diff --git a/apps/web/src/bootstrap.test.ts b/apps/web/src/bootstrap.test.ts new file mode 100644 index 0000000000000..d2909d28e5c41 --- /dev/null +++ b/apps/web/src/bootstrap.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it, vi } from "vite-plus/test"; + +const mocks = vi.hoisted(() => ({ showBootError: vi.fn().mockName("showBootError") })); + +vi.mock("./lib/bootError", () => ({ showBootError: mocks.showBootError })); + +// Mirrors Vite's build preload helper when main's CSS or JS chunk fails to load. +vi.mock("./main", () => { + const error = new TypeError("Failed to fetch dynamically imported module: /assets/main.js"); + const event = Object.assign(new Event("vite:preloadError", { cancelable: true }), { + payload: error, + }); + window.dispatchEvent(event); + throw error; +}); + +function createStorageStub(initial: Record = {}): Storage { + const store = new Map(Object.entries(initial)); + return { + getItem: (key) => store.get(key) ?? null, + setItem: (key, value) => { + store.set(key, value); + }, + removeItem: (key) => { + store.delete(key); + }, + clear: () => store.clear(), + key: (index) => [...store.keys()][index] ?? null, + get length() { + return store.size; + }, + }; +} + +async function bootWithFailingMainChunk(storage: Storage) { + vi.resetModules(); + mocks.showBootError.mockClear(); + const reload = vi.fn().mockName("location.reload"); + vi.stubGlobal( + "window", + Object.assign(new EventTarget(), { sessionStorage: storage, location: { reload } }), + ); + try { + const { boot } = await import("./bootstrap"); + await boot; + return { reload, showBootError: mocks.showBootError }; + } finally { + vi.unstubAllGlobals(); + } +} + +describe("bootstrap", () => { + it("reloads once instead of showing the boot error when the main chunk fails to load", async () => { + const { reload, showBootError } = await bootWithFailingMainChunk(createStorageStub()); + + expect(reload).toHaveBeenCalledTimes(1); + expect(showBootError).not.toHaveBeenCalled(); + }); + + it("shows the boot error when the main chunk fails again after the reload", async () => { + const { reload, showBootError } = await bootWithFailingMainChunk( + createStorageStub({ "t3code:chunk-load-reloaded": "1" }), + ); + + expect(reload).not.toHaveBeenCalled(); + expect(showBootError).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/web/src/bootstrap.ts b/apps/web/src/bootstrap.ts index b6f422c5d68f4..fe07e58b02872 100644 --- a/apps/web/src/bootstrap.ts +++ b/apps/web/src/bootstrap.ts @@ -1,4 +1,11 @@ import { showBootError } from "./lib/bootError"; +import { chunkLoadState, handleChunkLoadErrors } from "./lib/chunkReloadGuard"; + +handleChunkLoadErrors(); // Shared dev chunks need the React refresh preamble first. Catch failures before React mounts. -void import("./main").then(({ startup }) => startup).catch(showBootError); +export const boot = import("./main") + .then(({ startup }) => startup) + .catch((error: unknown) => { + if (!chunkLoadState.reloadScheduled) showBootError(error); + }); diff --git a/apps/web/src/bundledDev.test.ts b/apps/web/src/bundledDev.test.ts index 770b26e0a686b..5517910d754db 100644 --- a/apps/web/src/bundledDev.test.ts +++ b/apps/web/src/bundledDev.test.ts @@ -26,7 +26,12 @@ it("initializes React refresh before a shared UI chunk runs in bundled dev", asy try { await NodeFSP.mkdir(NodePath.join(root, "src/lib"), { recursive: true }); await NodeFSP.writeFile(NodePath.join(root, "package.json"), '{"type":"module"}'); - for (const file of ["index.html", "src/bootstrap.ts", "src/lib/bootError.ts"]) { + for (const file of [ + "index.html", + "src/bootstrap.ts", + "src/lib/bootError.ts", + "src/lib/chunkReloadGuard.ts", + ]) { await NodeFSP.copyFile(new URL(`../${file}`, import.meta.url), NodePath.join(root, file)); } await NodeFSP.writeFile( @@ -94,6 +99,7 @@ export const startup = Promise.resolve().then(() => globalThis.onStarted(Shared( `import assert from "node:assert/strict"; const started = Promise.withResolvers(); globalThis.window = globalThis; +globalThis.addEventListener = () => {}; globalThis.document = { createElement: () => ({ relList: { supports: () => true } }), getElementById: () => null, diff --git a/apps/web/src/lib/chunkReloadGuard.ts b/apps/web/src/lib/chunkReloadGuard.ts index c83e0e71e0f4c..ad226e322fd16 100644 --- a/apps/web/src/lib/chunkReloadGuard.ts +++ b/apps/web/src/lib/chunkReloadGuard.ts @@ -24,3 +24,16 @@ export function clearChunkReloadGuard(getStorage: () => Storage = () => window.s getStorage().removeItem(CHUNK_RELOAD_GUARD_KEY); } catch {} } + +export const chunkLoadState = { failed: false, reloadScheduled: false }; + +// Registered from the entry before main loads, so main-chunk and route-chunk failures share one decision. +export function handleChunkLoadErrors() { + window.addEventListener("vite:preloadError", (event) => { + chunkLoadState.failed = true; + if (reloadOnceForChunkLoadError()) { + chunkLoadState.reloadScheduled = true; + event.preventDefault(); + } + }); +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index d6cf4ce77b91b..d483e9cfae84f 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -8,22 +8,12 @@ import { hasCloudPublicConfig } from "./cloud/publicConfig"; import { isElectron } from "./env"; import { startAppServices } from "./appInit"; import { AppRoot } from "./AppRoot"; -import { clearChunkReloadGuard, reloadOnceForChunkLoadError } from "./lib/chunkReloadGuard"; +import { chunkLoadState, clearChunkReloadGuard } from "./lib/chunkReloadGuard"; const router = startAppServices(); const clerkPublishableKey = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY as string | undefined; -let chunkLoadFailed = false; -let reloadScheduled = false; -window.addEventListener("vite:preloadError", (event) => { - chunkLoadFailed = true; - if (reloadOnceForChunkLoadError()) { - reloadScheduled = true; - event.preventDefault(); - } -}); - const app = ; // Split by runtime to keep Clerk out of local startup and clerk-js out of browser builds. @@ -40,8 +30,8 @@ export const startup = Promise.all([ ]) .then(([ManagedAuthShell]) => { // Route chunk failures resolve router.load() and surface later through the error boundary. - if (reloadScheduled) return; - if (!chunkLoadFailed) clearChunkReloadGuard(); + if (chunkLoadState.reloadScheduled) return; + if (!chunkLoadState.failed) clearChunkReloadGuard(); ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( {ManagedAuthShell && clerkPublishableKey ? ( @@ -53,6 +43,6 @@ export const startup = Promise.all([ ); }) .catch((error: unknown) => { - if (reloadScheduled) return; + if (chunkLoadState.reloadScheduled) return; throw error; });