"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 { try { const data = await apiGet<{ version?: string } | undefined>( "/api/version", { cache: "no-store", signal }, ); return data?.version ?? null; } catch { return null; } } // In-memory mirror of the session guard: under strict-privacy modes // sessionStorage throws, and without a fallback a persistent // shell/live mismatch (misconfigured build) would reload every poll. // Module scope survives re-renders but not the reload itself — after // a reload the storage read is retried first, so the mirror only has // to break the loop within one document lifetime. let inMemoryReloadGuard: string | null = null; async function evictAndReload(targetVersion: string): Promise { inMemoryReloadGuard = targetVersion; try { sessionStorage.setItem(SESSION_GUARD_KEY, targetVersion); } catch { // sessionStorage can throw under strict-privacy modes; the // in-memory mirror above still breaks same-document loops. } 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 { const live = await fetchLiveVersion(controller.signal); let lastReloadedFor: string | null = null; try { lastReloadedFor = sessionStorage.getItem(SESSION_GUARD_KEY); } catch { /* storage unavailable — the in-memory mirror still applies */ } lastReloadedFor ??= inMemoryReloadGuard; 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; }