Files
HealthLog/src/components/version-poller.tsx
T
Marc-André Bombeck 3c3e5ceab7 fix(config): treat an empty environment variable as unset
docker-compose.yml forwards its whitelist as "${NAME:-}", so a variable
the operator never set reaches the process as the empty string. Reads
written as a nullish fallback kept that string: the weather client built
an empty base URL and every geocode failed with ERR_INVALID_URL, and the
IP geo lookup requested a relative path.

Reads with a fallback now go through src/lib/env.ts (envValue, envOr,
envFlag), which treats empty and whitespace as unset. IP_GEO_LOOKUP_DISABLED
accepts 1, true, yes and on instead of only "1". A structural test refuses
a ?? fallback written directly against process.env anywhere in src/, and
the compose whitelist guard counts the helper's call shape as a read.
2026-09-26 15:20:42 +02:00

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;
}