authorgravatar for 71498452+StiensWout@users.noreply.github.comWout Stiens <71498452+StiensWout@users.noreply.github.com> 2026-09-02 03:51:00+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-01 18:51:00-07:00
log80c708a1fa8986bce0b2d1ba4d35e79d9e4094ba
treedcca28606d6c84ef8f5d2920203129b67387388d
parenta434677eca737771dd64703545c13eb11ba92ce2
signature Signed by PGP key B5690EEEBB952194

perf(web): halve the cold-start bundle by splitting Clerk and cold routes (#9058)

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,6 +1,8 @@
1import { useAtomValue } from "@effect/atom-react";1import { useAtomValue } from "@effect/atom-react";
2import * as Schema from "effect/Schema";2import * as Schema from "effect/Schema";
3import {3import {
4 lazy,
5 Suspense,
4 useEffect,6 useEffect,
5 useState,7 useState,
6 useSyncExternalStore,8 useSyncExternalStore,
...@@ -17,7 +19,6 @@ import { primaryServerKeybindingsAtom } from "../state/server";...@@ -17,7 +19,6 @@ import { primaryServerKeybindingsAtom } from "../state/server";
17import { useEnvironmentIdentificationMode, useLegacySidebarEnabled } from "../hooks/useSettings";19import { useEnvironmentIdentificationMode, useLegacySidebarEnabled } from "../hooks/useSettings";
18import LegacyThreadSidebar from "./LegacySidebar";20import LegacyThreadSidebar from "./LegacySidebar";
19import ThreadSidebar from "./Sidebar";21import ThreadSidebar from "./Sidebar";
20import { SettingsSidebarNav } from "./settings/SettingsSidebarNav";
21import { SidebarChromeHeader } from "./sidebar/SidebarChrome";22import { SidebarChromeHeader } from "./sidebar/SidebarChrome";
22import {23import {
23 resolveSidebarStageFocusRingOffsetClass,24 resolveSidebarStageFocusRingOffsetClass,
...@@ -43,6 +44,14 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";...@@ -43,6 +44,14 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
4344
44const MACOS_TRAFFIC_LIGHTS_LEFT_INSET = "90px";45const MACOS_TRAFFIC_LIGHTS_LEFT_INSET = "90px";
4546
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.
49const SettingsSidebarNav = lazy(() =>
50 import("./settings/SettingsSidebarNav").then((module) => ({
51 default: module.SettingsSidebarNav,
52 })),
53);
54
46function subscribeToViewportWidth(onChange: () => void): () => void {55function subscribeToViewportWidth(onChange: () => void): () => void {
47 window.addEventListener("resize", onChange);56 window.addEventListener("resize", onChange);
48 return () => window.removeEventListener("resize", onChange);57 return () => window.removeEventListener("resize", onChange);
...@@ -229,7 +238,9 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {...@@ -229,7 +238,9 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {
229 {isOnSettings ? (238 {isOnSettings ? (
230 <>239 <>
231 <SidebarChromeHeader isElectron={isElectron} />240 <SidebarChromeHeader isElectron={isElectron} />
232 <SettingsSidebarNav pathname={pathname} />241 <Suspense fallback={null}>
242 <SettingsSidebarNav pathname={pathname} />
243 </Suspense>
233 </>244 </>
234 ) : legacySidebarEnabled ? (245 ) : legacySidebarEnabled ? (
235 <LegacyThreadSidebar />246 <LegacyThreadSidebar />
apps/web/src/components/clerk/BrowserManagedAuthShell.tsx created+25
...@@ -0,0 +1,25 @@
1import { ClerkProvider } from "@clerk/react";
2import type { ReactNode } from "react";
3
4import { ManagedRelayAuthProvider } from "../../cloud/managedAuth";
5import { 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 */
13export 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 @@
1import { passkeys } from "@clerk/electron/passkeys";
2import { ClerkProvider } from "@clerk/electron/react";
3import type { ReactNode } from "react";
4
5import { ManagedRelayAuthProvider } from "../../cloud/managedAuth";
6import { 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 */
14export 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,11 +1,17 @@
1import { useCallback } from "react";1import { lazy, Suspense, useCallback } from "react";
22
3import { useTheme } from "../../hooks/useTheme";3import { useTheme } from "../../hooks/useTheme";
4import { getThemeDefinition, type ThemeAppearance, type ThemeDefinition } from "../../themePalette";4import { getThemeDefinition, type ThemeAppearance, type ThemeDefinition } from "../../themePalette";
5import { stackedThreadToast, toastManager } from "../ui/toast";5import { stackedThreadToast, toastManager } from "../ui/toast";
6import { ThemeEditorPanel } from "./ThemeEditorPanel";
7import { useThemeEditorStore } from "./themeEditorStore";6import { useThemeEditorStore } from "./themeEditorStore";
87
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.
11const ThemeEditorPanel = lazy(() =>
12 import("./ThemeEditorPanel").then((module) => ({ default: module.ThemeEditorPanel })),
13);
14
9/**15/**
10 * Renders the theme editor above the router. The editor paints its draft on16 * Renders the theme editor above the router. The editor paints its draft on
11 * the live app, so it has to outlive the settings route: the point is to walk17 * the live app, so it has to outlive the settings route: the point is to walk
...@@ -97,18 +103,20 @@ export function ThemeEditorHost() {...@@ -97,18 +103,20 @@ export function ThemeEditorHost() {
97 const seedTheme = session.seedThemeId ? (getThemeDefinition(session.seedThemeId) ?? null) : null;103 const seedTheme = session.seedThemeId ? (getThemeDefinition(session.seedThemeId) ?? null) : null;
98104
99 return (105 return (
100 <ThemeEditorPanel106 <Suspense fallback={null}>
101 editingTheme={editingTheme}107 <ThemeEditorPanel
102 initialAppearance={session.initialAppearance}108 editingTheme={editingTheme}
103 key={session.id}109 initialAppearance={session.initialAppearance}
104 onOpenChange={(open) => {110 key={session.id}
105 if (!open) closeThemeEditor();111 onOpenChange={(open) => {
106 }}112 if (!open) closeThemeEditor();
107 onSaved={handleSaved}113 }}
108 open114 onSaved={handleSaved}
109 restoreTheme={refreshTheme}115 open
110 seedName={session.seedName ?? undefined}116 restoreTheme={refreshTheme}
111 seedTheme={seedTheme}117 seedName={session.seedName ?? undefined}
112 />118 seedTheme={seedTheme}
119 />
120 </Suspense>
113 );121 );
114}122}
apps/web/src/lib/chunkReloadGuard.test.ts created+56
...@@ -0,0 +1,56 @@
1import { describe, expect, it, vi } from "vite-plus/test";
2
3import { clearChunkReloadGuard, reloadOnceForChunkLoadError } from "./chunkReloadGuard";
4
5function 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
23describe("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.
6const 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 */
13export 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. */
33export 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,14 +1,10 @@
1import React from "react";1import React from "react";
2import ReactDOM from "react-dom/client";2import ReactDOM from "react-dom/client";
3import { ClerkProvider } from "@clerk/react";
4import { passkeys } from "@clerk/electron/passkeys";
5import { ClerkProvider as ElectronClerkProvider } from "@clerk/electron/react";
6import { createHashHistory, createBrowserHistory } from "@tanstack/react-router";3import { createHashHistory, createBrowserHistory } from "@tanstack/react-router";
74
8import "./index.css";5import "./index.css";
96
10import { isElectron } from "./env";7import { isElectron } from "./env";
11import { ManagedRelayAuthProvider } from "./cloud/managedAuth";
12import { hasCloudPublicConfig } from "./cloud/publicConfig";8import { hasCloudPublicConfig } from "./cloud/publicConfig";
13import { getRouter } from "./router";9import { getRouter } from "./router";
14import {10import {
...@@ -16,7 +12,7 @@ import {...@@ -16,7 +12,7 @@ import {
16 syncDocumentWindowControlsOverlayClass,12 syncDocumentWindowControlsOverlayClass,
17} from "./lib/windowControlsOverlay";13} from "./lib/windowControlsOverlay";
18import { AppRoot } from "./AppRoot";14import { AppRoot } from "./AppRoot";
19import { clerkAppearance } from "./components/clerk/clerkAppearance";15import { clearChunkReloadGuard, reloadOnceForChunkLoadError } from "./lib/chunkReloadGuard";
2016
21// Electron loads the app from a file-backed shell, so hash history avoids path resolution issues.17// Electron loads the app from a file-backed shell, so hash history avoids path resolution issues.
22const history = isElectron ? createHashHistory() : createBrowserHistory();18const history = isElectron ? createHashHistory() : createBrowserHistory();
...@@ -30,26 +26,59 @@ if (isElectron) {...@@ -30,26 +26,59 @@ if (isElectron) {
3026
31const clerkPublishableKey = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY as string | undefined;27const clerkPublishableKey = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY as string | undefined;
3228
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.
31let chunkLoadFailed = false;
32let reloadScheduled = false;
33window.addEventListener("vite:preloadError", (event) => {
34 chunkLoadFailed = true;
35 if (reloadOnceForChunkLoadError()) {
36 reloadScheduled = true;
37 event.preventDefault();
38 }
39});
40
33const app = <AppRoot router={router} />;41const app = <AppRoot router={router} />;
3442
35ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(43// Managed auth is cloud-only, and the Electron Clerk provider bundles the full
36 <React.StrictMode>44// clerk-js runtime. Loading only the selected runtime as a split chunk keeps
37 {clerkPublishableKey && hasCloudPublicConfig() ? (45// every Clerk byte out of the startup graph for local-mode users, and keeps
38 isElectron ? (46// the bundled clerk-js out of the browser build entirely.
39 <ElectronClerkProvider47const managedAuthShellModule =
40 appearance={clerkAppearance}48 clerkPublishableKey && hasCloudPublicConfig()
41 publishableKey={clerkPublishableKey}49 ? isElectron
42 passkeys={passkeys}50 ? import("./components/clerk/ElectronManagedAuthShell")
43 >51 : import("./components/clerk/BrowserManagedAuthShell")
44 <ManagedRelayAuthProvider>{app}</ManagedRelayAuthProvider>52 : null;
45 </ElectronClerkProvider>53
46 ) : (54// The index.html boot splash lives inside #root, and React's first commit
47 <ClerkProvider appearance={clerkAppearance} publishableKey={clerkPublishableKey}>55// clears it. Resolve everything that first commit needs, the selected
48 <ManagedRelayAuthProvider>{app}</ManagedRelayAuthProvider>56// managed-auth runtime and the initial route's split chunks, before
49 </ClerkProvider>57// rendering, so the splash holds until real UI paints instead of dropping to
50 )58// a blank window while chunks download.
51 ) : (59void Promise.all([managedAuthShellModule?.then((module) => module.default) ?? null, router.load()])
52 app60 .then(([ManagedAuthShell]) => {
53 )}61 // A route chunk failure still resolves router.load(): the error is parked in
54 </React.StrictMode>,62 // the lazy component and surfaces through the route error boundary. Skip the
55);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,6 +7,10 @@ export function getRouter(history: RouterHistory) {
7 routeTree,7 routeTree,
8 history,8 history,
9 context: {},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}
1216
apps/web/vite.config.ts+4-1
...@@ -156,7 +156,10 @@ export default defineConfig(() => {...@@ -156,7 +156,10 @@ export default defineConfig(() => {
156 assetsInclude: ["**/*.wasm"],156 assetsInclude: ["**/*.wasm"],
157 plugins: [157 plugins: [
158 devCompressionPlugin(),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 react(),163 react(),
161 babel({164 babel({
162 // We need to be explicit about the parser options after moving to @vitejs/plugin-react v6.0.0165 // We need to be explicit about the parser options after moving to @vitejs/plugin-react v6.0.0