From 0e31f949e4a97767565c4482245c9dc91807d991 Mon Sep 17 00:00:00 2001 From: grodriguez-fr <78903686+grodriguez-fr@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:09:11 +0000 Subject: [PATCH 1/5] fix(web): a chunk that fails after boot no longer reloads the page in a loop The chunk reload guard was cleared after every successful boot, so a lazy chunk that fails on each visit, such as the code highlighter, reloaded the page forever. The guard now expires five minutes after the last reload instead. Fixes #29304 --- apps/web/src/lib/chunkReloadGuard.test.ts | 69 +++++++++++++++++++---- apps/web/src/lib/chunkReloadGuard.ts | 22 ++++---- apps/web/src/main.tsx | 5 +- 3 files changed, 71 insertions(+), 25 deletions(-) diff --git a/apps/web/src/lib/chunkReloadGuard.test.ts b/apps/web/src/lib/chunkReloadGuard.test.ts index 632e8ec8cf0dc..01c1093514c2e 100644 --- a/apps/web/src/lib/chunkReloadGuard.test.ts +++ b/apps/web/src/lib/chunkReloadGuard.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it, vi } from "vite-plus/test"; -import { clearChunkReloadGuard, reloadOnceForChunkLoadError } from "./chunkReloadGuard"; +import { CHUNK_RELOAD_GUARD_WINDOW_MS, reloadOnceForChunkLoadError } from "./chunkReloadGuard"; -function createStorageStub(): Storage { - const store = new Map(); +function createStorageStub(initial: Record = {}): Storage { + const store = new Map(Object.entries(initial)); return { getItem: (key) => store.get(key) ?? null, setItem: (key, value) => { @@ -20,29 +20,77 @@ function createStorageStub(): Storage { }; } +function createClock(start = 1_800_000_000_000) { + let time = start; + return { + now: () => time, + advance: (ms: number) => { + time += ms; + }, + }; +} + describe("reloadOnceForChunkLoadError", () => { - it("reloads on the first failure and lets the second one surface", () => { + it("reloads on the first failure and lets a repeat within the window surface", () => { const storage = createStorageStub(); + const clock = createClock(); const reload = vi.fn(); - expect(reloadOnceForChunkLoadError(() => storage, reload)).toBe(true); + expect(reloadOnceForChunkLoadError(() => storage, reload, clock.now)).toBe(true); + expect(reload).toHaveBeenCalledTimes(1); + + clock.advance(1_000); + expect(reloadOnceForChunkLoadError(() => storage, reload, clock.now)).toBe(false); expect(reload).toHaveBeenCalledTimes(1); + }); + + it("doesn't reload again when a lazy chunk fails after every boot", () => { + const storage = createStorageStub(); + const clock = createClock(); + const reload = vi.fn(); + + // Each visit boots fine, then fails to load the same lazy chunk about two seconds later. + const reloads = Array.from({ length: 10 }, () => { + clock.advance(2_000); + return reloadOnceForChunkLoadError(() => storage, reload, clock.now); + }); - expect(reloadOnceForChunkLoadError(() => storage, reload)).toBe(false); + expect(reloads.filter(Boolean)).toHaveLength(1); expect(reload).toHaveBeenCalledTimes(1); }); - it("reloads again after a successful boot cleared the guard", () => { + it("reloads again once the window has passed, so a later deploy still recovers", () => { const storage = createStorageStub(); + const clock = createClock(); const reload = vi.fn(); - reloadOnceForChunkLoadError(() => storage, reload); - clearChunkReloadGuard(() => storage); + reloadOnceForChunkLoadError(() => storage, reload, clock.now); + clock.advance(CHUNK_RELOAD_GUARD_WINDOW_MS); - expect(reloadOnceForChunkLoadError(() => storage, reload)).toBe(true); + expect(reloadOnceForChunkLoadError(() => storage, reload, clock.now)).toBe(true); expect(reload).toHaveBeenCalledTimes(2); }); + it("treats a guard written by an older version as expired", () => { + const storage = createStorageStub({ "t3code:chunk-load-reloaded": "1" }); + const reload = vi.fn(); + + expect(reloadOnceForChunkLoadError(() => storage, reload, createClock().now)).toBe(true); + expect(reload).toHaveBeenCalledTimes(1); + }); + + it("doesn't reload when the clock moved backwards since the last reload", () => { + const storage = createStorageStub(); + const clock = createClock(); + const reload = vi.fn(); + + reloadOnceForChunkLoadError(() => storage, reload, clock.now); + clock.advance(-60_000); + + expect(reloadOnceForChunkLoadError(() => storage, reload, clock.now)).toBe(false); + expect(reload).toHaveBeenCalledTimes(1); + }); + it("never reloads when storage is blocked, so a persistent failure cannot loop", () => { const reload = vi.fn(); const blocked = () => { @@ -51,6 +99,5 @@ describe("reloadOnceForChunkLoadError", () => { expect(reloadOnceForChunkLoadError(blocked, reload)).toBe(false); expect(reload).not.toHaveBeenCalled(); - expect(() => clearChunkReloadGuard(blocked)).not.toThrow(); }); }); diff --git a/apps/web/src/lib/chunkReloadGuard.ts b/apps/web/src/lib/chunkReloadGuard.ts index c83e0e71e0f4c..319f2137906d6 100644 --- a/apps/web/src/lib/chunkReloadGuard.ts +++ b/apps/web/src/lib/chunkReloadGuard.ts @@ -1,26 +1,28 @@ // Deploys and desktop server swaps can invalidate lazy chunks; one reload fetches the fresh index. +// The guard expires after a delay instead of being cleared once the app boots. A successful boot +// doesn't prove that every lazy chunk loads, so clearing it there let a chunk that fails on each +// visit, such as the code highlighter blocked by a proxy, reload the page forever. const CHUNK_RELOAD_GUARD_KEY = "t3code:chunk-load-reloaded"; +export const CHUNK_RELOAD_GUARD_WINDOW_MS = 5 * 60 * 1000; export function reloadOnceForChunkLoadError( getStorage: () => Storage = () => window.sessionStorage, reload: () => void = () => window.location.reload(), + now: () => number = Date.now, ): boolean { - let alreadyReloaded: boolean; + let reloadedRecently: boolean; try { const storage = getStorage(); - alreadyReloaded = storage.getItem(CHUNK_RELOAD_GUARD_KEY) === "1"; - if (!alreadyReloaded) storage.setItem(CHUNK_RELOAD_GUARD_KEY, "1"); + const lastReloadAt = Number(storage.getItem(CHUNK_RELOAD_GUARD_KEY)); + const elapsed = now() - lastReloadAt; + // A clock that moved backwards counts as recent, so it can't turn into a loop either. + reloadedRecently = lastReloadAt > 0 && elapsed < CHUNK_RELOAD_GUARD_WINDOW_MS; + if (!reloadedRecently) storage.setItem(CHUNK_RELOAD_GUARD_KEY, String(now())); } catch { // Without storage, the reload guard cannot prevent an infinite loop. return false; } - if (alreadyReloaded) return false; + if (reloadedRecently) return false; reload(); return true; } - -export function clearChunkReloadGuard(getStorage: () => Storage = () => window.sessionStorage) { - try { - getStorage().removeItem(CHUNK_RELOAD_GUARD_KEY); - } catch {} -} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 4469f9cbf6ba3..112de4848170c 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -9,16 +9,14 @@ import { isElectron } from "./env"; import { startAppServices } from "./appInit"; import { AppRoot } from "./AppRoot"; import { whenLocaleReady } from "./i18n/runtime"; -import { clearChunkReloadGuard, reloadOnceForChunkLoadError } from "./lib/chunkReloadGuard"; +import { reloadOnceForChunkLoadError } 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(); @@ -43,7 +41,6 @@ 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(); ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( {ManagedAuthShell && clerkPublishableKey ? ( From 0517b1d05cbaa630cff6b235015189d9a0edabcd Mon Sep 17 00:00:00 2001 From: grodriguez-fr <78903686+grodriguez-fr@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:15:22 +0000 Subject: [PATCH 2/5] test(web): cover a lazy chunk that fails after every boot Boots main twice with the same sessionStorage, as a reload does, and fails a lazy chunk after each boot. Before the fix the second visit reloaded again. --- apps/web/src/lib/chunkReloadGuard.test.ts | 3 +- apps/web/src/main.test.ts | 77 +++++++++++++++++++++++ 2 files changed, 78 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/main.test.ts diff --git a/apps/web/src/lib/chunkReloadGuard.test.ts b/apps/web/src/lib/chunkReloadGuard.test.ts index 01c1093514c2e..b90f5cb88b25c 100644 --- a/apps/web/src/lib/chunkReloadGuard.test.ts +++ b/apps/web/src/lib/chunkReloadGuard.test.ts @@ -44,12 +44,11 @@ describe("reloadOnceForChunkLoadError", () => { expect(reload).toHaveBeenCalledTimes(1); }); - it("doesn't reload again when a lazy chunk fails after every boot", () => { + it("keeps refusing repeated failures for the whole window", () => { const storage = createStorageStub(); const clock = createClock(); const reload = vi.fn(); - // Each visit boots fine, then fails to load the same lazy chunk about two seconds later. const reloads = Array.from({ length: 10 }, () => { clock.advance(2_000); return reloadOnceForChunkLoadError(() => storage, reload, clock.now); diff --git a/apps/web/src/main.test.ts b/apps/web/src/main.test.ts new file mode 100644 index 0000000000000..7984784dad4ea --- /dev/null +++ b/apps/web/src/main.test.ts @@ -0,0 +1,77 @@ +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; + +const mocks = vi.hoisted(() => ({ render: vi.fn().mockName("root.render") })); + +vi.mock("@fontsource-variable/inter", () => ({})); +vi.mock("./index.css", () => ({})); +vi.mock("./cloud/publicConfig", () => ({ hasCloudPublicConfig: () => false })); +vi.mock("./env", () => ({ isElectron: false })); +vi.mock("./appInit", () => ({ startAppServices: () => ({ load: () => Promise.resolve() }) })); +vi.mock("./AppRoot", () => ({ AppRoot: () => null })); +vi.mock("react-dom/client", () => ({ + default: { createRoot: () => ({ render: mocks.render }) }, +})); + +function createStorageStub(): Storage { + const store = new Map(); + 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; + }, + }; +} + +// Boots main in a fresh page that shares the tab's sessionStorage, as a reload does. +async function bootPage(storage: Storage) { + vi.resetModules(); + mocks.render.mockClear(); + const reload = vi.fn().mockName("location.reload"); + const page = Object.assign(new EventTarget(), { + sessionStorage: storage, + location: { reload }, + }); + vi.unstubAllGlobals(); + vi.stubGlobal("window", page); + vi.stubGlobal("document", { getElementById: () => ({}) }); + const { startup } = await import("./main"); + await startup; + const failLazyChunk = () => { + const event = Object.assign(new Event("vite:preloadError", { cancelable: true }), { + payload: new TypeError("Failed to fetch dynamically imported module: /assets/highlights.js"), + }); + page.dispatchEvent(event); + return event; + }; + return { reload, failLazyChunk }; +} + +describe("main chunk reload guard", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("reloads once, not on every visit, when a lazy chunk fails after each boot", async () => { + const storage = createStorageStub(); + + const firstVisit = await bootPage(storage); + expect(mocks.render).toHaveBeenCalledTimes(1); + expect(firstVisit.failLazyChunk().defaultPrevented).toBe(true); + expect(firstVisit.reload).toHaveBeenCalledTimes(1); + + const afterReload = await bootPage(storage); + expect(mocks.render).toHaveBeenCalledTimes(1); + const repeat = afterReload.failLazyChunk(); + expect(afterReload.reload).not.toHaveBeenCalled(); + // Not prevented, so the import rejects and the code block falls back to plain text. + expect(repeat.defaultPrevented).toBe(false); + }); +}); From 201fe25bc3b4bb9e7538938b812daeb4648fe90d Mon Sep 17 00:00:00 2001 From: grodriguez-fr <78903686+grodriguez-fr@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:45:56 +0000 Subject: [PATCH 3/5] fix(web): key the chunk reload guard by build instead of a time window A five-minute window still let a chunk that always fails reload the page again every five minutes. The guard now stores the build that reloaded the tab: the same build never reloads twice, and a deploy re-arms it. --- apps/web/src/lib/chunkReloadGuard.test.ts | 75 ++++++++--------------- apps/web/src/lib/chunkReloadGuard.ts | 20 +++--- apps/web/src/main.test.ts | 62 ++++++++++++++----- 3 files changed, 79 insertions(+), 78 deletions(-) diff --git a/apps/web/src/lib/chunkReloadGuard.test.ts b/apps/web/src/lib/chunkReloadGuard.test.ts index b90f5cb88b25c..26e623432ea75 100644 --- a/apps/web/src/lib/chunkReloadGuard.test.ts +++ b/apps/web/src/lib/chunkReloadGuard.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vite-plus/test"; -import { CHUNK_RELOAD_GUARD_WINDOW_MS, reloadOnceForChunkLoadError } from "./chunkReloadGuard"; +import { reloadOnceForChunkLoadError } from "./chunkReloadGuard"; function createStorageStub(initial: Record = {}): Storage { const store = new Map(Object.entries(initial)); @@ -20,73 +20,37 @@ function createStorageStub(initial: Record = {}): Storage { }; } -function createClock(start = 1_800_000_000_000) { - let time = start; - return { - now: () => time, - advance: (ms: number) => { - time += ms; - }, - }; -} - describe("reloadOnceForChunkLoadError", () => { - it("reloads on the first failure and lets a repeat within the window surface", () => { + it("reloads on the first failure and lets every later one on the same build surface", () => { const storage = createStorageStub(); - const clock = createClock(); const reload = vi.fn(); - expect(reloadOnceForChunkLoadError(() => storage, reload, clock.now)).toBe(true); - expect(reload).toHaveBeenCalledTimes(1); - - clock.advance(1_000); - expect(reloadOnceForChunkLoadError(() => storage, reload, clock.now)).toBe(false); + expect(reloadOnceForChunkLoadError(() => storage, reload, "1.0.0")).toBe(true); expect(reload).toHaveBeenCalledTimes(1); - }); - - it("keeps refusing repeated failures for the whole window", () => { - const storage = createStorageStub(); - const clock = createClock(); - const reload = vi.fn(); - const reloads = Array.from({ length: 10 }, () => { - clock.advance(2_000); - return reloadOnceForChunkLoadError(() => storage, reload, clock.now); - }); - - expect(reloads.filter(Boolean)).toHaveLength(1); + for (let attempt = 0; attempt < 5; attempt += 1) { + expect(reloadOnceForChunkLoadError(() => storage, reload, "1.0.0")).toBe(false); + } expect(reload).toHaveBeenCalledTimes(1); }); - it("reloads again once the window has passed, so a later deploy still recovers", () => { + it("reloads again once a deploy changed the build", () => { const storage = createStorageStub(); - const clock = createClock(); const reload = vi.fn(); - reloadOnceForChunkLoadError(() => storage, reload, clock.now); - clock.advance(CHUNK_RELOAD_GUARD_WINDOW_MS); + reloadOnceForChunkLoadError(() => storage, reload, "1.0.0"); - expect(reloadOnceForChunkLoadError(() => storage, reload, clock.now)).toBe(true); + expect(reloadOnceForChunkLoadError(() => storage, reload, "1.0.1")).toBe(true); + expect(reload).toHaveBeenCalledTimes(2); + expect(reloadOnceForChunkLoadError(() => storage, reload, "1.0.1")).toBe(false); expect(reload).toHaveBeenCalledTimes(2); }); - it("treats a guard written by an older version as expired", () => { + it("treats a guard written by an older version as another build", () => { const storage = createStorageStub({ "t3code:chunk-load-reloaded": "1" }); const reload = vi.fn(); - expect(reloadOnceForChunkLoadError(() => storage, reload, createClock().now)).toBe(true); - expect(reload).toHaveBeenCalledTimes(1); - }); - - it("doesn't reload when the clock moved backwards since the last reload", () => { - const storage = createStorageStub(); - const clock = createClock(); - const reload = vi.fn(); - - reloadOnceForChunkLoadError(() => storage, reload, clock.now); - clock.advance(-60_000); - - expect(reloadOnceForChunkLoadError(() => storage, reload, clock.now)).toBe(false); + expect(reloadOnceForChunkLoadError(() => storage, reload, "1.0.0")).toBe(true); expect(reload).toHaveBeenCalledTimes(1); }); @@ -96,7 +60,18 @@ describe("reloadOnceForChunkLoadError", () => { throw new DOMException("blocked", "SecurityError"); }; - expect(reloadOnceForChunkLoadError(blocked, reload)).toBe(false); + expect(reloadOnceForChunkLoadError(blocked, reload, "1.0.0")).toBe(false); + expect(reload).not.toHaveBeenCalled(); + }); + + it("never reloads when the guard can't be written", () => { + const storage = createStorageStub(); + storage.setItem = () => { + throw new DOMException("full", "QuotaExceededError"); + }; + const reload = vi.fn(); + + expect(reloadOnceForChunkLoadError(() => storage, reload, "1.0.0")).toBe(false); expect(reload).not.toHaveBeenCalled(); }); }); diff --git a/apps/web/src/lib/chunkReloadGuard.ts b/apps/web/src/lib/chunkReloadGuard.ts index 319f2137906d6..1bfa327237189 100644 --- a/apps/web/src/lib/chunkReloadGuard.ts +++ b/apps/web/src/lib/chunkReloadGuard.ts @@ -1,28 +1,24 @@ // Deploys and desktop server swaps can invalidate lazy chunks; one reload fetches the fresh index. -// The guard expires after a delay instead of being cleared once the app boots. A successful boot -// doesn't prove that every lazy chunk loads, so clearing it there let a chunk that fails on each -// visit, such as the code highlighter blocked by a proxy, reload the page forever. +// Keyed by build, not cleared at boot: a lazy chunk can fail after every successful boot, so a +// build reloads the tab at most once and a new deploy re-arms the guard. const CHUNK_RELOAD_GUARD_KEY = "t3code:chunk-load-reloaded"; -export const CHUNK_RELOAD_GUARD_WINDOW_MS = 5 * 60 * 1000; +const CURRENT_BUILD = import.meta.env.APP_VERSION || "0.0.0"; export function reloadOnceForChunkLoadError( getStorage: () => Storage = () => window.sessionStorage, reload: () => void = () => window.location.reload(), - now: () => number = Date.now, + build: string = CURRENT_BUILD, ): boolean { - let reloadedRecently: boolean; + let alreadyReloaded: boolean; try { const storage = getStorage(); - const lastReloadAt = Number(storage.getItem(CHUNK_RELOAD_GUARD_KEY)); - const elapsed = now() - lastReloadAt; - // A clock that moved backwards counts as recent, so it can't turn into a loop either. - reloadedRecently = lastReloadAt > 0 && elapsed < CHUNK_RELOAD_GUARD_WINDOW_MS; - if (!reloadedRecently) storage.setItem(CHUNK_RELOAD_GUARD_KEY, String(now())); + alreadyReloaded = storage.getItem(CHUNK_RELOAD_GUARD_KEY) === build; + if (!alreadyReloaded) storage.setItem(CHUNK_RELOAD_GUARD_KEY, build); } catch { // Without storage, the reload guard cannot prevent an infinite loop. return false; } - if (reloadedRecently) return false; + if (alreadyReloaded) return false; reload(); return true; } diff --git a/apps/web/src/main.test.ts b/apps/web/src/main.test.ts index 7984784dad4ea..7f52c79a01ae8 100644 --- a/apps/web/src/main.test.ts +++ b/apps/web/src/main.test.ts @@ -1,12 +1,22 @@ import { afterEach, describe, expect, it, vi } from "vite-plus/test"; -const mocks = vi.hoisted(() => ({ render: vi.fn().mockName("root.render") })); +const mocks = vi.hoisted(() => ({ + render: vi.fn().mockName("root.render"), + // Runs while the router loads, before main decides whether to render. + duringRouterLoad: (): void => {}, +})); vi.mock("@fontsource-variable/inter", () => ({})); vi.mock("./index.css", () => ({})); vi.mock("./cloud/publicConfig", () => ({ hasCloudPublicConfig: () => false })); vi.mock("./env", () => ({ isElectron: false })); -vi.mock("./appInit", () => ({ startAppServices: () => ({ load: () => Promise.resolve() }) })); +vi.mock("./appInit", () => ({ + startAppServices: () => ({ + load: async () => { + mocks.duringRouterLoad(); + }, + }), +})); vi.mock("./AppRoot", () => ({ AppRoot: () => null })); vi.mock("react-dom/client", () => ({ default: { createRoot: () => ({ render: mocks.render }) }, @@ -30,8 +40,16 @@ function createStorageStub(): Storage { }; } +function failChunk(page: EventTarget) { + const event = Object.assign(new Event("vite:preloadError", { cancelable: true }), { + payload: new TypeError("Failed to fetch dynamically imported module: /assets/highlights.js"), + }); + page.dispatchEvent(event); + return event; +} + // Boots main in a fresh page that shares the tab's sessionStorage, as a reload does. -async function bootPage(storage: Storage) { +async function bootPage(storage: Storage, { failDuringBoot = false } = {}) { vi.resetModules(); mocks.render.mockClear(); const reload = vi.fn().mockName("location.reload"); @@ -39,19 +57,13 @@ async function bootPage(storage: Storage) { sessionStorage: storage, location: { reload }, }); + mocks.duringRouterLoad = failDuringBoot ? () => void failChunk(page) : () => {}; vi.unstubAllGlobals(); vi.stubGlobal("window", page); vi.stubGlobal("document", { getElementById: () => ({}) }); const { startup } = await import("./main"); await startup; - const failLazyChunk = () => { - const event = Object.assign(new Event("vite:preloadError", { cancelable: true }), { - payload: new TypeError("Failed to fetch dynamically imported module: /assets/highlights.js"), - }); - page.dispatchEvent(event); - return event; - }; - return { reload, failLazyChunk }; + return { reload, failLazyChunk: () => failChunk(page) }; } describe("main chunk reload guard", () => { @@ -67,11 +79,29 @@ describe("main chunk reload guard", () => { expect(firstVisit.failLazyChunk().defaultPrevented).toBe(true); expect(firstVisit.reload).toHaveBeenCalledTimes(1); - const afterReload = await bootPage(storage); + for (let visit = 0; visit < 3; visit += 1) { + const nextVisit = await bootPage(storage); + expect(mocks.render).toHaveBeenCalledTimes(1); + // Not prevented: the failed import rejects and the caller's error handling takes over. + expect(nextVisit.failLazyChunk().defaultPrevented).toBe(false); + expect(nextVisit.reload).not.toHaveBeenCalled(); + } + }); + + it("skips rendering when a chunk fails during boot and a reload is on its way", async () => { + const { reload } = await bootPage(createStorageStub(), { failDuringBoot: true }); + + expect(reload).toHaveBeenCalledTimes(1); + expect(mocks.render).not.toHaveBeenCalled(); + }); + + it("renders when a chunk fails during boot after the build already reloaded", async () => { + const storage = createStorageStub(); + await bootPage(storage, { failDuringBoot: true }); + + const { reload } = await bootPage(storage, { failDuringBoot: true }); + + expect(reload).not.toHaveBeenCalled(); expect(mocks.render).toHaveBeenCalledTimes(1); - const repeat = afterReload.failLazyChunk(); - expect(afterReload.reload).not.toHaveBeenCalled(); - // Not prevented, so the import rejects and the code block falls back to plain text. - expect(repeat.defaultPrevented).toBe(false); }); }); From 8cd53a5b8b1ea51b3fe666baa69d3ddf61e8db58 Mon Sep 17 00:00:00 2001 From: grodriguez-fr <78903686+grodriguez-fr@users.noreply.github.com> Date: Mon, 2 Nov 2026 14:05:34 +0000 Subject: [PATCH 4/5] test(web): keep only the chunk reload tests that fail on main The boot-level test now covers the loop in one case, the guard tests extend the existing ones, and the guard reads APP_VERSION directly like bootError does. --- apps/web/src/lib/chunkReloadGuard.test.ts | 33 +------ apps/web/src/lib/chunkReloadGuard.ts | 6 +- apps/web/src/main.test.ts | 113 ++++------------------ 3 files changed, 28 insertions(+), 124 deletions(-) diff --git a/apps/web/src/lib/chunkReloadGuard.test.ts b/apps/web/src/lib/chunkReloadGuard.test.ts index 26e623432ea75..716dbb3fc4fbc 100644 --- a/apps/web/src/lib/chunkReloadGuard.test.ts +++ b/apps/web/src/lib/chunkReloadGuard.test.ts @@ -2,8 +2,8 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { reloadOnceForChunkLoadError } from "./chunkReloadGuard"; -function createStorageStub(initial: Record = {}): Storage { - const store = new Map(Object.entries(initial)); +function createStorageStub(): Storage { + const store = new Map(); return { getItem: (key) => store.get(key) ?? null, setItem: (key, value) => { @@ -21,16 +21,14 @@ function createStorageStub(initial: Record = {}): Storage { } describe("reloadOnceForChunkLoadError", () => { - it("reloads on the first failure and lets every later one on the same build surface", () => { + it("reloads on the first failure and lets the second one surface", () => { const storage = createStorageStub(); const reload = vi.fn(); expect(reloadOnceForChunkLoadError(() => storage, reload, "1.0.0")).toBe(true); expect(reload).toHaveBeenCalledTimes(1); - for (let attempt = 0; attempt < 5; attempt += 1) { - expect(reloadOnceForChunkLoadError(() => storage, reload, "1.0.0")).toBe(false); - } + expect(reloadOnceForChunkLoadError(() => storage, reload, "1.0.0")).toBe(false); expect(reload).toHaveBeenCalledTimes(1); }); @@ -42,16 +40,6 @@ describe("reloadOnceForChunkLoadError", () => { expect(reloadOnceForChunkLoadError(() => storage, reload, "1.0.1")).toBe(true); expect(reload).toHaveBeenCalledTimes(2); - expect(reloadOnceForChunkLoadError(() => storage, reload, "1.0.1")).toBe(false); - expect(reload).toHaveBeenCalledTimes(2); - }); - - it("treats a guard written by an older version as another build", () => { - const storage = createStorageStub({ "t3code:chunk-load-reloaded": "1" }); - const reload = vi.fn(); - - expect(reloadOnceForChunkLoadError(() => storage, reload, "1.0.0")).toBe(true); - expect(reload).toHaveBeenCalledTimes(1); }); it("never reloads when storage is blocked, so a persistent failure cannot loop", () => { @@ -60,18 +48,7 @@ describe("reloadOnceForChunkLoadError", () => { throw new DOMException("blocked", "SecurityError"); }; - expect(reloadOnceForChunkLoadError(blocked, reload, "1.0.0")).toBe(false); - expect(reload).not.toHaveBeenCalled(); - }); - - it("never reloads when the guard can't be written", () => { - const storage = createStorageStub(); - storage.setItem = () => { - throw new DOMException("full", "QuotaExceededError"); - }; - const reload = vi.fn(); - - expect(reloadOnceForChunkLoadError(() => storage, reload, "1.0.0")).toBe(false); + expect(reloadOnceForChunkLoadError(blocked, reload)).toBe(false); expect(reload).not.toHaveBeenCalled(); }); }); diff --git a/apps/web/src/lib/chunkReloadGuard.ts b/apps/web/src/lib/chunkReloadGuard.ts index 1bfa327237189..0cad00232eb6b 100644 --- a/apps/web/src/lib/chunkReloadGuard.ts +++ b/apps/web/src/lib/chunkReloadGuard.ts @@ -1,13 +1,11 @@ // Deploys and desktop server swaps can invalidate lazy chunks; one reload fetches the fresh index. -// Keyed by build, not cleared at boot: a lazy chunk can fail after every successful boot, so a -// build reloads the tab at most once and a new deploy re-arms the guard. +// Keyed by build and kept across boots: a chunk loaded after boot can fail on every visit. const CHUNK_RELOAD_GUARD_KEY = "t3code:chunk-load-reloaded"; -const CURRENT_BUILD = import.meta.env.APP_VERSION || "0.0.0"; export function reloadOnceForChunkLoadError( getStorage: () => Storage = () => window.sessionStorage, reload: () => void = () => window.location.reload(), - build: string = CURRENT_BUILD, + build = import.meta.env.APP_VERSION, ): boolean { let alreadyReloaded: boolean; try { diff --git a/apps/web/src/main.test.ts b/apps/web/src/main.test.ts index 7f52c79a01ae8..86613e894db11 100644 --- a/apps/web/src/main.test.ts +++ b/apps/web/src/main.test.ts @@ -1,107 +1,36 @@ -import { afterEach, describe, expect, it, vi } from "vite-plus/test"; +import { expect, it, vi } from "vite-plus/test"; -const mocks = vi.hoisted(() => ({ - render: vi.fn().mockName("root.render"), - // Runs while the router loads, before main decides whether to render. - duringRouterLoad: (): void => {}, -})); - -vi.mock("@fontsource-variable/inter", () => ({})); -vi.mock("./index.css", () => ({})); -vi.mock("./cloud/publicConfig", () => ({ hasCloudPublicConfig: () => false })); -vi.mock("./env", () => ({ isElectron: false })); -vi.mock("./appInit", () => ({ - startAppServices: () => ({ - load: async () => { - mocks.duringRouterLoad(); - }, - }), -})); +vi.mock("./appInit", () => ({ startAppServices: () => ({ load: async () => {} }) })); vi.mock("./AppRoot", () => ({ AppRoot: () => null })); -vi.mock("react-dom/client", () => ({ - default: { createRoot: () => ({ render: mocks.render }) }, -})); - -function createStorageStub(): Storage { - const store = new Map(); - 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; - }, - }; -} - -function failChunk(page: EventTarget) { - const event = Object.assign(new Event("vite:preloadError", { cancelable: true }), { - payload: new TypeError("Failed to fetch dynamically imported module: /assets/highlights.js"), - }); - page.dispatchEvent(event); - return event; -} +vi.mock("react-dom/client", () => ({ default: { createRoot: () => ({ render: () => {} }) } })); // Boots main in a fresh page that shares the tab's sessionStorage, as a reload does. -async function bootPage(storage: Storage, { failDuringBoot = false } = {}) { +async function bootThenFailLazyChunk(sessionStorage: Map) { vi.resetModules(); - mocks.render.mockClear(); const reload = vi.fn().mockName("location.reload"); const page = Object.assign(new EventTarget(), { - sessionStorage: storage, + sessionStorage: { + getItem: (key: string) => sessionStorage.get(key) ?? null, + setItem: (key: string, value: string) => sessionStorage.set(key, value), + removeItem: (key: string) => sessionStorage.delete(key), + }, location: { reload }, }); - mocks.duringRouterLoad = failDuringBoot ? () => void failChunk(page) : () => {}; - vi.unstubAllGlobals(); vi.stubGlobal("window", page); vi.stubGlobal("document", { getElementById: () => ({}) }); - const { startup } = await import("./main"); - await startup; - return { reload, failLazyChunk: () => failChunk(page) }; -} - -describe("main chunk reload guard", () => { - afterEach(() => { + try { + const { startup } = await import("./main"); + await startup; + page.dispatchEvent(new Event("vite:preloadError", { cancelable: true })); + return reload; + } finally { vi.unstubAllGlobals(); - }); - - it("reloads once, not on every visit, when a lazy chunk fails after each boot", async () => { - const storage = createStorageStub(); - - const firstVisit = await bootPage(storage); - expect(mocks.render).toHaveBeenCalledTimes(1); - expect(firstVisit.failLazyChunk().defaultPrevented).toBe(true); - expect(firstVisit.reload).toHaveBeenCalledTimes(1); - - for (let visit = 0; visit < 3; visit += 1) { - const nextVisit = await bootPage(storage); - expect(mocks.render).toHaveBeenCalledTimes(1); - // Not prevented: the failed import rejects and the caller's error handling takes over. - expect(nextVisit.failLazyChunk().defaultPrevented).toBe(false); - expect(nextVisit.reload).not.toHaveBeenCalled(); - } - }); - - it("skips rendering when a chunk fails during boot and a reload is on its way", async () => { - const { reload } = await bootPage(createStorageStub(), { failDuringBoot: true }); - - expect(reload).toHaveBeenCalledTimes(1); - expect(mocks.render).not.toHaveBeenCalled(); - }); - - it("renders when a chunk fails during boot after the build already reloaded", async () => { - const storage = createStorageStub(); - await bootPage(storage, { failDuringBoot: true }); + } +} - const { reload } = await bootPage(storage, { failDuringBoot: true }); +it("reloads only once when a lazy chunk fails after every boot", async () => { + const sessionStorage = new Map(); - expect(reload).not.toHaveBeenCalled(); - expect(mocks.render).toHaveBeenCalledTimes(1); - }); + expect(await bootThenFailLazyChunk(sessionStorage)).toHaveBeenCalledTimes(1); + expect(await bootThenFailLazyChunk(sessionStorage)).not.toHaveBeenCalled(); }); From eb54b1eb1682de8837dc2a88bddd6dd6c351981a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 20:58:18 +0000 Subject: [PATCH 5/5] test(web): stub the i18n runtime in the chunk reload boot test AI-assisted; verified with the tests and tools listed above. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/main.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/src/main.test.ts b/apps/web/src/main.test.ts index 86613e894db11..56be08671e331 100644 --- a/apps/web/src/main.test.ts +++ b/apps/web/src/main.test.ts @@ -2,6 +2,7 @@ import { expect, it, vi } from "vite-plus/test"; vi.mock("./appInit", () => ({ startAppServices: () => ({ load: async () => {} }) })); vi.mock("./AppRoot", () => ({ AppRoot: () => null })); +vi.mock("./i18n/runtime", () => ({ whenLocaleReady: async () => {} })); vi.mock("react-dom/client", () => ({ default: { createRoot: () => ({ render: () => {} }) } })); // Boots main in a fresh page that shares the tab's sessionStorage, as a reload does.