page loading
This commit is contained in:
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