web: reload once on any client-side error to recover from cached old builds
web / ci (push) Successful in 2m45s

This commit is contained in:
ponas committed 2026-10-18 23:03:56 +02:00
1 parent cff6376129
commit 429d688ea8
7 files changed
+168 -56

No files matched your search

+14 -15
View File
@@ -1,26 +1,25 @@
"use client"; "use client";
import { useEffect } from "react"; import { useEffect, useMemo } from "react";
import { PageHeader } from "@/components/general/PageHeader"; import { PageHeader } from "@/components/general/PageHeader";
import { isChunkLoadError } from "@/lib/chunkError"; import { reloadFor, shouldReload } from "@/lib/reloadOnError";
import { reportError } from "@/lib/reportError"; import { reportError } from "@/lib/reportError";
const RELOAD_KEY = "chunk-error-reload";
const RELOAD_COOLDOWN_MS = 10_000;
function ErrorPage({ error }: { error: Error & { digest?: string } }) { function ErrorPage({ error }: { error: Error & { digest?: string } }) {
useEffect(() => { // Decided before the first render, so the error page never flashes before
reportError(error); // the reload. The server only renders this for its own errors, which have
}, [error]); // a digest and are not reloaded
const reloading = useMemo(
() => typeof window !== "undefined" && shouldReload(error),
[error]
);
useEffect(() => { useEffect(() => {
if (!isChunkLoadError(error)) return; if (reloading) reloadFor(error);
// No reload loop if the chunk is gone for good else reportError(error);
const last = Number(sessionStorage.getItem(RELOAD_KEY) ?? 0); }, [error, reloading]);
if (Date.now() - last < RELOAD_COOLDOWN_MS) return;
sessionStorage.setItem(RELOAD_KEY, String(Date.now())); if (reloading) return <main className="site-main" id="main" />;
window.location.reload();
}, [error]);
return ( return (
<main className="site-main" id="main"> <main className="site-main" id="main">
+5
View File
@@ -1,5 +1,10 @@
import { takePendingReport } from "@/lib/reloadOnError";
import { reportError } from "@/lib/reportError"; import { reportError } from "@/lib/reportError";
// The error that app/error.tsx reloaded the page for
const recovered = takePendingReport();
if (recovered) reportError(recovered, { recovered_by_reload: "yes" });
// Errors that no React error boundary catches // Errors that no React error boundary catches
window.addEventListener("error", (event) => { window.addEventListener("error", (event) => {
reportError(event.error ?? event.message); reportError(event.error ?? event.message);
-30
View File
@@ -1,30 +0,0 @@
import { describe, expect, it } from "vitest";
import { isChunkLoadError } from "./chunkError.ts";
describe("isChunkLoadError", () => {
it("matches the turbopack chunk loader error", () => {
const error = new Error(
"Failed to load chunk /_next/static/chunks/0xd_vh2ud8yl-.js from module 64893"
);
error.name = "ChunkLoadError";
expect(isChunkLoadError(error)).toBe(true);
});
it("matches by message when the name is generic", () => {
expect(isChunkLoadError(new Error("Loading chunk 123 failed."))).toBe(true);
expect(
isChunkLoadError(
new TypeError("Failed to fetch dynamically imported module: https://neuf.no/x.js")
)
).toBe(true);
expect(isChunkLoadError(new TypeError("Importing a module script failed."))).toBe(true);
});
it("ignores other errors", () => {
expect(isChunkLoadError(new Error("boom"))).toBe(false);
expect(
isChunkLoadError(new TypeError("Cannot read properties of undefined (reading 'slug')"))
).toBe(false);
});
});
-9
View File
@@ -1,9 +0,0 @@
// A tab opened before a deploy may reference chunks the new build no longer ships
export function isChunkLoadError(error: Error): boolean {
return (
error.name === "ChunkLoadError" ||
/failed to load chunk|loading chunk .* failed|dynamically imported module|importing a module script failed/i.test(
error.message
)
);
}
+82
View File
@@ -0,0 +1,82 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { reloadFor, shouldReload, takePendingReport } from "./reloadOnError.ts";
function memoryStorage(): Storage {
const items = new Map<string, string>();
return {
get length() {
return items.size;
},
clear: () => items.clear(),
getItem: (key) => items.get(key) ?? null,
key: (index) => [...items.keys()][index] ?? null,
removeItem: (key) => void items.delete(key),
setItem: (key, value) => void items.set(key, String(value)),
};
}
describe("reloadOnError", () => {
let storage: Storage;
const reload = vi.fn();
beforeEach(() => {
storage = memoryStorage();
reload.mockReset();
vi.stubGlobal("window", { location: { reload, href: "https://neuf.no/arrangementer/chicago" } });
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("reloads for an error raised in the browser", () => {
const error = new TypeError("(0 , O.f) is not a function");
expect(shouldReload(error, storage, 50_000)).toBe(true);
reloadFor(error, storage, 50_000);
expect(reload).toHaveBeenCalledOnce();
});
it("leaves errors from server rendering alone", () => {
const error = Object.assign(new Error("An error occurred in the Server Components render"), {
digest: "2853960925",
});
expect(shouldReload(error, storage, 50_000)).toBe(false);
});
it("does not reload a page again right after reloading it", () => {
const error = new TypeError("Cannot read properties of undefined (reading 'includes')");
reloadFor(error, storage, 50_000);
expect(shouldReload(error, storage, 55_000)).toBe(false);
expect(shouldReload(error, storage, 60_000)).toBe(true);
});
it("still reloads another page right after a reload", () => {
const error = new TypeError("x is not a function");
reloadFor(error, storage, 50_000);
expect(shouldReload(error, storage, 51_000, "https://neuf.no/arrangementer")).toBe(true);
});
it("does not reload without storage, which the loop guard needs", () => {
const broken = memoryStorage();
broken.getItem = () => {
throw new Error("SecurityError");
};
broken.setItem = broken.getItem;
const error = new TypeError("x is not a function");
expect(shouldReload(error, broken, 50_000)).toBe(false);
reloadFor(error, broken, 50_000);
expect(reload).not.toHaveBeenCalled();
});
it("hands the error over to the next page load once", () => {
const error = new TypeError("(0 , r.f) is not a function");
reloadFor(error, storage, 50_000);
const reported = takePendingReport(storage);
expect(reported).toBeInstanceOf(Error);
expect(reported?.name).toBe("TypeError");
expect(reported?.message).toBe("(0 , r.f) is not a function");
expect(reported?.stack).toBe(error.stack);
expect(takePendingReport(storage)).toBeNull();
});
});
+65
View File
@@ -0,0 +1,65 @@
// A browser can hold pages and prefetched navigation data from an earlier
// build: they were sent with a year-long stale-while-revalidate until
// 2026-10-15, and a deploy removes the chunks an open tab still refers to.
// Rendering those with the current build's code fails in no fixed way ("x is
// not a function", failed chunk loads, undefined properties), so any error in
// the browser gets one full reload, which fetches the current page fresh
const LAST_RELOAD_KEY = "error-reload-last";
const PENDING_REPORT_KEY = "error-reload-report";
// The same page failing again this soon after its reload is not something
// reloading fixes. Another page may still need its own reload
const COOLDOWN_MS = 10_000;
export function shouldReload(
error: Error & { digest?: string },
storage: Storage = sessionStorage,
now: number = Date.now(),
url: string = window.location.href
): boolean {
// Errors from rendering on the server carry a digest; a reload asks the
// server for the same thing again
if (error.digest) return false;
try {
const last = JSON.parse(storage.getItem(LAST_RELOAD_KEY) ?? "null");
return !(last?.url === url && now - last.at < COOLDOWN_MS);
} catch {
// no storage, no loop guard
return false;
}
}
// The error is reported after the reload, as the SDK that sends it is only
// downloaded on demand and would not make it out before the page goes away
export function reloadFor(
error: Error,
storage: Storage = sessionStorage,
now: number = Date.now(),
url: string = window.location.href
) {
try {
storage.setItem(LAST_RELOAD_KEY, JSON.stringify({ at: now, url }));
storage.setItem(
PENDING_REPORT_KEY,
JSON.stringify({ name: error.name, message: error.message, stack: error.stack })
);
} catch {
return;
}
window.location.reload();
}
export function takePendingReport(storage: Storage = sessionStorage): Error | null {
try {
const saved = storage.getItem(PENDING_REPORT_KEY);
if (saved === null) return null;
storage.removeItem(PENDING_REPORT_KEY);
const { name, message, stack } = JSON.parse(saved);
const error = new Error(message);
error.name = name;
error.stack = stack;
return error;
} catch {
return null;
}
}
+2 -2
View File
@@ -2,7 +2,7 @@
// once there is something to report, so it costs nothing on a normal visit // once there is something to report, so it costs nothing on a normal visit
let sdk: Promise<typeof import("@sentry/browser")> | undefined; let sdk: Promise<typeof import("@sentry/browser")> | undefined;
export function reportError(error: unknown) { export function reportError(error: unknown, tags?: Record<string, string>) {
const dsn = process.env.NEXT_PUBLIC_SENTRY_DSN; const dsn = process.env.NEXT_PUBLIC_SENTRY_DSN;
// unset (as in local builds) leaves error reporting off // unset (as in local builds) leaves error reporting off
if (!dsn || process.env.NODE_ENV !== "production") return; if (!dsn || process.env.NODE_ENV !== "production") return;
@@ -19,5 +19,5 @@ export function reportError(error: unknown) {
}); });
return Sentry; return Sentry;
}); });
void sdk.then((Sentry) => Sentry.captureException(error)); void sdk.then((Sentry) => Sentry.captureException(error, { tags }));
} }