web: reload once on any client-side error to recover from cached old builds
web / ci (push) Successful in 2m45s
web / ci (push) Successful in 2m45s
This commit is contained in:
7 files changed
+168
-56
No files matched your search
+14
-15
@@ -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 <main className="site-main" id="main" />;
|
||||
|
||||
return (
|
||||
<main className="site-main" id="main">
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,7 +2,7 @@
|
||||
// once there is something to report, so it costs nothing on a normal visit
|
||||
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;
|
||||
// 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 }));
|
||||
}
|
||||
Reference in new issue
Block a user