mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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.
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;
|
|
}
|