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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user