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 }));
}