fix(app): reload onto a new build instead of crashing on a missing chunk

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.
This commit is contained in:
2026-09-19 04:51:31 +02:00
parent 3ee00fb051
commit 51e98bfc38
4 changed files with 148 additions and 40 deletions
@@ -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<Props, State> {
}
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<Props, State> {
render() {
if (this.state.crashed) {
if (reloadingForNewBuild()) return null;
return (
<div className="min-h-screen grid place-items-center text-muted p-6 text-center">
<div>
+44
View File
@@ -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);
});
});
+54
View File
@@ -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);
}
+7 -2
View File
@@ -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,7 +66,6 @@ const root =
</FloatingTree>
) : (
<QueryClientProvider client={queryClient}>
<ErrorBoundary>
{/* 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). */}
<FloatingTree>
@@ -101,12 +103,15 @@ const root =
</BootGate>
</ConfirmProvider>
</FloatingTree>
</ErrorBoundary>
</QueryClientProvider>
);
createRoot(document.getElementById("root")!).render(
<React.StrictMode>
{/* 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. */}
<ErrorBoundary>
<Suspense fallback={<div style={{ minHeight: "100vh" }} />}>{root}</Suspense>
</ErrorBoundary>
</React.StrictMode>,
);