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>
This commit is contained in:
35
src/admin/utils/lazyWithReload.ts
Normal file
35
src/admin/utils/lazyWithReload.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
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;
|
||||
}),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user