Compare commits

..

1 Commits

Author SHA1 Message Date
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
12 changed files with 321 additions and 49 deletions

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "app-ts",
"version": "2.4.36",
"version": "2.4.37",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "app-ts",
"version": "2.4.36",
"version": "2.4.37",
"license": "ISC",
"dependencies": {
"@anthropic-ai/sdk": "^0.102.0",

View File

@@ -1,6 +1,6 @@
{
"name": "app-ts",
"version": "2.4.36",
"version": "2.4.37",
"description": "",
"main": "dist/server.js",
"scripts": {

View File

@@ -0,0 +1,46 @@
import { describe, it, expect, beforeEach } from "vitest";
import {
reportServerVersion,
subscribeNewVersion,
getNewVersion,
BUILD_VERSION,
__resetVersionStateForTest,
} from "../admin/utils/appVersion";
/**
* Client store backing the "new version available" banner. The server stamps
* X-App-Version on every response; apiFetch feeds it here. A version that
* differs from the one baked into THIS bundle (BUILD_VERSION) means a deploy
* happened → latch it and notify subscribers (the banner).
*/
describe("appVersion store", () => {
beforeEach(() => __resetVersionStateForTest());
it("ignores the same version, empty, or null", () => {
reportServerVersion(BUILD_VERSION);
reportServerVersion("");
reportServerVersion(null);
reportServerVersion(undefined);
expect(getNewVersion()).toBeNull();
});
it("latches + notifies once when the server reports a different version", () => {
let notified = 0;
const unsub = subscribeNewVersion(() => {
notified++;
});
reportServerVersion(`${BUILD_VERSION}-next`);
expect(getNewVersion()).toBe(`${BUILD_VERSION}-next`);
expect(notified).toBe(1);
// Idempotent: once a new version is known, further reports don't re-notify
// or overwrite (the banner is already showing).
reportServerVersion("99.0.0");
expect(getNewVersion()).toBe(`${BUILD_VERSION}-next`);
expect(notified).toBe(1);
unsub();
});
});

View File

@@ -0,0 +1,24 @@
import { describe, it, expect } from "vitest";
import Fastify from "fastify";
import { registerVersionHeader, readAppVersion } from "../middleware/version";
/**
* The server stamps every response with X-App-Version so the SPA can detect a
* deploy (the client compares it to the version baked into its bundle).
*/
describe("registerVersionHeader", () => {
it("adds X-App-Version to responses", async () => {
const app = Fastify({ logger: false });
registerVersionHeader(app, "9.9.9");
app.get("/ping", async () => ({ ok: true }));
const res = await app.inject({ method: "GET", url: "/ping" });
expect(res.headers["x-app-version"]).toBe("9.9.9");
await app.close();
});
it("defaults to the package.json version (semver string)", () => {
expect(readAppVersion()).toMatch(/^\d+\.\d+\.\d+/);
});
});

View File

@@ -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>

View 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>
}
/>
);
}

View File

@@ -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),

View 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();
}

View 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;
}),
);
}

33
src/middleware/version.ts Normal file
View File

@@ -0,0 +1,33 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import type { FastifyInstance } from "fastify";
/**
* The build version (from package.json), read once. A static `import` of the
* root package.json would pull a file outside `src` into the program and expand
* tsc's rootDir, so resolve it at runtime relative to the compiled file.
*/
export function readAppVersion(): string {
try {
const pkg = JSON.parse(
readFileSync(join(__dirname, "../../package.json"), "utf8"),
) as { version?: string };
return pkg.version ?? "0.0.0";
} catch {
return "0.0.0";
}
}
/**
* Stamp every response with `X-App-Version` so the SPA can detect a new deploy
* (the client compares it to the version baked into its own bundle and prompts
* a refresh on mismatch).
*/
export function registerVersionHeader(
app: FastifyInstance,
version: string = readAppVersion(),
): void {
app.addHook("onSend", async (_request, reply) => {
reply.header("X-App-Version", version);
});
}

View File

@@ -7,6 +7,7 @@ import rateLimit from "@fastify/rate-limit";
import path from "path";
import { config } from "./config/env";
import { securityHeaders } from "./middleware/security";
import { registerVersionHeader } from "./middleware/version";
import prisma from "./config/database";
import authRoutes from "./routes/admin/auth";
@@ -100,6 +101,9 @@ async function start() {
// --- Security headers ---
app.addHook("onRequest", securityHeaders);
// --- Version header: lets the SPA detect a new deploy and prompt a refresh ---
registerVersionHeader(app);
// --- Global error handler — consistent { success, error } format ---
app.setErrorHandler(
(err: Error & { statusCode?: number }, request, reply) => {

View File

@@ -1,10 +1,20 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { readFileSync } from "node:fs";
// Bake the package.json version into the bundle so the running SPA can compare
// it to the server's X-App-Version header and detect a new deploy.
const appVersion = (
JSON.parse(readFileSync("./package.json", "utf8")) as { version: string }
).version;
export default defineConfig({
plugins: [react()],
root: ".",
publicDir: "public",
define: {
__APP_VERSION__: JSON.stringify(appVersion),
},
server: {
port: 3000,
},