| author | |
| committer | |
| log | 80c708a1fa8986bce0b2d1ba4d35e79d9e4094ba |
| tree | dcca28606d6c84ef8f5d2920203129b67387388d |
| parent | a434677eca737771dd64703545c13eb11ba92ce2 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Theo Browne <me@t3.gg>9 files changed, 245 insertions(+), 44 deletions(-)
apps/web/src/components/AppSidebarLayout.tsx+13-2| ... | ... | @@ -1,6 +1,8 @@ |
| 1 | 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | 2 | import * as Schema from "effect/Schema"; |
| 3 | 3 | import { |
| 4 | lazy, | |
| 5 | Suspense, | |
| 4 | 6 | useEffect, |
| 5 | 7 | useState, |
| 6 | 8 | useSyncExternalStore, |
| ... | ... | @@ -17,7 +19,6 @@ import { primaryServerKeybindingsAtom } from "../state/server"; |
| 17 | 19 | import { useEnvironmentIdentificationMode, useLegacySidebarEnabled } from "../hooks/useSettings"; |
| 18 | 20 | import LegacyThreadSidebar from "./LegacySidebar"; |
| 19 | 21 | import ThreadSidebar from "./Sidebar"; |
| 20 | import { SettingsSidebarNav } from "./settings/SettingsSidebarNav"; | |
| 21 | 22 | import { SidebarChromeHeader } from "./sidebar/SidebarChrome"; |
| 22 | 23 | import { |
| 23 | 24 | resolveSidebarStageFocusRingOffsetClass, |
| ... | ... | @@ -43,6 +44,14 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; |
| 43 | 44 | |
| 44 | 45 | const MACOS_TRAFFIC_LIGHTS_LEFT_INSET = "90px"; |
| 45 | 46 | |
| 47 | // The settings nav (and the Clerk profile surfaces behind it) only renders on | |
| 48 | // settings routes; lazy-loading it keeps that subtree out of the startup chunk. | |
| 49 | const SettingsSidebarNav = lazy(() => | |
| 50 | import("./settings/SettingsSidebarNav").then((module) => ({ | |
| 51 | default: module.SettingsSidebarNav, | |
| 52 | })), | |
| 53 | ); | |
| 54 | ||
| 46 | 55 | function subscribeToViewportWidth(onChange: () => void): () => void { |
| 47 | 56 | window.addEventListener("resize", onChange); |
| 48 | 57 | return () => window.removeEventListener("resize", onChange); |
| ... | ... | @@ -229,7 +238,9 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { |
| 229 | 238 | {isOnSettings ? ( |
| 230 | 239 | <> |
| 231 | 240 | <SidebarChromeHeader isElectron={isElectron} /> |
| 232 | <SettingsSidebarNav pathname={pathname} /> | |
| 241 | <Suspense fallback={null}> | |
| 242 | <SettingsSidebarNav pathname={pathname} /> | |
| 243 | </Suspense> | |
| 233 | 244 | </> |
| 234 | 245 | ) : legacySidebarEnabled ? ( |
| 235 | 246 | <LegacyThreadSidebar /> |
apps/web/src/components/clerk/BrowserManagedAuthShell.tsx created+25| ... | ... | @@ -0,0 +1,25 @@ |
| 1 | import { ClerkProvider } from "@clerk/react"; | |
| 2 | import type { ReactNode } from "react"; | |
| 3 | ||
| 4 | import { ManagedRelayAuthProvider } from "../../cloud/managedAuth"; | |
| 5 | import { clerkAppearance } from "./clerkAppearance"; | |
| 6 | ||
| 7 | /** | |
| 8 | * Browser half of the managed-auth boundary, loaded lazily from the entry so | |
| 9 | * cloudless local mode never downloads a Clerk runtime. The browser provider | |
| 10 | * stays small on its own: it hotloads clerk-js at runtime instead of bundling | |
| 11 | * it. | |
| 12 | */ | |
| 13 | export default function BrowserManagedAuthShell({ | |
| 14 | publishableKey, | |
| 15 | children, | |
| 16 | }: { | |
| 17 | readonly publishableKey: string; | |
| 18 | readonly children: ReactNode; | |
| 19 | }) { | |
| 20 | return ( | |
| 21 | <ClerkProvider appearance={clerkAppearance} publishableKey={publishableKey}> | |
| 22 | <ManagedRelayAuthProvider>{children}</ManagedRelayAuthProvider> | |
| 23 | </ClerkProvider> | |
| 24 | ); | |
| 25 | } |
apps/web/src/components/clerk/ElectronManagedAuthShell.tsx created+26| ... | ... | @@ -0,0 +1,26 @@ |
| 1 | import { passkeys } from "@clerk/electron/passkeys"; | |
| 2 | import { ClerkProvider } from "@clerk/electron/react"; | |
| 3 | import type { ReactNode } from "react"; | |
| 4 | ||
| 5 | import { ManagedRelayAuthProvider } from "../../cloud/managedAuth"; | |
| 6 | import { clerkAppearance } from "./clerkAppearance"; | |
| 7 | ||
| 8 | /** | |
| 9 | * Electron half of the managed-auth boundary. The Electron provider statically | |
| 10 | * bundles the full clerk-js runtime, so this module must only ever load | |
| 11 | * lazily, and only inside the desktop shell — importing it eagerly would put | |
| 12 | * clerk-js back into every client's startup graph. | |
| 13 | */ | |
| 14 | export default function ElectronManagedAuthShell({ | |
| 15 | publishableKey, | |
| 16 | children, | |
| 17 | }: { | |
| 18 | readonly publishableKey: string; | |
| 19 | readonly children: ReactNode; | |
| 20 | }) { | |
| 21 | return ( | |
| 22 | <ClerkProvider appearance={clerkAppearance} publishableKey={publishableKey} passkeys={passkeys}> | |
| 23 | <ManagedRelayAuthProvider>{children}</ManagedRelayAuthProvider> | |
| 24 | </ClerkProvider> | |
| 25 | ); | |
| 26 | } |
apps/web/src/components/settings/ThemeEditorHost.tsx+23-15| ... | ... | @@ -1,11 +1,17 @@ |
| 1 | import { useCallback } from "react"; | |
| 1 | import { lazy, Suspense, useCallback } from "react"; | |
| 2 | 2 | |
| 3 | 3 | import { useTheme } from "../../hooks/useTheme"; |
| 4 | 4 | import { getThemeDefinition, type ThemeAppearance, type ThemeDefinition } from "../../themePalette"; |
| 5 | 5 | import { stackedThreadToast, toastManager } from "../ui/toast"; |
| 6 | import { ThemeEditorPanel } from "./ThemeEditorPanel"; | |
| 7 | 6 | import { useThemeEditorStore } from "./themeEditorStore"; |
| 8 | 7 | |
| 8 | // The host mounts above the router on every page, but the editor body only | |
| 9 | // renders once a session opens; lazy-loading it keeps the editor UI out of | |
| 10 | // the startup chunk. | |
| 11 | const ThemeEditorPanel = lazy(() => | |
| 12 | import("./ThemeEditorPanel").then((module) => ({ default: module.ThemeEditorPanel })), | |
| 13 | ); | |
| 14 | ||
| 9 | 15 | /** |
| 10 | 16 | * Renders the theme editor above the router. The editor paints its draft on |
| 11 | 17 | * the live app, so it has to outlive the settings route: the point is to walk |
| ... | ... | @@ -97,18 +103,20 @@ export function ThemeEditorHost() { |
| 97 | 103 | const seedTheme = session.seedThemeId ? (getThemeDefinition(session.seedThemeId) ?? null) : null; |
| 98 | 104 | |
| 99 | 105 | return ( |
| 100 | <ThemeEditorPanel | |
| 101 | editingTheme={editingTheme} | |
| 102 | initialAppearance={session.initialAppearance} | |
| 103 | key={session.id} | |
| 104 | onOpenChange={(open) => { | |
| 105 | if (!open) closeThemeEditor(); | |
| 106 | }} | |
| 107 | onSaved={handleSaved} | |
| 108 | open | |
| 109 | restoreTheme={refreshTheme} | |
| 110 | seedName={session.seedName ?? undefined} | |
| 111 | seedTheme={seedTheme} | |
| 112 | /> | |
| 106 | <Suspense fallback={null}> | |
| 107 | <ThemeEditorPanel | |
| 108 | editingTheme={editingTheme} | |
| 109 | initialAppearance={session.initialAppearance} | |
| 110 | key={session.id} | |
| 111 | onOpenChange={(open) => { | |
| 112 | if (!open) closeThemeEditor(); | |
| 113 | }} | |
| 114 | onSaved={handleSaved} | |
| 115 | open | |
| 116 | restoreTheme={refreshTheme} | |
| 117 | seedName={session.seedName ?? undefined} | |
| 118 | seedTheme={seedTheme} | |
| 119 | /> | |
| 120 | </Suspense> | |
| 113 | 121 | ); |
| 114 | 122 | } |
apps/web/src/lib/chunkReloadGuard.test.ts created+56| ... | ... | @@ -0,0 +1,56 @@ |
| 1 | import { describe, expect, it, vi } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { clearChunkReloadGuard, reloadOnceForChunkLoadError } from "./chunkReloadGuard"; | |
| 4 | ||
| 5 | function createStorageStub(): Storage { | |
| 6 | const store = new Map<string, string>(); | |
| 7 | return { | |
| 8 | getItem: (key) => store.get(key) ?? null, | |
| 9 | setItem: (key, value) => { | |
| 10 | store.set(key, value); | |
| 11 | }, | |
| 12 | removeItem: (key) => { | |
| 13 | store.delete(key); | |
| 14 | }, | |
| 15 | clear: () => store.clear(), | |
| 16 | key: (index) => [...store.keys()][index] ?? null, | |
| 17 | get length() { | |
| 18 | return store.size; | |
| 19 | }, | |
| 20 | }; | |
| 21 | } | |
| 22 | ||
| 23 | describe("reloadOnceForChunkLoadError", () => { | |
| 24 | it("reloads on the first failure and lets the second one surface", () => { | |
| 25 | const storage = createStorageStub(); | |
| 26 | const reload = vi.fn(); | |
| 27 | ||
| 28 | expect(reloadOnceForChunkLoadError(() => storage, reload)).toBe(true); | |
| 29 | expect(reload).toHaveBeenCalledTimes(1); | |
| 30 | ||
| 31 | expect(reloadOnceForChunkLoadError(() => storage, reload)).toBe(false); | |
| 32 | expect(reload).toHaveBeenCalledTimes(1); | |
| 33 | }); | |
| 34 | ||
| 35 | it("reloads again after a successful boot cleared the guard", () => { | |
| 36 | const storage = createStorageStub(); | |
| 37 | const reload = vi.fn(); | |
| 38 | ||
| 39 | reloadOnceForChunkLoadError(() => storage, reload); | |
| 40 | clearChunkReloadGuard(() => storage); | |
| 41 | ||
| 42 | expect(reloadOnceForChunkLoadError(() => storage, reload)).toBe(true); | |
| 43 | expect(reload).toHaveBeenCalledTimes(2); | |
| 44 | }); | |
| 45 | ||
| 46 | it("never reloads when storage is blocked, so a persistent failure cannot loop", () => { | |
| 47 | const reload = vi.fn(); | |
| 48 | const blocked = () => { | |
| 49 | throw new DOMException("blocked", "SecurityError"); | |
| 50 | }; | |
| 51 | ||
| 52 | expect(reloadOnceForChunkLoadError(blocked, reload)).toBe(false); | |
| 53 | expect(reload).not.toHaveBeenCalled(); | |
| 54 | expect(() => clearChunkReloadGuard(blocked)).not.toThrow(); | |
| 55 | }); | |
| 56 | }); |
apps/web/src/lib/chunkReloadGuard.ts created+39| ... | ... | @@ -0,0 +1,39 @@ |
| 1 | // Split chunks are fetched lazily, so a deploy (or desktop server swap) | |
| 2 | // between page load and a later fetch can 404 the old hashed assets. One | |
| 3 | // reload picks up the fresh index.html. A sessionStorage flag keeps a | |
| 4 | // persistent failure from becoming a reload loop, and a successful boot clears | |
| 5 | // it so the next stale deploy gets its own single reload. | |
| 6 | const CHUNK_RELOAD_GUARD_KEY = "t3code:chunk-load-reloaded"; | |
| 7 | ||
| 8 | /** | |
| 9 | * Called from the `vite:preloadError` listener. Reloads at most once per | |
| 10 | * failure streak and returns whether it did, so the caller knows whether to | |
| 11 | * swallow the event or let the error surface through the normal paths. | |
| 12 | */ | |
| 13 | export function reloadOnceForChunkLoadError( | |
| 14 | getStorage: () => Storage = () => window.sessionStorage, | |
| 15 | reload: () => void = () => window.location.reload(), | |
| 16 | ): boolean { | |
| 17 | let alreadyReloaded: boolean; | |
| 18 | try { | |
| 19 | const storage = getStorage(); | |
| 20 | alreadyReloaded = storage.getItem(CHUNK_RELOAD_GUARD_KEY) === "1"; | |
| 21 | if (!alreadyReloaded) storage.setItem(CHUNK_RELOAD_GUARD_KEY, "1"); | |
| 22 | } catch { | |
| 23 | // Without storage the guard cannot survive a reload, so a persistent | |
| 24 | // failure would loop forever. Let the error surface instead. | |
| 25 | return false; | |
| 26 | } | |
| 27 | if (alreadyReloaded) return false; | |
| 28 | reload(); | |
| 29 | return true; | |
| 30 | } | |
| 31 | ||
| 32 | /** Clears the guard after a successful boot so a later stale deploy can reload again. */ | |
| 33 | export function clearChunkReloadGuard(getStorage: () => Storage = () => window.sessionStorage) { | |
| 34 | try { | |
| 35 | getStorage().removeItem(CHUNK_RELOAD_GUARD_KEY); | |
| 36 | } catch { | |
| 37 | // Blocked storage never held the flag. | |
| 38 | } | |
| 39 | } |
apps/web/src/main.tsx+55-26| ... | ... | @@ -1,14 +1,10 @@ |
| 1 | 1 | import React from "react"; |
| 2 | 2 | import ReactDOM from "react-dom/client"; |
| 3 | import { ClerkProvider } from "@clerk/react"; | |
| 4 | import { passkeys } from "@clerk/electron/passkeys"; | |
| 5 | import { ClerkProvider as ElectronClerkProvider } from "@clerk/electron/react"; | |
| 6 | 3 | import { createHashHistory, createBrowserHistory } from "@tanstack/react-router"; |
| 7 | 4 | |
| 8 | 5 | import "./index.css"; |
| 9 | 6 | |
| 10 | 7 | import { isElectron } from "./env"; |
| 11 | import { ManagedRelayAuthProvider } from "./cloud/managedAuth"; | |
| 12 | 8 | import { hasCloudPublicConfig } from "./cloud/publicConfig"; |
| 13 | 9 | import { getRouter } from "./router"; |
| 14 | 10 | import { |
| ... | ... | @@ -16,7 +12,7 @@ import { |
| 16 | 12 | syncDocumentWindowControlsOverlayClass, |
| 17 | 13 | } from "./lib/windowControlsOverlay"; |
| 18 | 14 | import { AppRoot } from "./AppRoot"; |
| 19 | import { clerkAppearance } from "./components/clerk/clerkAppearance"; | |
| 15 | import { clearChunkReloadGuard, reloadOnceForChunkLoadError } from "./lib/chunkReloadGuard"; | |
| 20 | 16 | |
| 21 | 17 | // Electron loads the app from a file-backed shell, so hash history avoids path resolution issues. |
| 22 | 18 | const history = isElectron ? createHashHistory() : createBrowserHistory(); |
| ... | ... | @@ -30,26 +26,59 @@ if (isElectron) { |
| 30 | 26 | |
| 31 | 27 | const clerkPublishableKey = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY as string | undefined; |
| 32 | 28 | |
| 29 | // A failed split-chunk fetch usually means the hashed assets went stale under | |
| 30 | // a deploy; one guarded reload picks up the fresh index.html. | |
| 31 | let chunkLoadFailed = false; | |
| 32 | let reloadScheduled = false; | |
| 33 | window.addEventListener("vite:preloadError", (event) => { | |
| 34 | chunkLoadFailed = true; | |
| 35 | if (reloadOnceForChunkLoadError()) { | |
| 36 | reloadScheduled = true; | |
| 37 | event.preventDefault(); | |
| 38 | } | |
| 39 | }); | |
| 40 | ||
| 33 | 41 | const app = <AppRoot router={router} />; |
| 34 | 42 | |
| 35 | ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( | |
| 36 | <React.StrictMode> | |
| 37 | {clerkPublishableKey && hasCloudPublicConfig() ? ( | |
| 38 | isElectron ? ( | |
| 39 | <ElectronClerkProvider | |
| 40 | appearance={clerkAppearance} | |
| 41 | publishableKey={clerkPublishableKey} | |
| 42 | passkeys={passkeys} | |
| 43 | > | |
| 44 | <ManagedRelayAuthProvider>{app}</ManagedRelayAuthProvider> | |
| 45 | </ElectronClerkProvider> | |
| 46 | ) : ( | |
| 47 | <ClerkProvider appearance={clerkAppearance} publishableKey={clerkPublishableKey}> | |
| 48 | <ManagedRelayAuthProvider>{app}</ManagedRelayAuthProvider> | |
| 49 | </ClerkProvider> | |
| 50 | ) | |
| 51 | ) : ( | |
| 52 | app | |
| 53 | )} | |
| 54 | </React.StrictMode>, | |
| 55 | ); | |
| 43 | // Managed auth is cloud-only, and the Electron Clerk provider bundles the full | |
| 44 | // clerk-js runtime. Loading only the selected runtime as a split chunk keeps | |
| 45 | // every Clerk byte out of the startup graph for local-mode users, and keeps | |
| 46 | // the bundled clerk-js out of the browser build entirely. | |
| 47 | const managedAuthShellModule = | |
| 48 | clerkPublishableKey && hasCloudPublicConfig() | |
| 49 | ? isElectron | |
| 50 | ? import("./components/clerk/ElectronManagedAuthShell") | |
| 51 | : import("./components/clerk/BrowserManagedAuthShell") | |
| 52 | : null; | |
| 53 | ||
| 54 | // The index.html boot splash lives inside #root, and React's first commit | |
| 55 | // clears it. Resolve everything that first commit needs, the selected | |
| 56 | // managed-auth runtime and the initial route's split chunks, before | |
| 57 | // rendering, so the splash holds until real UI paints instead of dropping to | |
| 58 | // a blank window while chunks download. | |
| 59 | void Promise.all([managedAuthShellModule?.then((module) => module.default) ?? null, router.load()]) | |
| 60 | .then(([ManagedAuthShell]) => { | |
| 61 | // A route chunk failure still resolves router.load(): the error is parked in | |
| 62 | // the lazy component and surfaces through the route error boundary. Skip the | |
| 63 | // paint when a reload is on its way, and only re-arm the guard after a boot | |
| 64 | // that fetched every chunk it asked for. | |
| 65 | if (reloadScheduled) return; | |
| 66 | if (!chunkLoadFailed) clearChunkReloadGuard(); | |
| 67 | ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( | |
| 68 | <React.StrictMode> | |
| 69 | {ManagedAuthShell && clerkPublishableKey ? ( | |
| 70 | <ManagedAuthShell publishableKey={clerkPublishableKey}>{app}</ManagedAuthShell> | |
| 71 | ) : ( | |
| 72 | app | |
| 73 | )} | |
| 74 | </React.StrictMode>, | |
| 75 | ); | |
| 76 | }) | |
| 77 | .catch((error: unknown) => { | |
| 78 | // The auth shell chunk failed and the guarded reload is spent. Say so | |
| 79 | // instead of leaving the splash up forever. | |
| 80 | if (reloadScheduled) return; | |
| 81 | console.error("T3 Code failed to load its startup chunks.", error); | |
| 82 | const bootShell = document.getElementById("boot-shell"); | |
| 83 | if (bootShell) bootShell.textContent = "T3 Code could not load. Reload to try again."; | |
| 84 | }); |
apps/web/src/router.ts+4| ... | ... | @@ -7,6 +7,10 @@ export function getRouter(history: RouterHistory) { |
| 7 | 7 | routeTree, |
| 8 | 8 | history, |
| 9 | 9 | context: {}, |
| 10 | // Route components are split chunks (autoCodeSplitting in vite.config); | |
| 11 | // fetching them on hover/focus intent hides the load from the first | |
| 12 | // settings or pull-request navigation. | |
| 13 | defaultPreload: "intent", | |
| 10 | 14 | }); |
| 11 | 15 | } |
| 12 | 16 |
apps/web/vite.config.ts+4-1| ... | ... | @@ -156,7 +156,10 @@ export default defineConfig(() => { |
| 156 | 156 | assetsInclude: ["**/*.wasm"], |
| 157 | 157 | plugins: [ |
| 158 | 158 | devCompressionPlugin(), |
| 159 | tanstackRouter(), | |
| 159 | // Route components load as split chunks so settings, pull-request, and | |
| 160 | // usage code stay out of the cold-start payload; the router prefetches | |
| 161 | // them on navigation intent (see getRouter's defaultPreload). | |
| 162 | tanstackRouter({ autoCodeSplitting: true }), | |
| 160 | 163 | react(), |
| 161 | 164 | babel({ |
| 162 | 165 | // We need to be explicit about the parser options after moving to @vitejs/plugin-react v6.0.0 |