# Frontend Architecture Map React 19 + React Router 7, served by `vite-express` from the same Node process that serves the API. No SSR, no state library, no CSS framework. Roughly 73 JSX files plus a single 9070-line `src/index.css`, on top of the vendored design primitives (section 6). --- ## 1. Routing and code splitting `src/main.jsx` mounts, `src/App.jsx` routes. Only `HomePage` is imported eagerly; **eleven pages are `lazy()`-loaded** behind one `}>`, and the whole thing sits inside a single ``. **`ErrorBoundary` (`src/components/ErrorBoundary.jsx`) is the only one in the app,** wrapping `Suspense` in `App.jsx`. Before it existed, any render throw unmounted the React root and left a blank page with no message and no way back except a manual reload; that is what turned the editor teardown race into a total outage (`open-questions.md` B11) and what would have made a null workspace owner take down the admin console. It renders a message plus "Reload the page" and "Try again", and logs with the project's `[timestamp] Unhandled render error:` format. Note what it cannot do: React error boundaries never see errors from event handlers, async callbacks, or `setTimeout`. It is a floor under render errors, not a general catch-all. | Route | Page | Notes | |---|---|---| | `/` | HomePage | eager | | `/reset-password` | ResetPasswordPage | | | `/editor/:logId` | Editor | wrapped in `MobileEditorGuard`; the page wraps its `EditorView` in `Std_Layout`, which `ArchiveView` also embeds it in | | `/account`, `/settings` → `/account` | AccountSettings | `/settings` is a redirect | | `/archives`, `/archives/:archiveId` | ArchivesPage | | | `/archives/:archiveId/doc/:logId`, `/archives/:archiveId/doc` | ArchiveView | | | `/workspaces`, `/workspaces/:workspaceId` | WorkspacesPage | | | `/github`, `/github/:owner/:repo` | GitHubPage | | | `/admin` | AdminPage | | | `/notifications`, `/notifications/preferences` | NotificationsPage, NotificationPreferences | | | `/activity`, `/activity/workspace/:workspaceId` | WorkspaceActivity | | | `/404`, `*` | NotFoundPage | catch-all redirects to `/404`; a standalone card (`src/pages/NotFoundPage.jsx`) | `MobileEditorGuard` (`App.jsx:43-47`) checks `window.matchMedia('(max-width: 768px)')` **once at render** and redirects to `/`. It does not subscribe to the media query, so resizing a desktop window below 768px does not evict an open editor. On mobile, documents are read-only through `ArchiveView`. There is no route-level auth guard. Pages check auth themselves, and the API returns 401 regardless. ### Vite `manualChunks` (`vite.config.js:15-38`) Five named vendor chunks, split by what a page actually needs: ``` vendor-react react, react-dom, react-router every page vendor-tiptap @tiptap/*, prosemirror Editor, ArchiveView vendor-highlight lowlight, highlight.js editor code blocks vendor-yjs yjs, y-protocols, lib0 collab pages vendor-markup marked, turndown, dompurify import/export paths ``` **Before adding a heavy frontend dependency, add it here**, or it lands in the default chunk and every page pays for it. Anything not matched falls through to Rollup's default chunking. ## 2. The API layer: `src/util.jsx` 727 lines, and the single place any component should talk to the server from. `apiFetch(method, url, data)` (`util.jsx:28-52`) reads the session token from the cookie via `getSessionTokenFromCookie()` (`util.jsx:661`), sets `Authorization: Bearer`, JSON-encodes the body for non-GET, and on a non-2xx throws an `Error` carrying `.status` and `.body`. `getErrorMessage(err)` (`util.jsx:102`) is the standard way to render that. A 401 `Invalid or expired session` on a request that carried a cookie is the one dead-session refusal: apiFetch forgets the cached user and announces it on the window (`src/lib/session-ended.js`, `c2:session-ended`), and `Std_Layout` runs the signed-out front door (section 3c) at once, the same as a cookie-less visit. Other 401s stay with their caller. `setSessionCookie(token)` (`util.jsx:676`) is the one client-side writer of the session cookie: `__Host-sessionToken` with `secure` on an https page, `sessionToken` without it on plain http (a browser keeps neither a `__Host-` nor a `Secure` cookie there), both `path=/`, seven-day `max-age`, `samesite=strict`, never a `Domain`. Sign-in (`Login.jsx`, all three success paths) and the account panel's session rotation both call it, so a token rotated after an email or password change lives exactly as long as a fresh sign-in's. Write the cookie any other way and the two drift. `clearSessionCookie()` (`util.jsx:688`) expires both names, and sign-out (`src/lib/sign-out.js`, `signOut()`, moved from the account panel in W6-CDX-40) goes through it. `getSessionTokenFromCookie()` prefers the prefixed name and, on an https page, does not read a lone legacy `sessionToken` at all, since another host could have planted it. `upgradeLegacySessionCookie()` (`util.jsx:706`) moves a real one across: `main.jsx` awaits it before the first render, it asks `POST /api/validate-session` with `legacyCookie: true`, and it rewrites the token under the prefixed name only on a yes. It gives up after 5 seconds, so a silent server cannot hold the first render, and remembers a refusal for the tab. Every cookie name is matched exactly by `readCookie` (`util.jsx:645`), which strips only ASCII space and tab, never Unicode whitespace (W6-CDX-3; [request-lifecycle.md](request-lifecycle.md) section 3, "The session cookie"). `serverReq` (`util.jsx:62`) is the legacy predecessor. It does **not** attach auth. Do not use it in new code; it exists for the handful of pre-auth calls. Below the two primitives, `util.jsx` is a flat catalogue of ~130 named wrappers grouped by area: workspaces (`:117`), archive repos (`:124`), GitHub link and sync (`:134`), PR and issue (`:145`), CI and releases (`:166`), squads (`:183`), permissions (`:191`), members (`:199`), invitations (`:206`), admin (`:218`), search and browse (`:241`), watches (`:265`), activity (`:275`), notifications (`:291`), favorites (`:307`), comments (`:315`), archives (`:332`), logs (`:346`), documents and versions (`:356`). **Add new API calls here as a named export**, next to their neighbours, rather than calling `fetch` from a component. Two paths bypass `apiFetch` deliberately because they are not JSON: `uploadDocument` (`util.jsx:379`, multipart) and `exportDocument` (`util.jsx:415`, blob download). `exportDocument(..., 'pdf')` is the exception inside the exception: it opens an `about:blank` window, writes the sanitized document into it, and **prints it from the opener** (`printWindow.print()` on the window's `load`, and `close()` on its `afterprint`). It must not write a `