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:
@@ -2,6 +2,7 @@ import { Component, type ErrorInfo, type ReactNode } from "react";
|
|||||||
import i18n from "../i18n";
|
import i18n from "../i18n";
|
||||||
import { notify } from "../lib/notifications";
|
import { notify } from "../lib/notifications";
|
||||||
import { errorAttrs, log } from "../lib/log";
|
import { errorAttrs, log } from "../lib/log";
|
||||||
|
import { reloadingForNewBuild } from "../lib/staleBuild";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
@@ -20,6 +21,9 @@ export default class ErrorBoundary extends Component<Props, State> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
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", {
|
log.fatal("ui.crash", "the app crashed while rendering", {
|
||||||
...errorAttrs(error),
|
...errorAttrs(error),
|
||||||
...(info.componentStack ? { component_stack: info.componentStack } : {}),
|
...(info.componentStack ? { component_stack: info.componentStack } : {}),
|
||||||
@@ -34,6 +38,7 @@ export default class ErrorBoundary extends Component<Props, State> {
|
|||||||
|
|
||||||
render() {
|
render() {
|
||||||
if (this.state.crashed) {
|
if (this.state.crashed) {
|
||||||
|
if (reloadingForNewBuild()) return null;
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen grid place-items-center text-muted p-6 text-center">
|
<div className="min-h-screen grid place-items-center text-muted p-6 text-center">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
+45
-40
@@ -21,10 +21,13 @@ import "./index.css";
|
|||||||
import "flag-icons/css/flag-icons.min.css";
|
import "flag-icons/css/flag-icons.min.css";
|
||||||
import { lazyWithPreload } from "./lib/lazyWithPreload";
|
import { lazyWithPreload } from "./lib/lazyWithPreload";
|
||||||
import { installClientLog } from "./lib/logHooks";
|
import { installClientLog } from "./lib/logHooks";
|
||||||
|
import { installStaleBuildReload } from "./lib/staleBuild";
|
||||||
import { appCopyOf } from "./i18n/appCopy";
|
import { appCopyOf } from "./i18n/appCopy";
|
||||||
|
|
||||||
// First, before any render: a crash or a navigation during boot belongs in the log too.
|
// First, before any render: a crash or a navigation during boot belongs in the log too.
|
||||||
installClientLog(appCopyOf);
|
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
|
// 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.
|
// legal page never downloads the authenticated app bundle, and the app never carries them either.
|
||||||
@@ -63,50 +66,52 @@ const root =
|
|||||||
</FloatingTree>
|
</FloatingTree>
|
||||||
) : (
|
) : (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<ErrorBoundary>
|
{/* One tree for the whole app: every anchored popover registers as a node so Escape and
|
||||||
{/* 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). */}
|
||||||
outside-press act only on the topmost layer (S3 folds the Modal + players in). */}
|
<FloatingTree>
|
||||||
<FloatingTree>
|
<ConfirmProvider>
|
||||||
<ConfirmProvider>
|
{/* Nothing below renders until the tab knows which account it is: every provider
|
||||||
{/* Nothing below renders until the tab knows which account it is: every provider
|
seeds per-account state in its initializer (see BootGate). */}
|
||||||
seeds per-account state in its initializer (see BootGate). */}
|
<BootGate>
|
||||||
<BootGate>
|
<NavigationProvider>
|
||||||
<NavigationProvider>
|
<FeedFiltersProvider>
|
||||||
<FeedFiltersProvider>
|
<PlaylistsProvider>
|
||||||
<PlaylistsProvider>
|
<PlexProvider>
|
||||||
<PlexProvider>
|
<PlexViewBridge>
|
||||||
<PlexViewBridge>
|
{/* SavedViewsProvider sits below BOTH filter providers so it can switch its
|
||||||
{/* SavedViewsProvider sits below BOTH filter providers so it can switch its
|
source (feed ⟷ plex) by the active page and read the right live filters. */}
|
||||||
source (feed ⟷ plex) by the active page and read the right live filters. */}
|
<SavedViewsProvider>
|
||||||
<SavedViewsProvider>
|
<ChannelsProvider>
|
||||||
<ChannelsProvider>
|
<FeedViewProvider>
|
||||||
<FeedViewProvider>
|
<PrefsProvider>
|
||||||
<PrefsProvider>
|
<WizardProvider>
|
||||||
<WizardProvider>
|
{/* The same quiet screen as the gate's, so a chunk that is still
|
||||||
{/* The same quiet screen as the gate's, so a chunk that is still
|
loading when the gate opens continues it instead of blanking. */}
|
||||||
loading when the gate opens continues it instead of blanking. */}
|
<Suspense fallback={<BootScreen />}>
|
||||||
<Suspense fallback={<BootScreen />}>
|
<App />
|
||||||
<App />
|
</Suspense>
|
||||||
</Suspense>
|
</WizardProvider>
|
||||||
</WizardProvider>
|
</PrefsProvider>
|
||||||
</PrefsProvider>
|
</FeedViewProvider>
|
||||||
</FeedViewProvider>
|
</ChannelsProvider>
|
||||||
</ChannelsProvider>
|
</SavedViewsProvider>
|
||||||
</SavedViewsProvider>
|
</PlexViewBridge>
|
||||||
</PlexViewBridge>
|
</PlexProvider>
|
||||||
</PlexProvider>
|
</PlaylistsProvider>
|
||||||
</PlaylistsProvider>
|
</FeedFiltersProvider>
|
||||||
</FeedFiltersProvider>
|
</NavigationProvider>
|
||||||
</NavigationProvider>
|
</BootGate>
|
||||||
</BootGate>
|
</ConfirmProvider>
|
||||||
</ConfirmProvider>
|
</FloatingTree>
|
||||||
</FloatingTree>
|
|
||||||
</ErrorBoundary>
|
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
|
|
||||||
createRoot(document.getElementById("root")!).render(
|
createRoot(document.getElementById("root")!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<Suspense fallback={<div style={{ minHeight: "100vh" }} />}>{root}</Suspense>
|
{/* 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>,
|
</React.StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user