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:
@@ -7,65 +7,97 @@ import { queryClient } from "./lib/queryClient";
|
||||
import ErrorBoundary from "./components/ErrorBoundary";
|
||||
import AppShell from "./ui/AppShell";
|
||||
import AlertContainer from "./components/AlertContainer";
|
||||
import UpdateBanner from "./components/UpdateBanner";
|
||||
import { lazyWithReload } from "./utils/lazyWithReload";
|
||||
import MuiProvider from "./ui/MuiProvider";
|
||||
import { LoadingState } from "./ui";
|
||||
import Login from "./pages/Login";
|
||||
import Dashboard from "./pages/Dashboard";
|
||||
|
||||
const Odin = lazy(() => import("./pages/Odin"));
|
||||
const Users = lazy(() => import("./pages/Users"));
|
||||
const Attendance = lazy(() => import("./pages/Attendance"));
|
||||
const AttendanceHistory = lazy(() => import("./pages/AttendanceHistory"));
|
||||
const AttendanceAdmin = lazy(() => import("./pages/AttendanceAdmin"));
|
||||
const AttendanceBalances = lazy(() => import("./pages/AttendanceBalances"));
|
||||
const AttendanceCreate = lazy(() => import("./pages/AttendanceCreate"));
|
||||
const LeaveRequests = lazy(() => import("./pages/LeaveRequests"));
|
||||
const LeaveApproval = lazy(() => import("./pages/LeaveApproval"));
|
||||
const AttendanceLocation = lazy(() => import("./pages/AttendanceLocation"));
|
||||
const PlanWork = lazy(() => import("./pages/PlanWork"));
|
||||
const Trips = lazy(() => import("./pages/Trips"));
|
||||
const TripsHistory = lazy(() => import("./pages/TripsHistory"));
|
||||
const TripsAdmin = lazy(() => import("./pages/TripsAdmin"));
|
||||
const Vehicles = lazy(() => import("./pages/Vehicles"));
|
||||
const Offers = lazy(() => import("./pages/Offers"));
|
||||
const OfferDetail = lazy(() => import("./pages/OfferDetail"));
|
||||
const OffersCustomers = lazy(() => import("./pages/OffersCustomers"));
|
||||
const OffersTemplates = lazy(() => import("./pages/OffersTemplates"));
|
||||
const Orders = lazy(() => import("./pages/Orders"));
|
||||
const OrderDetail = lazy(() => import("./pages/OrderDetail"));
|
||||
const IssuedOrderDetail = lazy(() => import("./pages/IssuedOrderDetail"));
|
||||
const Projects = lazy(() => import("./pages/Projects"));
|
||||
const ProjectDetail = lazy(() => import("./pages/ProjectDetail"));
|
||||
const Invoices = lazy(() => import("./pages/Invoices"));
|
||||
const InvoiceDetail = lazy(() => import("./pages/InvoiceDetail"));
|
||||
const Settings = lazy(() => import("./pages/Settings"));
|
||||
const AuditLog = lazy(() => import("./pages/AuditLog"));
|
||||
const NotFound = lazy(() => import("./pages/NotFound"));
|
||||
const Warehouse = lazy(() => import("./pages/Warehouse"));
|
||||
const WarehouseItems = lazy(() => import("./pages/WarehouseItems"));
|
||||
const WarehouseItemDetail = lazy(() => import("./pages/WarehouseItemDetail"));
|
||||
const WarehouseReceipts = lazy(() => import("./pages/WarehouseReceipts"));
|
||||
const WarehouseReceiptDetail = lazy(
|
||||
const Odin = lazyWithReload(() => import("./pages/Odin"));
|
||||
const Users = lazyWithReload(() => import("./pages/Users"));
|
||||
const Attendance = lazyWithReload(() => import("./pages/Attendance"));
|
||||
const AttendanceHistory = lazyWithReload(
|
||||
() => import("./pages/AttendanceHistory"),
|
||||
);
|
||||
const AttendanceAdmin = lazyWithReload(() => import("./pages/AttendanceAdmin"));
|
||||
const AttendanceBalances = lazyWithReload(
|
||||
() => import("./pages/AttendanceBalances"),
|
||||
);
|
||||
const AttendanceCreate = lazyWithReload(
|
||||
() => import("./pages/AttendanceCreate"),
|
||||
);
|
||||
const LeaveRequests = lazyWithReload(() => import("./pages/LeaveRequests"));
|
||||
const LeaveApproval = lazyWithReload(() => import("./pages/LeaveApproval"));
|
||||
const AttendanceLocation = lazyWithReload(
|
||||
() => import("./pages/AttendanceLocation"),
|
||||
);
|
||||
const PlanWork = lazyWithReload(() => import("./pages/PlanWork"));
|
||||
const Trips = lazyWithReload(() => import("./pages/Trips"));
|
||||
const TripsHistory = lazyWithReload(() => import("./pages/TripsHistory"));
|
||||
const TripsAdmin = lazyWithReload(() => import("./pages/TripsAdmin"));
|
||||
const Vehicles = lazyWithReload(() => import("./pages/Vehicles"));
|
||||
const Offers = lazyWithReload(() => import("./pages/Offers"));
|
||||
const OfferDetail = lazyWithReload(() => import("./pages/OfferDetail"));
|
||||
const OffersCustomers = lazyWithReload(() => import("./pages/OffersCustomers"));
|
||||
const OffersTemplates = lazyWithReload(() => import("./pages/OffersTemplates"));
|
||||
const Orders = lazyWithReload(() => import("./pages/Orders"));
|
||||
const OrderDetail = lazyWithReload(() => import("./pages/OrderDetail"));
|
||||
const IssuedOrderDetail = lazyWithReload(
|
||||
() => import("./pages/IssuedOrderDetail"),
|
||||
);
|
||||
const Projects = lazyWithReload(() => import("./pages/Projects"));
|
||||
const ProjectDetail = lazyWithReload(() => import("./pages/ProjectDetail"));
|
||||
const Invoices = lazyWithReload(() => import("./pages/Invoices"));
|
||||
const InvoiceDetail = lazyWithReload(() => import("./pages/InvoiceDetail"));
|
||||
const Settings = lazyWithReload(() => import("./pages/Settings"));
|
||||
const AuditLog = lazyWithReload(() => import("./pages/AuditLog"));
|
||||
const NotFound = lazyWithReload(() => import("./pages/NotFound"));
|
||||
const Warehouse = lazyWithReload(() => import("./pages/Warehouse"));
|
||||
const WarehouseItems = lazyWithReload(() => import("./pages/WarehouseItems"));
|
||||
const WarehouseItemDetail = lazyWithReload(
|
||||
() => import("./pages/WarehouseItemDetail"),
|
||||
);
|
||||
const WarehouseReceipts = lazyWithReload(
|
||||
() => import("./pages/WarehouseReceipts"),
|
||||
);
|
||||
const WarehouseReceiptDetail = lazyWithReload(
|
||||
() => import("./pages/WarehouseReceiptDetail"),
|
||||
);
|
||||
const WarehouseReceiptForm = lazy(() => import("./pages/WarehouseReceiptForm"));
|
||||
const WarehouseIssues = lazy(() => import("./pages/WarehouseIssues"));
|
||||
const WarehouseIssueDetail = lazy(() => import("./pages/WarehouseIssueDetail"));
|
||||
const WarehouseIssueForm = lazy(() => import("./pages/WarehouseIssueForm"));
|
||||
const WarehouseReservations = lazy(
|
||||
const WarehouseReceiptForm = lazyWithReload(
|
||||
() => import("./pages/WarehouseReceiptForm"),
|
||||
);
|
||||
const WarehouseIssues = lazyWithReload(() => import("./pages/WarehouseIssues"));
|
||||
const WarehouseIssueDetail = lazyWithReload(
|
||||
() => import("./pages/WarehouseIssueDetail"),
|
||||
);
|
||||
const WarehouseIssueForm = lazyWithReload(
|
||||
() => import("./pages/WarehouseIssueForm"),
|
||||
);
|
||||
const WarehouseReservations = lazyWithReload(
|
||||
() => import("./pages/WarehouseReservations"),
|
||||
);
|
||||
const WarehouseInventory = lazy(() => import("./pages/WarehouseInventory"));
|
||||
const WarehouseInventoryDetail = lazy(
|
||||
const WarehouseInventory = lazyWithReload(
|
||||
() => import("./pages/WarehouseInventory"),
|
||||
);
|
||||
const WarehouseInventoryDetail = lazyWithReload(
|
||||
() => import("./pages/WarehouseInventoryDetail"),
|
||||
);
|
||||
const WarehouseInventoryForm = lazy(
|
||||
const WarehouseInventoryForm = lazyWithReload(
|
||||
() => import("./pages/WarehouseInventoryForm"),
|
||||
);
|
||||
const WarehouseReports = lazy(() => import("./pages/WarehouseReports"));
|
||||
const WarehouseSuppliers = lazy(() => import("./pages/WarehouseSuppliers"));
|
||||
const WarehouseLocations = lazy(() => import("./pages/WarehouseLocations"));
|
||||
const WarehouseCategories = lazy(() => import("./pages/WarehouseCategories"));
|
||||
const WarehouseReports = lazyWithReload(
|
||||
() => import("./pages/WarehouseReports"),
|
||||
);
|
||||
const WarehouseSuppliers = lazyWithReload(
|
||||
() => import("./pages/WarehouseSuppliers"),
|
||||
);
|
||||
const WarehouseLocations = lazyWithReload(
|
||||
() => import("./pages/WarehouseLocations"),
|
||||
);
|
||||
const WarehouseCategories = lazyWithReload(
|
||||
() => import("./pages/WarehouseCategories"),
|
||||
);
|
||||
const UiKit = import.meta.env.DEV ? lazy(() => import("./pages/UiKit")) : null;
|
||||
|
||||
export default function AdminApp() {
|
||||
@@ -75,6 +107,7 @@ export default function AdminApp() {
|
||||
<AlertProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AlertContainer />
|
||||
<UpdateBanner />
|
||||
<ErrorBoundary>
|
||||
<Suspense fallback={<LoadingState />}>
|
||||
<Routes>
|
||||
|
||||
35
src/admin/components/UpdateBanner.tsx
Normal file
35
src/admin/components/UpdateBanner.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
import Snackbar from "@mui/material/Snackbar";
|
||||
import Button from "@mui/material/Button";
|
||||
import { subscribeNewVersion, getNewVersion } from "../utils/appVersion";
|
||||
|
||||
/**
|
||||
* Shows a persistent banner when the server reports a newer build than the one
|
||||
* this tab is running (detected via the X-App-Version header in apiFetch). The
|
||||
* reload is user-initiated — we never force it, so an in-progress form is safe.
|
||||
*/
|
||||
export default function UpdateBanner() {
|
||||
const newVersion = useSyncExternalStore(
|
||||
subscribeNewVersion,
|
||||
getNewVersion,
|
||||
() => null,
|
||||
);
|
||||
|
||||
return (
|
||||
<Snackbar
|
||||
open={Boolean(newVersion)}
|
||||
anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
|
||||
message="Je k dispozici nová verze aplikace."
|
||||
action={
|
||||
<Button
|
||||
color="primary"
|
||||
size="small"
|
||||
variant="contained"
|
||||
onClick={() => window.location.reload()}
|
||||
>
|
||||
Obnovit
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { reportServerVersion } from "./appVersion";
|
||||
|
||||
class ApiState {
|
||||
showSessionExpiredAlert = false;
|
||||
showLogoutAlert = false;
|
||||
@@ -120,6 +122,8 @@ export const apiFetch = async (
|
||||
headers,
|
||||
credentials: "include",
|
||||
});
|
||||
// Detect a new deploy: compare the server's build version to ours.
|
||||
reportServerVersion(response.headers.get("X-App-Version"));
|
||||
|
||||
if (response.status === 401 && state.refreshFn) {
|
||||
// If the caller already unmounted (TanStack Query aborted the request),
|
||||
|
||||
48
src/admin/utils/appVersion.ts
Normal file
48
src/admin/utils/appVersion.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* New-deploy detection for the SPA.
|
||||
*
|
||||
* The server stamps every response with `X-App-Version`. `apiFetch` feeds that
|
||||
* header here via `reportServerVersion`. When it differs from the version baked
|
||||
* into THIS bundle at build time (`BUILD_VERSION`), a deploy has happened — we
|
||||
* latch it and notify subscribers (the refresh banner). We never force a
|
||||
* reload; the user picks the moment (they may be mid-form).
|
||||
*/
|
||||
|
||||
// Injected by Vite `define` at build time; falls back to "dev" in tests / when
|
||||
// running unbuilt. `typeof` guard avoids a ReferenceError when undefined.
|
||||
declare const __APP_VERSION__: string;
|
||||
export const BUILD_VERSION: string =
|
||||
typeof __APP_VERSION__ !== "undefined" ? __APP_VERSION__ : "dev";
|
||||
|
||||
let newVersion: string | null = null;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
/**
|
||||
* Compare a server-reported version against this bundle's build version. Latches
|
||||
* the first genuine mismatch and notifies subscribers exactly once.
|
||||
*/
|
||||
export function reportServerVersion(
|
||||
serverVersion: string | null | undefined,
|
||||
): void {
|
||||
if (newVersion) return; // already latched — banner is up
|
||||
if (!serverVersion || serverVersion === BUILD_VERSION) return;
|
||||
newVersion = serverVersion;
|
||||
for (const l of listeners) l();
|
||||
}
|
||||
|
||||
export function subscribeNewVersion(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
export function getNewVersion(): string | null {
|
||||
return newVersion;
|
||||
}
|
||||
|
||||
/** Test-only: reset the module singleton between cases. */
|
||||
export function __resetVersionStateForTest(): void {
|
||||
newVersion = null;
|
||||
listeners.clear();
|
||||
}
|
||||
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