Files
HealthLog/src/app/error.tsx
T
Marc-André Bombeck b5e799c2d6 fix(charts): degrade chart failures to one card and heal stale shells per version
The update poller and the chunk-error reload guard key on the target
version instead of disarming for the whole session, so a second deploy
re-arms the self-heal and a stale shell can no longer strand with
404ing chunks. Lazy chart imports retry once and mount inside a
per-card error boundary with a reload affordance — a failed chunk
degrades to one card instead of replacing the page. The JSON API
client attaches a fifteen-second timeout unless the caller brings its
own signal (the raw streaming path is exempt), chart queries surface
errors with a retry instead of rendering as an empty period, per-type
fetch failures reject instead of caching an empty series as fresh, and
the session and snapshot queries retry once on transient network or
server errors.
2026-06-11 21:17:27 +02:00

91 lines
3.3 KiB
TypeScript

"use client";
import { useEffect } from "react";
import { ErrorDetails } from "@/components/error-details";
const CHUNK_RELOAD_KEY = "healthlog:chunk-reload-attempted";
const SHELL_VERSION = process.env.NEXT_PUBLIC_APP_VERSION ?? "";
function isChunkLoadError(err: Error & { digest?: string }): boolean {
if (err.name === "ChunkLoadError") return true;
const msg = err.message ?? "";
return (
msg.includes("Loading chunk") ||
msg.includes("Loading CSS chunk") ||
msg.includes("Failed to load chunk") ||
msg.includes("Failed to fetch dynamically imported module")
);
}
/**
* Guard value recorded per auto-reload attempt — the running shell's
* build version, so the suppression is scoped to ONE shell. After a
* successful heal the reloaded page carries the NEW version and the
* guard re-arms by construction; only a shell that reloaded and is
* STILL broken (the new build is also missing the chunk) stays
* suppressed. Builds without an injected version degrade to the legacy
* once-per-session behaviour via the "unversioned" sentinel.
*/
export function chunkReloadGuardValue(shellVersion: string): string {
return shellVersion || "unversioned";
}
/**
* Pure reload decision so the guard semantics carry direct unit
* coverage. `stored` is the sessionStorage value from the last attempt
* (`null` when none happened); legacy guard values (the pre-v1.16.8
* timestamp) never match a version string, so a session stuck from
* before the change heals on its next chunk error.
*/
export function shouldAttemptChunkReload(
stored: string | null,
shellVersion: string,
): boolean {
return stored !== chunkReloadGuardValue(shellVersion);
}
export default function AppError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
// Surface to any client-side error tracker that's already hooked up.
if (typeof window !== "undefined") {
const g = window as typeof window & {
__healthlog_onError?: (err: Error & { digest?: string }) => void;
};
g.__healthlog_onError?.(error);
}
// v1.4.38.3 — auto-recover from a stale-shell chunk-load error.
// After a deploy the cached shell still references old chunk
// filenames; they 404 and Next.js surfaces them as `ChunkLoadError`.
// A single hard reload fetches the new shell + chunk graph.
// The sessionStorage guard is keyed on the shell version (see
// `chunkReloadGuardValue`) so it suppresses a loop within ONE
// broken shell but re-arms after every deploy — the pre-v1.16.8
// once-per-session guard exhausted on multi-deploy days and left
// the user on the error page with no automatic way out.
if (typeof window !== "undefined" && isChunkLoadError(error)) {
try {
const stored = window.sessionStorage.getItem(CHUNK_RELOAD_KEY);
if (shouldAttemptChunkReload(stored, SHELL_VERSION)) {
window.sessionStorage.setItem(
CHUNK_RELOAD_KEY,
chunkReloadGuardValue(SHELL_VERSION),
);
window.location.reload();
}
} catch {
// sessionStorage can throw under strict privacy modes — fall
// through to the error UI in that case.
}
}
}, [error]);
return <ErrorDetails error={error} reset={reset} />;
}