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 @@
11import { useAtomValue } from "@effect/atom-react";
22import * as Schema from "effect/Schema";
33import {
4 lazy,
5 Suspense,
46 useEffect,
57 useState,
68 useSyncExternalStore,
......@@ -17,7 +19,6 @@ import { primaryServerKeybindingsAtom } from "../state/server";
1719import { useEnvironmentIdentificationMode, useLegacySidebarEnabled } from "../hooks/useSettings";
1820import LegacyThreadSidebar from "./LegacySidebar";
1921import ThreadSidebar from "./Sidebar";
20import { SettingsSidebarNav } from "./settings/SettingsSidebarNav";
2122import { SidebarChromeHeader } from "./sidebar/SidebarChrome";
2223import {
2324 resolveSidebarStageFocusRingOffsetClass,
......@@ -43,6 +44,14 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
4344
4445const 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
4655function subscribeToViewportWidth(onChange: () => void): () => void {
4756 window.addEventListener("resize", onChange);
4857 return () => window.removeEventListener("resize", onChange);
......@@ -229,7 +238,9 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {
229238 {isOnSettings ? (
230239 <>
231240 <SidebarChromeHeader isElectron={isElectron} />
232 <SettingsSidebarNav pathname={pathname} />
241 <Suspense fallback={null}>
242 <SettingsSidebarNav pathname={pathname} />
243 </Suspense>
233244 </>
234245 ) : legacySidebarEnabled ? (
235246 <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 @@
1import { useCallback } from "react";
1import { lazy, Suspense, useCallback } from "react";
22
33import { useTheme } from "../../hooks/useTheme";
44import { getThemeDefinition, type ThemeAppearance, type ThemeDefinition } from "../../themePalette";
55import { stackedThreadToast, toastManager } from "../ui/toast";
6import { ThemeEditorPanel } from "./ThemeEditorPanel";
76import { 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
915/**
1016 * Renders the theme editor above the router. The editor paints its draft on
1117 * the live app, so it has to outlive the settings route: the point is to walk
......@@ -97,18 +103,20 @@ export function ThemeEditorHost() {
97103 const seedTheme = session.seedThemeId ? (getThemeDefinition(session.seedThemeId) ?? null) : null;
98104
99105 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>
113121 );
114122}
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 @@
11import React from "react";
22import ReactDOM from "react-dom/client";
3import { ClerkProvider } from "@clerk/react";
4import { passkeys } from "@clerk/electron/passkeys";
5import { ClerkProvider as ElectronClerkProvider } from "@clerk/electron/react";
63import { createHashHistory, createBrowserHistory } from "@tanstack/react-router";
74
85import "./index.css";
96
107import { isElectron } from "./env";
11import { ManagedRelayAuthProvider } from "./cloud/managedAuth";
128import { hasCloudPublicConfig } from "./cloud/publicConfig";
139import { getRouter } from "./router";
1410import {
......@@ -16,7 +12,7 @@ import {
1612 syncDocumentWindowControlsOverlayClass,
1713} from "./lib/windowControlsOverlay";
1814import { AppRoot } from "./AppRoot";
19import { clerkAppearance } from "./components/clerk/clerkAppearance";
15import { clearChunkReloadGuard, reloadOnceForChunkLoadError } from "./lib/chunkReloadGuard";
2016
2117// Electron loads the app from a file-backed shell, so hash history avoids path resolution issues.
2218const history = isElectron ? createHashHistory() : createBrowserHistory();
......@@ -30,26 +26,59 @@ if (isElectron) {
3026
3127const 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
3341const app = <AppRoot router={router} />;
3442
35ReactDOM.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.
47const 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.
59void 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) {
77 routeTree,
88 history,
99 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",
1014 });
1115}
1216
apps/web/vite.config.ts+4-1
......@@ -156,7 +156,10 @@ export default defineConfig(() => {
156156 assetsInclude: ["**/*.wasm"],
157157 plugins: [
158158 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 }),
160163 react(),
161164 babel({
162165 // We need to be explicit about the parser options after moving to @vitejs/plugin-react v6.0.0