Files
HealthLog/src/components/version-poller.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

151 lines
4.9 KiB
TypeScript

"use client";
import { useEffect } from "react";
import { apiGet } from "@/lib/api/api-fetch";
/**
* v1.4.38.4 — runtime self-heal for the stale-shell post-deploy
* paper-cut.
*
* After a release the cached SPA shell still references the old
* chunk filenames; the running React tree lazy-loads them, the new
* server 404s them, and the user lands on `ChunkLoadError`. The
* v1.4.38.3 `AppError` boundary added a one-shot
* `window.location.reload()` for that case, but it only fires AFTER
* the user trips the error — typically in the middle of a
* navigation flow.
*
* This component runs the check pro-actively. Every 60 s it fetches
* `/api/version` and compares the live version string against
* `NEXT_PUBLIC_APP_VERSION` (injected from `package.json` at build
* time by `next.config.ts`). When the live version moves ahead of
* the running shell:
*
* 1. Unregister every active service worker so the next page load
* doesn't reinstall the old SW.
* 2. Delete every CacheStorage entry so the precached root HTML
* and the `/_next/static/*` chunks from the previous deploy
* can't be served back.
* 3. `window.location.reload()` — fetches the fresh shell + new
* chunk graph.
*
* The reload guard is keyed on the TARGET version: `sessionStorage`
* records which live version we last reloaded for, and only a repeat
* mismatch against that SAME version is suppressed (a misset version —
* server briefly serves a stale image mid-deploy — cannot loop).
* Polling itself never stops, so on a multi-deploy day a SECOND
* release moves the live version past the recorded one and the heal
* flow re-arms. The pre-v1.16.8 guard disabled all polling for the
* rest of the session after one attempt, which left a stale shell
* with 404ing chunks stuck forever once the guard was spent.
*/
const POLL_INTERVAL_MS = 60_000;
const SESSION_GUARD_KEY = "healthlog:version-reload-attempted";
const SHELL_VERSION = process.env.NEXT_PUBLIC_APP_VERSION ?? "";
export type VersionPollDecision = "up-to-date" | "reload" | "already-attempted";
/**
* Pure reload decision so the guard semantics carry direct unit
* coverage. `lastReloadedFor` is the live version recorded by the most
* recent heal attempt in this session (`null` when none happened);
* legacy guard values (the pre-v1.16.8 timestamp) never match a real
* version string, so a stuck session from before the change heals on
* its next mismatch.
*/
export function resolveVersionPollDecision(
liveVersion: string | null,
shellVersion: string,
lastReloadedFor: string | null,
): VersionPollDecision {
if (!liveVersion || liveVersion === shellVersion) return "up-to-date";
if (lastReloadedFor === liveVersion) return "already-attempted";
return "reload";
}
async function fetchLiveVersion(signal: AbortSignal): Promise<string | null> {
try {
const data = await apiGet<{ version?: string } | undefined>(
"/api/version",
{ cache: "no-store", signal },
);
return data?.version ?? null;
} catch {
return null;
}
}
async function evictAndReload(targetVersion: string): Promise<void> {
try {
sessionStorage.setItem(SESSION_GUARD_KEY, targetVersion);
} catch {
// sessionStorage can throw under strict-privacy modes; fall
// through to the reload anyway — worst case the next deploy
// re-triggers the same heal flow.
}
if ("serviceWorker" in navigator) {
try {
const regs = await navigator.serviceWorker.getRegistrations();
await Promise.all(regs.map((r) => r.unregister()));
} catch {
/* best effort */
}
}
if (typeof caches !== "undefined") {
try {
const keys = await caches.keys();
await Promise.all(keys.map((k) => caches.delete(k)));
} catch {
/* best effort */
}
}
window.location.reload();
}
export function VersionPoller(): null {
useEffect(() => {
if (!SHELL_VERSION) return;
if (typeof window === "undefined") return;
const controller = new AbortController();
async function checkOnce(): Promise<void> {
const live = await fetchLiveVersion(controller.signal);
let lastReloadedFor: string | null = null;
try {
lastReloadedFor = sessionStorage.getItem(SESSION_GUARD_KEY);
} catch {
/* fall through — treat as "no attempt recorded" */
}
const decision = resolveVersionPollDecision(
live,
SHELL_VERSION,
lastReloadedFor,
);
if (decision !== "reload") return;
await evictAndReload(live as string);
}
// First check 5 s after mount — gives the app a chance to settle
// before we touch caches. Then on the recurring interval.
const initial = window.setTimeout(() => void checkOnce(), 5_000);
const interval = window.setInterval(
() => void checkOnce(),
POLL_INTERVAL_MS,
);
return () => {
window.clearTimeout(initial);
window.clearInterval(interval);
controller.abort();
};
}, []);
return null;
}