From 429d688ea811d836b7f17f60e2d3c4f9373aa52b Mon Sep 17 00:00:00 2001 From: Jonas Braathen Date: Sun, 18 Oct 2026 23:03:56 +0200 Subject: [PATCH] web: reload once on any client-side error to recover from cached old builds --- web/src/app/error.tsx | 29 ++++++----- web/src/instrumentation-client.ts | 5 ++ web/src/lib/chunkError.test.ts | 30 ----------- web/src/lib/chunkError.ts | 9 ---- web/src/lib/reloadOnError.test.ts | 82 +++++++++++++++++++++++++++++++ web/src/lib/reloadOnError.ts | 65 ++++++++++++++++++++++++ web/src/lib/reportError.ts | 4 +- 7 files changed, 168 insertions(+), 56 deletions(-) delete mode 100644 web/src/lib/chunkError.test.ts delete mode 100644 web/src/lib/chunkError.ts create mode 100644 web/src/lib/reloadOnError.test.ts create mode 100644 web/src/lib/reloadOnError.ts diff --git a/web/src/app/error.tsx b/web/src/app/error.tsx index 95d86e4..2116b7b 100644 --- a/web/src/app/error.tsx +++ b/web/src/app/error.tsx @@ -1,26 +1,25 @@ "use client"; -import { useEffect } from "react"; +import { useEffect, useMemo } from "react"; import { PageHeader } from "@/components/general/PageHeader"; -import { isChunkLoadError } from "@/lib/chunkError"; +import { reloadFor, shouldReload } from "@/lib/reloadOnError"; import { reportError } from "@/lib/reportError"; -const RELOAD_KEY = "chunk-error-reload"; -const RELOAD_COOLDOWN_MS = 10_000; - function ErrorPage({ error }: { error: Error & { digest?: string } }) { - useEffect(() => { - reportError(error); - }, [error]); + // Decided before the first render, so the error page never flashes before + // the reload. The server only renders this for its own errors, which have + // a digest and are not reloaded + const reloading = useMemo( + () => typeof window !== "undefined" && shouldReload(error), + [error] + ); useEffect(() => { - if (!isChunkLoadError(error)) return; - // No reload loop if the chunk is gone for good - const last = Number(sessionStorage.getItem(RELOAD_KEY) ?? 0); - if (Date.now() - last < RELOAD_COOLDOWN_MS) return; - sessionStorage.setItem(RELOAD_KEY, String(Date.now())); - window.location.reload(); - }, [error]); + if (reloading) reloadFor(error); + else reportError(error); + }, [error, reloading]); + + if (reloading) return
; return (
diff --git a/web/src/instrumentation-client.ts b/web/src/instrumentation-client.ts index 8521b9d..4974648 100644 --- a/web/src/instrumentation-client.ts +++ b/web/src/instrumentation-client.ts @@ -1,5 +1,10 @@ +import { takePendingReport } from "@/lib/reloadOnError"; 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 window.addEventListener("error", (event) => { reportError(event.error ?? event.message); diff --git a/web/src/lib/chunkError.test.ts b/web/src/lib/chunkError.test.ts deleted file mode 100644 index 094271e..0000000 --- a/web/src/lib/chunkError.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/web/src/lib/chunkError.ts b/web/src/lib/chunkError.ts deleted file mode 100644 index d5015bb..0000000 --- a/web/src/lib/chunkError.ts +++ /dev/null @@ -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 - ) - ); -} diff --git a/web/src/lib/reloadOnError.test.ts b/web/src/lib/reloadOnError.test.ts new file mode 100644 index 0000000..45ad413 --- /dev/null +++ b/web/src/lib/reloadOnError.test.ts @@ -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(); + 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(); + }); +}); diff --git a/web/src/lib/reloadOnError.ts b/web/src/lib/reloadOnError.ts new file mode 100644 index 0000000..bd1cefa --- /dev/null +++ b/web/src/lib/reloadOnError.ts @@ -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; + } +} diff --git a/web/src/lib/reportError.ts b/web/src/lib/reportError.ts index 150b450..3fd5717 100644 --- a/web/src/lib/reportError.ts +++ b/web/src/lib/reportError.ts @@ -2,7 +2,7 @@ // once there is something to report, so it costs nothing on a normal visit let sdk: Promise | undefined; -export function reportError(error: unknown) { +export function reportError(error: unknown, tags?: Record) { const dsn = process.env.NEXT_PUBLIC_SENTRY_DSN; // unset (as in local builds) leaves error reporting off if (!dsn || process.env.NODE_ENV !== "production") return; @@ -19,5 +19,5 @@ export function reportError(error: unknown) { }); return Sentry; }); - void sdk.then((Sentry) => Sentry.captureException(error)); + void sdk.then((Sentry) => Sentry.captureException(error, { tags })); }