page loading
This commit is contained in:
@@ -37,14 +37,40 @@ server {
|
|||||||
index index.html;
|
index index.html;
|
||||||
# React Router owns the paths, so an unknown one is a route, not a 404.
|
# React Router owns the paths, so an unknown one is a route, not a 404.
|
||||||
try_files $uri $uri/ /index.html;
|
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,
|
# Hashed filenames, so these can be cached hard — the hash changes when the
|
||||||
# or a deploy leaves people on the previous bundle until they force-reload.
|
# content does, which is what makes a year safe.
|
||||||
location /assets/ {
|
location /assets/ {
|
||||||
root /usr/share/nginx/html;
|
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 ───────────────────────────────────────────────────────────────
|
# ── Fiesta ───────────────────────────────────────────────────────────────
|
||||||
|
|||||||
12
src/App.tsx
12
src/App.tsx
@@ -3,6 +3,7 @@ import { Navigate, Route, Routes } from 'react-router-dom';
|
|||||||
import { Spinner } from '@astryxdesign/core/Spinner';
|
import { Spinner } from '@astryxdesign/core/Spinner';
|
||||||
import { RequireRole, useAuth } from '@/auth/AuthContext';
|
import { RequireRole, useAuth } from '@/auth/AuthContext';
|
||||||
import { HOME_ROUTE } from '@/auth/roles';
|
import { HOME_ROUTE } from '@/auth/roles';
|
||||||
|
import { withStaleChunkRecovery } from '@/lib/staleChunk';
|
||||||
import { LoginPage } from '@/features/auth/LoginPage';
|
import { LoginPage } from '@/features/auth/LoginPage';
|
||||||
import { NearleAdminShell } from '@/features/nearle-admin/NearleAdminShell';
|
import { NearleAdminShell } from '@/features/nearle-admin/NearleAdminShell';
|
||||||
import { StoreAdminShell } from '@/features/store-admin/StoreAdminShell';
|
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.
|
* means every visitor downloads the spreadsheet importer to look at a dashboard.
|
||||||
* That is the one thing from it worth deliberately not copying.
|
* 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 = <T extends string>(key: T, loader: () => Promise<Record<T, ComponentType>>) =>
|
const named = <T extends string>(key: T, loader: () => Promise<Record<T, ComponentType>>) =>
|
||||||
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 StoresPage = named('StoresPage', () => import('@/features/nearle-admin/pages/StoresPage'));
|
||||||
const StoreDetailPage = named('StoreDetailPage', () => import('@/features/nearle-admin/pages/StoreDetailPage'));
|
const StoreDetailPage = named('StoreDetailPage', () => import('@/features/nearle-admin/pages/StoreDetailPage'));
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Component, type ErrorInfo, type ReactNode } from 'react';
|
import { Component, type ErrorInfo, type ReactNode } from 'react';
|
||||||
|
import { isStaleChunkError } from '@/lib/staleChunk';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The last line before a white page.
|
* The last line before a white page.
|
||||||
@@ -45,12 +46,28 @@ export class ErrorBoundary extends Component<Props, State> {
|
|||||||
this.setState({ error: null });
|
this.setState({ error: null });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private reload = () => {
|
||||||
|
window.location.reload();
|
||||||
|
};
|
||||||
|
|
||||||
override render() {
|
override render() {
|
||||||
const { error } = this.state;
|
const { error } = this.state;
|
||||||
if (!error) return this.props.children;
|
if (!error) return this.props.children;
|
||||||
|
|
||||||
const area = this.props.area ?? 'this screen';
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
role="alert"
|
role="alert"
|
||||||
@@ -65,11 +82,12 @@ export class ErrorBoundary extends Component<Props, State> {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<h2 style={{ margin: '0 0 8px', fontSize: 18, fontWeight: 600, color: '#8A1F1F' }}>
|
<h2 style={{ margin: '0 0 8px', fontSize: 18, fontWeight: 600, color: '#8A1F1F' }}>
|
||||||
Something on {area} failed to render
|
{isStale ? 'This page was updated while you were working' : `Something on ${area} failed to render`}
|
||||||
</h2>
|
</h2>
|
||||||
<p style={{ margin: '0 0 16px', fontSize: 14, lineHeight: 1.6, color: '#5C4747' }}>
|
<p style={{ margin: '0 0 16px', fontSize: 14, lineHeight: 1.6, color: '#5C4747' }}>
|
||||||
The rest of the console is fine — this is one screen, not the whole app. The message
|
{isStale
|
||||||
below is what broke, and the full stack is in the browser console.
|
? '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.'}
|
||||||
</p>
|
</p>
|
||||||
<pre
|
<pre
|
||||||
style={{
|
style={{
|
||||||
@@ -88,7 +106,7 @@ export class ErrorBoundary extends Component<Props, State> {
|
|||||||
</pre>
|
</pre>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={this.reset}
|
onClick={isStale ? this.reload : this.reset}
|
||||||
style={{
|
style={{
|
||||||
padding: '9px 18px',
|
padding: '9px 18px',
|
||||||
borderRadius: 999,
|
borderRadius: 999,
|
||||||
@@ -99,7 +117,7 @@ export class ErrorBoundary extends Component<Props, State> {
|
|||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Try again
|
{isStale ? 'Reload the page' : 'Try again'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
76
src/lib/staleChunk.test.ts
Normal file
76
src/lib/staleChunk.test.ts
Normal file
@@ -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<string, string>();
|
||||||
|
(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);
|
||||||
|
});
|
||||||
111
src/lib/staleChunk.ts
Normal file
111
src/lib/staleChunk.ts
Normal file
@@ -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: <url>
|
||||||
|
* Firefox error loading dynamically imported module: <url>
|
||||||
|
* 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<never> {
|
||||||
|
writeMarker(Date.now());
|
||||||
|
window.location.reload();
|
||||||
|
return new Promise<never>(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<T>(load: () => Promise<T>): () => Promise<T> {
|
||||||
|
return () =>
|
||||||
|
load().catch((error: unknown) => {
|
||||||
|
if (canRecoverByReloading(error)) return reloadForStaleChunk();
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user