From dd6a4771edc690636197bdd54c0c27464edbfdd4 Mon Sep 17 00:00:00 2001 From: abhishek Date: Fri, 28 Aug 2026 11:15:47 +0530 Subject: [PATCH] page loading --- nginx.conf.template | 34 ++++++++-- src/App.tsx | 12 +++- src/components/ErrorBoundary.tsx | 28 ++++++-- src/lib/staleChunk.test.ts | 76 +++++++++++++++++++++ src/lib/staleChunk.ts | 111 +++++++++++++++++++++++++++++++ 5 files changed, 251 insertions(+), 10 deletions(-) create mode 100644 src/lib/staleChunk.test.ts create mode 100644 src/lib/staleChunk.ts diff --git a/nginx.conf.template b/nginx.conf.template index 47c5919..dd9b58b 100644 --- a/nginx.conf.template +++ b/nginx.conf.template @@ -37,14 +37,40 @@ server { index index.html; # React Router owns the paths, so an unknown one is a route, not a 404. try_files $uri $uri/ /index.html; + + # index.html must be revalidated on every visit, and until now it was + # not — the line below is new, and its absence was a real outage. + # + # The block above said "index.html must NOT be cached" and then set no + # cache header at all, which is not the same thing. With neither + # `Cache-Control` nor `Expires`, a browser falls back to HEURISTIC + # caching: RFC 9111 lets it invent a freshness lifetime from + # `Last-Modified`, commonly a tenth of the document's age, and serve the + # document from disk WITHOUT revalidating. So a tab kept the previous + # index.html, that index.html named `InventoryPage-BAzj-ICF.js`, the + # deploy had replaced it with `InventoryPage-Cbh53mHU.js`, and the + # import 404'd on a screen that had worked ten minutes earlier. + # + # `no-cache` does NOT mean "do not store" — it means "revalidate before + # use". The ETag still answers 304 on an unchanged deploy, so this costs + # one conditional request per visit and never a re-download. + add_header Cache-Control "no-cache" always; } - # Hashed filenames, so these can be cached hard. index.html must NOT be, - # or a deploy leaves people on the previous bundle until they force-reload. + # Hashed filenames, so these can be cached hard — the hash changes when the + # content does, which is what makes a year safe. location /assets/ { root /usr/share/nginx/html; - expires 1y; - add_header Cache-Control "public, immutable"; + + # ONE header, not two. `expires 1y` emits its own + # `Cache-Control: max-age=31536000`, and the `add_header` beside it + # appended a second, so every asset went out with two conflicting + # `Cache-Control` lines — `max-age=31536000` and `public, immutable`, + # neither complete. Browsers mostly cope; caches and CDNs in between are + # entitled to take the first and drop `immutable`, or to treat the pair + # as malformed. Merged into a single directive, with `expires` dropped + # because it exists only to emit the header this now sets by hand. + add_header Cache-Control "public, max-age=31536000, immutable" always; } # ── Fiesta ─────────────────────────────────────────────────────────────── diff --git a/src/App.tsx b/src/App.tsx index 40fafc7..e634d41 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,6 +3,7 @@ import { Navigate, Route, Routes } from 'react-router-dom'; import { Spinner } from '@astryxdesign/core/Spinner'; import { RequireRole, useAuth } from '@/auth/AuthContext'; import { HOME_ROUTE } from '@/auth/roles'; +import { withStaleChunkRecovery } from '@/lib/staleChunk'; import { LoginPage } from '@/features/auth/LoginPage'; import { NearleAdminShell } from '@/features/nearle-admin/NearleAdminShell'; import { StoreAdminShell } from '@/features/store-admin/StoreAdminShell'; @@ -15,8 +16,17 @@ import { StoreUserShell } from '@/features/store-user/StoreUserShell'; * means every visitor downloads the spreadsheet importer to look at a dashboard. * That is the one thing from it worth deliberately not copying. */ +/** + * Split pages recover from a deploy that lands mid-session. + * + * `withStaleChunkRecovery` is the difference between "Inventory is broken" and + * a reload nobody notices: the hashed filename this tab remembers stops + * existing the moment a new build ships, and the import 404s. See + * `lib/staleChunk.ts` — real errors still surface, only the missing chunk is + * retried. + */ const named = (key: T, loader: () => Promise>) => - lazy(() => loader().then((module) => ({ default: module[key] }))); + lazy(withStaleChunkRecovery(() => loader().then((module) => ({ default: module[key] })))); const StoresPage = named('StoresPage', () => import('@/features/nearle-admin/pages/StoresPage')); const StoreDetailPage = named('StoreDetailPage', () => import('@/features/nearle-admin/pages/StoreDetailPage')); diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx index 1953e91..796c4a1 100644 --- a/src/components/ErrorBoundary.tsx +++ b/src/components/ErrorBoundary.tsx @@ -1,4 +1,5 @@ import { Component, type ErrorInfo, type ReactNode } from 'react'; +import { isStaleChunkError } from '@/lib/staleChunk'; /** * The last line before a white page. @@ -45,12 +46,28 @@ export class ErrorBoundary extends Component { this.setState({ error: null }); }; + private reload = () => { + window.location.reload(); + }; + override render() { const { error } = this.state; if (!error) return this.props.children; const area = this.props.area ?? 'this screen'; + /** + * A page whose code never arrived cannot be re-rendered into existence. + * + * "Try again" clears the error and renders the same `lazy()` component, + * which requests the same missing file and throws the same error — the + * button looked like a recovery and was a loop. `lib/staleChunk.ts` already + * reloads once on its own; landing here means that reload has happened and + * not helped, or was suppressed to avoid a boot loop, so the honest offer + * is a reload the person chooses and a message that names the cause. + */ + const isStale = isStaleChunkError(error); + return (
{ }} >

- Something on {area} failed to render + {isStale ? 'This page was updated while you were working' : `Something on ${area} failed to render`}

- The rest of the console is fine — this is one screen, not the whole app. The message - below is what broke, and the full stack is in the browser console. + {isStale + ? 'A new version of the console was deployed, so the file this tab was about to load no longer exists. Nothing is wrong with your data — reloading picks up the current version.' + : 'The rest of the console is fine — this is one screen, not the whole app. The message below is what broke, and the full stack is in the browser console.'}

 {
         
); diff --git a/src/lib/staleChunk.test.ts b/src/lib/staleChunk.test.ts new file mode 100644 index 0000000..f950f17 --- /dev/null +++ b/src/lib/staleChunk.test.ts @@ -0,0 +1,76 @@ +/** + * The chunk-recovery guard. + * + * The messages below are the real ones each engine throws — the detection is a + * string match against three vendors who agree on nothing, so a fixture copied + * from the wrong browser would pass while production kept failing. + */ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { canRecoverByReloading, isStaleChunkError } from './staleChunk'; + +/** `sessionStorage` and `location` do not exist under `node:test`. */ +const store = new Map(); +(globalThis as unknown as { window: unknown }).window = { + sessionStorage: { + getItem: (k: string) => store.get(k) ?? null, + setItem: (k: string, v: string) => void store.set(k, v), + }, + location: { reload: () => {} }, +}; + +test('recognises what each browser actually throws', () => { + // Chrome and Edge — the message from the reported failure. + assert.ok( + isStaleChunkError( + new Error( + 'Failed to fetch dynamically imported module: https://app.nearledaily.com/assets/InventoryPage-BAzj-ICF.js', + ), + ), + ); + // Firefox. + assert.ok(isStaleChunkError(new Error('error loading dynamically imported module: /assets/x.js'))); + // Safari. + assert.ok(isStaleChunkError(new Error('Importing a module script failed.'))); + // Vite's own preload helper, for a split chunk's stylesheet. + assert.ok(isStaleChunkError(new Error('Unable to preload CSS for /assets/x.css'))); +}); + +test('does not mistake a real bug for a missing chunk', () => { + // The distinction that matters: reloading past one of these would hide a + // genuine crash behind an endless refresh. + assert.equal(isStaleChunkError(new TypeError("Cannot read properties of undefined (reading 'map')")), false); + assert.equal(isStaleChunkError(new Error('Request failed (HTTP 500)')), false); + assert.equal(isStaleChunkError(null), false); + assert.equal(isStaleChunkError(undefined), false); +}); + +test('reloads once, then refuses so it cannot loop', () => { + store.clear(); + const error = new Error('Failed to fetch dynamically imported module: /assets/a.js'); + + assert.equal(canRecoverByReloading(error), true, 'first failure should reload'); + + // Stamp a reload as having just happened. + store.set('nearle:stale-chunk-reload-at', String(Date.now())); + assert.equal( + canRecoverByReloading(error), + false, + 'a second failure inside the cooldown must surface, not reload again', + ); +}); + +test('recovers again from a later deploy', () => { + store.clear(); + // A reload well outside the 30s window — a different deploy, hours later. + store.set('nearle:stale-chunk-reload-at', String(Date.now() - 60 * 60_000)); + assert.equal( + canRecoverByReloading(new Error('Failed to fetch dynamically imported module: /assets/b.js')), + true, + ); +}); + +test('never reloads for an error that is not a missing chunk', () => { + store.clear(); + assert.equal(canRecoverByReloading(new TypeError('x is not a function')), false); +}); diff --git a/src/lib/staleChunk.ts b/src/lib/staleChunk.ts new file mode 100644 index 0000000..c1a44f8 --- /dev/null +++ b/src/lib/staleChunk.ts @@ -0,0 +1,111 @@ +/** + * Surviving a deploy that happens while somebody is using the console. + * + * Routes are code-split, so a page is fetched the moment it is first opened. + * The filenames are content-hashed, and a deploy replaces them: the tab that + * was open five minutes ago holds a module graph naming + * `InventoryPage-BAzj-ICF.js`, the server now has `InventoryPage-Cbh53mHU.js`, + * and the import 404s on a screen that worked before lunch. Nothing is wrong + * with the page or the build — the two halves are simply from different + * deploys. + * + * `nginx.conf.template` stops the NEXT visit inheriting it, by revalidating + * index.html instead of letting a browser cache it heuristically. It cannot + * help the tab that is already open: that document is loaded, its import map is + * in memory, and only a reload replaces it. Hence this. + */ + +/** + * One reload, then stop. + * + * A flat "reload on failure" is a boot loop when the chunk is missing for any + * reason other than a deploy — an interrupted upload, a half-written asset + * directory — and a loop takes the console away entirely rather than one + * screen. Recording WHEN we last reloaded rather than THAT we did means a + * failure recurring within the window gives up and shows the error, while a + * genuine second deploy an hour later is still recovered from. + */ +const MARKER = 'nearle:stale-chunk-reload-at'; +const COOLDOWN_MS = 30_000; + +/** + * Whether a thrown value is a chunk that would not load. + * + * Matched on the message because there is no error type to check: the browsers + * disagree on the wording and agree on nothing else. + * + * Chrome/Edge Failed to fetch dynamically imported module: + * Firefox error loading dynamically imported module: + * Safari Importing a module script failed. + * + * `Unable to preload CSS` is Vite's own, thrown by the preload helper when a + * stylesheet belonging to a split chunk has gone the same way. + */ +export function isStaleChunkError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error ?? ''); + return ( + /dynamically imported module/i.test(message) || + /Importing a module script failed/i.test(message) || + /Unable to preload CSS/i.test(message) + ); +} + +/** Private-mode Safari throws on `sessionStorage`; a failure here must not mask the real error. */ +function readMarker(): number { + try { + return Number(window.sessionStorage.getItem(MARKER)) || 0; + } catch { + return 0; + } +} + +function writeMarker(at: number): void { + try { + window.sessionStorage.setItem(MARKER, String(at)); + } catch { + /* Storage unavailable. The reload still happens; only the loop guard is + lost, and a browser that cannot store this cannot loop through it + either — the marker is per-tab, and so is the reload. */ + } +} + +/** + * True when a reload is worth attempting for this error. + * + * Exported for the boundary, which needs to know whether to offer "Reload" or + * the ordinary "Try again" — re-rendering a component whose module never + * arrived just throws the same error again, which is what the old button did. + */ +export function canRecoverByReloading(error: unknown): boolean { + return isStaleChunkError(error) && Date.now() - readMarker() > COOLDOWN_MS; +} + +/** + * Reload once to pick up the current deploy. + * + * Returns a promise that never settles, on purpose: the caller is a `lazy()` + * loader, and resolving or rejecting it would render something into a document + * that is being torn down. Leaving it pending holds the Suspense fallback until + * the navigation happens, so the last frame is the spinner rather than a flash + * of an error the user cannot act on. + */ +export function reloadForStaleChunk(): Promise { + writeMarker(Date.now()); + window.location.reload(); + return new Promise(() => {}); +} + +/** + * Wraps a `lazy()` loader so a deploy mid-session recovers itself. + * + * Anything that is not a missing chunk is rethrown untouched — a page that + * throws while evaluating is a real bug, and reloading past it would hide the + * bug behind an infinite refresh. + */ +export function withStaleChunkRecovery(load: () => Promise): () => Promise { + return () => + load().catch((error: unknown) => { + if (canRecoverByReloading(error)) return reloadForStaleChunk(); + throw error; + }); +}