Files
TREK/client/src/utils/chunkReload.ts
T
Maurice c3c21fb0b4 fix(client): five loads that catch their own error recover from a deploy too
The PDF export on desktop and phone, the language switch, the holiday
regions and the PDF's map image load a chunk on demand and catch the
failure themselves. After a deploy the old page asks for chunk names the
new build no longer has, so they showed their own error on every retry
(or, for the language, silently stayed in the old one) instead of
reloading onto the new build the way an uncaught chunk error does.
importChunk reloads once for a missing chunk and still hands the caller
its error.
2026-10-07 17:05:36 +02:00

135 lines
5.0 KiB
TypeScript

/**
* Recognising — and surviving — a lazy chunk that no longer exists.
*
* After a deploy the old index file still references hashed chunks that are gone
* from the server, so a dynamic import fails for anyone with the tab still open.
* React.lazy caches the rejected promise on the lazy object, which means retrying
* the same render fails instantly and forever: the only cure is a reload that
* fetches the new index.
*
* Kept out of the component module so main.tsx can use it without pulling React in.
*/
import { probeNow } from '../sync/connectivity';
import { isEffectivelyOffline } from '../sync/networkMode';
import { dropAppShell, reloadHitsSameWorker } from './serviceWorkerShell';
const RELOAD_MARKER = 'trek:chunk-reload';
/** Vite's own message, plus what Chrome/Firefox/Safari say for a failed module fetch. */
const CHUNK_ERROR_PATTERNS = [
'failed to fetch dynamically imported module',
'error loading dynamically imported module',
'importing a module script failed',
'unable to preload css',
'dynamically imported module',
];
export function isChunkLoadError(error: unknown): boolean {
if (!error) return false;
const message = error instanceof Error ? error.message : String(error);
const haystack = message.toLowerCase();
return CHUNK_ERROR_PATTERNS.some(p => haystack.includes(p));
}
/**
* The query parameter a recovery reload carries. public/shell-guard.js strips it
* again before the app reads the URL.
*/
export const FRESH_LOAD_PARAM = 'trek-reload';
/**
* location.reload(), except that the page is asked for under a URL no cache has
* seen yet. A reverse proxy or CDN that keeps index.html against its headers
* would answer a plain reload with the shell that just failed, and the page
* would come back to the same missing chunk every time (#2524).
*/
export function reloadFresh(): void {
try {
const url = new URL(window.location.href);
url.searchParams.set(FRESH_LOAD_PARAM, Date.now().toString(36));
window.location.replace(url.href);
} catch {
window.location.reload();
}
}
/**
* Reload so that the page comes back on the build the server runs now.
*
* Usually a fresh reload is that. The chunk is gone because a newer service
* worker took over after this page loaded (it drops the previous build's files
* from its precache as it does), or because there is no worker and the tab
* outlived a deploy. Either way the reload is answered with the current shell.
*
* It is not when the worker that served this page is still the one in charge.
* That worker would answer the reload with the very shell whose chunk it just
* failed to deliver, so its precache is what is broken, and the page would come
* back to the same error on every reload, cleared browser cache or not (#2524).
* Then the precache and the worker are dropped first, so the reload reaches the
* server. Only while the server answers and the app is not in offline mode:
* offline, that precache is the only way the app starts at all, and a failed
* chunk says nothing about it being broken. The worker is looked at again once
* the server has answered, since a new one may have taken over in the meantime,
* and then there is nothing to drop.
*/
export function reloadOntoCurrentBuild(): void {
if (!reloadHitsSameWorker()) {
reloadFresh();
return;
}
void (async () => {
try {
if (!isEffectivelyOffline() && (await probeNow()) === 'online' && reloadHitsSameWorker()) {
await dropAppShell();
}
} catch {
// Reload regardless: at worst it shows this screen again.
}
reloadFresh();
})();
}
/**
* Reload once per session for a given chunk failure, then give up and let the
* fallback show. Without the marker a chunk that is genuinely missing — a broken
* deploy, a proxy serving stale assets — would put the tab in a reload loop.
*/
export function reloadOnceForChunk(): boolean {
try {
if (sessionStorage.getItem(RELOAD_MARKER)) return false;
sessionStorage.setItem(RELOAD_MARKER, String(Date.now()));
} catch {
// Private mode or a blocked storage partition: reloading blind would risk a
// loop, so prefer showing the fallback with its manual reload button.
return false;
}
reloadOntoCurrentBuild();
return true;
}
/** Called once the app has rendered successfully, so the next deploy can heal too. */
export function clearChunkReloadMarker(): void {
try {
sessionStorage.removeItem(RELOAD_MARKER);
} catch {
// Nothing to clear if storage is unavailable.
}
}
/**
* A dynamic import whose failure the caller catches. After a deploy the old
* page asks for chunk names the new build no longer has; a caller that only
* shows its own error would show it on every retry, so a missing chunk reloads
* onto the current build once, the way an uncaught one already does. The
* error is rethrown for the caller either way.
*/
export async function importChunk<T>(load: () => Promise<T>): Promise<T> {
try {
return await load()
} catch (err) {
if (isChunkLoadError(err)) reloadOnceForChunk()
throw err
}
}