Files
app/src/admin/utils/lazyWithReload.ts
BOHA 4bb7de7247 feat(app): detect new deploy and prompt refresh (+ lazy-chunk recovery)
When a new version is deployed, open tabs no longer silently run stale code
until a manual F5.

- Server stamps every response with X-App-Version (src/middleware/version.ts,
  onSend hook in server.ts).
- Client bakes its build version via Vite define (__APP_VERSION__); apiFetch
  compares the header to it (no polling — piggybacks existing traffic) and
  latches a mismatch in a small store (appVersion.ts).
- UpdateBanner: a persistent bottom Snackbar "Je k dispozici nová verze
  aplikace. — Obnovit" → location.reload(). User-initiated, so an in-progress
  form is never interrupted.
- lazyWithReload: every React.lazy page reloads once if its hashed chunk 404s
  after a deploy (sessionStorage-guarded against loops), fixing the "old chunk
  gone" error screen.

Approach chosen after researching current SPA practice (version header + banner
beats polling / a service worker for a non-PWA admin app).

Tests: +4 (server header, client store latch/notify). Full suite 669 pass,
tsc -b clean, lint 0. Verified live via Playwright (header emitted; banner
appears on a simulated mismatch). Detection applies to deploys from the NEXT
release onward (current clients lack the detection code).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 21:19:46 +02:00

36 lines
1.2 KiB
TypeScript

import { lazy } from "react";
import type { ComponentType } from "react";
const RELOAD_KEY = "spa-chunk-reloaded";
/**
* React.lazy that survives a deploy. If a page's dynamic import fails because
* its hashed chunk was removed by a newer build, reload once to fetch the fresh
* bundle (guarded by sessionStorage so a genuine load failure can't loop). On a
* successful load the guard is cleared so the next deploy can recover too.
*
* The generic mirrors React.lazy's own `ComponentType<any>` constraint — there
* is no non-any equivalent that satisfies it, so the `any` is unavoidable here.
*/
export function lazyWithReload<
// eslint-disable-next-line @typescript-eslint/no-explicit-any
T extends ComponentType<any>,
>(factory: () => Promise<{ default: T }>) {
return lazy(() =>
factory()
.then((mod) => {
sessionStorage.removeItem(RELOAD_KEY);
return mod;
})
.catch((err: unknown) => {
if (!sessionStorage.getItem(RELOAD_KEY)) {
sessionStorage.setItem(RELOAD_KEY, "1");
window.location.reload();
// Never resolves — the reload replaces the document.
return new Promise<{ default: T }>(() => {});
}
throw err;
}),
);
}