From 4bb7de724779ea3af3dd66e09ae9cee5aed0302f Mon Sep 17 00:00:00 2001 From: BOHA Date: Sat, 13 Jun 2026 21:19:46 +0200 Subject: [PATCH] feat(app): detect new deploy and prompt refresh (+ lazy-chunk recovery) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- package-lock.json | 4 +- package.json | 2 +- src/__tests__/app-version-store.test.ts | 46 +++++++++ src/__tests__/version-header.test.ts | 24 +++++ src/admin/AdminApp.tsx | 125 +++++++++++++++--------- src/admin/components/UpdateBanner.tsx | 35 +++++++ src/admin/utils/api.ts | 4 + src/admin/utils/appVersion.ts | 48 +++++++++ src/admin/utils/lazyWithReload.ts | 35 +++++++ src/middleware/version.ts | 33 +++++++ src/server.ts | 4 + vite.config.ts | 10 ++ 12 files changed, 321 insertions(+), 49 deletions(-) create mode 100644 src/__tests__/app-version-store.test.ts create mode 100644 src/__tests__/version-header.test.ts create mode 100644 src/admin/components/UpdateBanner.tsx create mode 100644 src/admin/utils/appVersion.ts create mode 100644 src/admin/utils/lazyWithReload.ts create mode 100644 src/middleware/version.ts diff --git a/package-lock.json b/package-lock.json index dd3c114..5aa809c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 2ae12b4..6996c7a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "app-ts", - "version": "2.4.36", + "version": "2.4.37", "description": "", "main": "dist/server.js", "scripts": { diff --git a/src/__tests__/app-version-store.test.ts b/src/__tests__/app-version-store.test.ts new file mode 100644 index 0000000..5ce7d89 --- /dev/null +++ b/src/__tests__/app-version-store.test.ts @@ -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(); + }); +}); diff --git a/src/__tests__/version-header.test.ts b/src/__tests__/version-header.test.ts new file mode 100644 index 0000000..3e530cd --- /dev/null +++ b/src/__tests__/version-header.test.ts @@ -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+/); + }); +}); diff --git a/src/admin/AdminApp.tsx b/src/admin/AdminApp.tsx index 99e29a2..9393a40 100644 --- a/src/admin/AdminApp.tsx +++ b/src/admin/AdminApp.tsx @@ -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() { + }> diff --git a/src/admin/components/UpdateBanner.tsx b/src/admin/components/UpdateBanner.tsx new file mode 100644 index 0000000..2323e18 --- /dev/null +++ b/src/admin/components/UpdateBanner.tsx @@ -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 ( + window.location.reload()} + > + Obnovit + + } + /> + ); +} diff --git a/src/admin/utils/api.ts b/src/admin/utils/api.ts index 6c775e0..ea1f300 100644 --- a/src/admin/utils/api.ts +++ b/src/admin/utils/api.ts @@ -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), diff --git a/src/admin/utils/appVersion.ts b/src/admin/utils/appVersion.ts new file mode 100644 index 0000000..f671a7d --- /dev/null +++ b/src/admin/utils/appVersion.ts @@ -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(); +} diff --git a/src/admin/utils/lazyWithReload.ts b/src/admin/utils/lazyWithReload.ts new file mode 100644 index 0000000..6c5d088 --- /dev/null +++ b/src/admin/utils/lazyWithReload.ts @@ -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` 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, +>(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; + }), + ); +} diff --git a/src/middleware/version.ts b/src/middleware/version.ts new file mode 100644 index 0000000..9676201 --- /dev/null +++ b/src/middleware/version.ts @@ -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); + }); +} diff --git a/src/server.ts b/src/server.ts index a177269..d4171a6 100644 --- a/src/server.ts +++ b/src/server.ts @@ -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) => { diff --git a/vite.config.ts b/vite.config.ts index 71819a0..ea36f8b 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -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, },