From 51e98bfc3854cacf13aee41d46c189741c7f0e36 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 19 Sep 2026 04:51:31 +0200 Subject: [PATCH] fix(app): reload onto a new build instead of crashing on a missing chunk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A tab left open across a release asked for a lazy chunk the server no longer has (prod: DownloadDialog 404 an hour after 0.89) and fell to the crash screen. `vite:preloadError` now reloads the tab once a minute at most; a second failure is let through to the error boundary, which now wraps every entry — the public /watch, /privacy and /terms pages crashed to a blank page. --- frontend/src/components/ErrorBoundary.tsx | 5 ++ frontend/src/lib/staleBuild.dom.test.ts | 44 ++++++++++++ frontend/src/lib/staleBuild.ts | 54 ++++++++++++++ frontend/src/main.tsx | 85 ++++++++++++----------- 4 files changed, 148 insertions(+), 40 deletions(-) create mode 100644 frontend/src/lib/staleBuild.dom.test.ts create mode 100644 frontend/src/lib/staleBuild.ts diff --git a/frontend/src/components/ErrorBoundary.tsx b/frontend/src/components/ErrorBoundary.tsx index c30a288e..acfdb5f9 100644 --- a/frontend/src/components/ErrorBoundary.tsx +++ b/frontend/src/components/ErrorBoundary.tsx @@ -2,6 +2,7 @@ import { Component, type ErrorInfo, type ReactNode } from "react"; import i18n from "../i18n"; import { notify } from "../lib/notifications"; import { errorAttrs, log } from "../lib/log"; +import { reloadingForNewBuild } from "../lib/staleBuild"; interface Props { children: ReactNode; @@ -20,6 +21,9 @@ export default class ErrorBoundary extends Component { } componentDidCatch(error: Error, info: ErrorInfo) { + // The tab is already reloading onto a newer build (lib/staleBuild): this is the chunk it could not + // load, not a crash — no fatal notice, which would outlive the reload. + if (reloadingForNewBuild()) return; log.fatal("ui.crash", "the app crashed while rendering", { ...errorAttrs(error), ...(info.componentStack ? { component_stack: info.componentStack } : {}), @@ -34,6 +38,7 @@ export default class ErrorBoundary extends Component { render() { if (this.state.crashed) { + if (reloadingForNewBuild()) return null; return (
diff --git a/frontend/src/lib/staleBuild.dom.test.ts b/frontend/src/lib/staleBuild.dom.test.ts new file mode 100644 index 00000000..05ddeca7 --- /dev/null +++ b/frontend/src/lib/staleBuild.dom.test.ts @@ -0,0 +1,44 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const fire = (): Event => { + const e = new Event("vite:preloadError", { cancelable: true }); + window.dispatchEvent(e); + return e; +}; + +let uninstall = () => {}; + +describe("a lazy chunk the server no longer has", () => { + afterEach(() => uninstall()); + beforeEach(() => { + vi.resetModules(); + sessionStorage.clear(); + }); + + it("reloads the tab once, and says so to the error boundary", async () => { + const { installStaleBuildReload, reloadingForNewBuild } = await import("./staleBuild"); + const reload = vi.fn(); + uninstall = installStaleBuildReload(reload); + + const e = fire(); + + expect(e.defaultPrevented).toBe(true); + expect(reload).toHaveBeenCalledTimes(1); + expect(reloadingForNewBuild()).toBe(true); + // The rest of the page's failed imports while the reload is under way: swallowed, no second reload. + expect(fire().defaultPrevented).toBe(true); + expect(reload).toHaveBeenCalledTimes(1); + }); + + it("lets the error through when the reload a moment ago did not help", async () => { + sessionStorage.setItem("sf.staleBuildReload", String(Date.now() - 5_000)); + const { installStaleBuildReload, reloadingForNewBuild } = await import("./staleBuild"); + const reload = vi.fn(); + uninstall = installStaleBuildReload(reload); + + expect(fire().defaultPrevented).toBe(false); + expect(reload).not.toHaveBeenCalled(); + expect(reloadingForNewBuild()).toBe(false); + }); +}); diff --git a/frontend/src/lib/staleBuild.ts b/frontend/src/lib/staleBuild.ts new file mode 100644 index 00000000..5a894028 --- /dev/null +++ b/frontend/src/lib/staleBuild.ts @@ -0,0 +1,54 @@ +// A tab left open across a release still runs the old build, and the old build's lazy chunks are gone +// from the server the moment the new one is deployed: its first lazy import (the download dialog, a +// module it had not opened yet) answers 404, and the whole app fell to the crash screen. Measured on +// prod 2026-09-19 — `GET /assets/DownloadDialog-….js 404` at 00:59, an hour after 0.89 went out. +// +// Vite announces such a failure as `vite:preloadError` (for the import itself as well as for its +// preloaded dependencies); cancelling it and reloading puts the tab on the current build. Once per +// minute at most: a chunk that is missing from the NEW build too would otherwise reload forever, so +// the second failure inside the minute is let through to the error boundary, which says so. +import { log } from "./log"; + +const KEY = "sf.staleBuildReload"; +const WINDOW_MS = 60_000; + +let reloading = false; + +/** True once a reload for a newer build is under way: whatever fails to render in the meantime is + * that missing chunk, and the error boundary must not report it as a crash. */ +export function reloadingForNewBuild(): boolean { + return reloading; +} + +function lastReload(): number { + try { + return Number(sessionStorage.getItem(KEY) ?? 0) || 0; + } catch { + return 0; + } +} + +/** Returns the uninstall (for tests: the listener is on `window`, which outlives a module reset). */ +export function installStaleBuildReload( + reload: () => void = () => window.location.reload(), +): () => void { + const onError = (event: Event) => { + if (reloading) { + event.preventDefault(); + return; + } + if (Date.now() - lastReload() < WINDOW_MS) return; + try { + sessionStorage.setItem(KEY, String(Date.now())); + } catch { + // No storage (a private window): without the marker a reload could repeat, so don't. + return; + } + event.preventDefault(); + reloading = true; + log.warn("app.stale_build", "a chunk of this build is gone from the server; reloading"); + reload(); + }; + window.addEventListener("vite:preloadError", onError); + return () => window.removeEventListener("vite:preloadError", onError); +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index bd4f567f..2b50c27d 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -21,10 +21,13 @@ import "./index.css"; import "flag-icons/css/flag-icons.min.css"; import { lazyWithPreload } from "./lib/lazyWithPreload"; import { installClientLog } from "./lib/logHooks"; +import { installStaleBuildReload } from "./lib/staleBuild"; import { appCopyOf } from "./i18n/appCopy"; // First, before any render: a crash or a navigation during boot belongs in the log too. installClientLog(appCopyOf); +// A tab from before the last release reloads onto the new build instead of crashing on a lazy chunk. +installStaleBuildReload(); // Split by top-level route so each entry point is its own chunk: a public /watch share link or a // legal page never downloads the authenticated app bundle, and the app never carries them either. @@ -63,50 +66,52 @@ const root = ) : ( - - {/* One tree for the whole app: every anchored popover registers as a node so Escape and - outside-press act only on the topmost layer (S3 folds the Modal + players in). */} - - - {/* Nothing below renders until the tab knows which account it is: every provider - seeds per-account state in its initializer (see BootGate). */} - - - - - - - {/* SavedViewsProvider sits below BOTH filter providers so it can switch its - source (feed ⟷ plex) by the active page and read the right live filters. */} - - - - - - {/* The same quiet screen as the gate's, so a chunk that is still - loading when the gate opens continues it instead of blanking. */} - }> - - - - - - - - - - - - - - - - + {/* One tree for the whole app: every anchored popover registers as a node so Escape and + outside-press act only on the topmost layer (S3 folds the Modal + players in). */} + + + {/* Nothing below renders until the tab knows which account it is: every provider + seeds per-account state in its initializer (see BootGate). */} + + + + + + + {/* SavedViewsProvider sits below BOTH filter providers so it can switch its + source (feed ⟷ plex) by the active page and read the right live filters. */} + + + + + + {/* The same quiet screen as the gate's, so a chunk that is still + loading when the gate opens continues it instead of blanking. */} + }> + + + + + + + + + + + + + + + ); createRoot(document.getElementById("root")!).render( - }>{root} + {/* Around EVERY entry, the public ones too: a crash on a shared /watch link, or its chunk gone + twice over after a release (lib/staleBuild), left a blank white page with no way on. */} + + }>{root} + , );