mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
The 60s poller used to unregister service workers, wipe CacheStorage, and call location.reload() the moment it saw a version bump, with no prompt — firing mid-form, mid-message, or mid-wizard and discarding whatever the user had typed. Keep detecting the new build, but now show a non-blocking, dismissible toast with a Reload action; the eviction and reload run only when the user chooses to reload. Show the toast once per detected version.
186 lines
6.4 KiB
TypeScript
186 lines
6.4 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useRef } from "react";
|
|
import { toast } from "sonner";
|
|
|
|
import { apiGet } from "@/lib/api/api-fetch";
|
|
import { useTranslations } from "@/lib/i18n/context";
|
|
|
|
/**
|
|
* 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 it surfaces a non-blocking toast with a Reload
|
|
* action. The eviction + reload only runs when the user clicks
|
|
* Reload — an in-progress form or chat draft is never destroyed by a
|
|
* silent mid-session reload. On that click:
|
|
*
|
|
* 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 toast is shown once per detected version (the poll keeps
|
|
* running, but a repeated mismatch against a version we already
|
|
* surfaced doesn't re-spam every 60 s). 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). On a multi-deploy day a
|
|
* SECOND release moves the live version past the recorded one and the
|
|
* flow re-arms.
|
|
*/
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
// 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<void> {
|
|
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 {
|
|
const { t } = useTranslations();
|
|
// Tracks the live version we've already surfaced a toast for, so a
|
|
// repeated mismatch on the recurring poll doesn't re-spam.
|
|
const notifiedForRef = useRef<string | null>(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 {
|
|
/* storage unavailable — the in-memory mirror still applies */
|
|
}
|
|
lastReloadedFor ??= inMemoryReloadGuard;
|
|
const decision = resolveVersionPollDecision(
|
|
live,
|
|
SHELL_VERSION,
|
|
lastReloadedFor,
|
|
);
|
|
if (decision !== "reload") return;
|
|
|
|
const target = live as string;
|
|
// One toast per detected version — don't re-open it every poll.
|
|
if (notifiedForRef.current === target) return;
|
|
notifiedForRef.current = target;
|
|
|
|
toast(t("version.updateAvailable.title"), {
|
|
duration: Infinity,
|
|
action: {
|
|
label: t("version.updateAvailable.reload"),
|
|
onClick: () => void evictAndReload(target),
|
|
},
|
|
cancel: {
|
|
label: t("version.updateAvailable.dismiss"),
|
|
onClick: () => {},
|
|
},
|
|
});
|
|
}
|
|
|
|
// First check 5 s after mount — gives the app a chance to settle
|
|
// before we surface anything. 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();
|
|
};
|
|
}, [t]);
|
|
|
|
return null;
|
|
}
|