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";
|
"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">
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
// 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 }));
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user