mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
bea3af4b45
After an update, a tab or a device whose service worker still serves the previous build asks for chunks the update removed. The server answered those requests with index.html and a 200, so the import failed with a MIME error, and everything caching in between was free to keep the HTML as the chunk. A precache install that met a missing file stored it, and Workbox reuses hashed entries by URL, so the device showed "A new version is available" on every load, whatever the reload or the cleared browser cache, and carried the broken entry into every later release that keeps the file's name. The SPA fallback now answers a GET for a build file that is not on disk with a plain 404 (no-store) instead of index.html. A precache install refuses it, so the worker whose cache is complete stays in charge until the new build is. index.html is sent relative to the public dir, which also stops an install living under a dot directory from answering every deep link with a 404. On the client, the reload after a dead chunk (the automatic one and the button on the update screen) no longer goes straight back to the worker that served the broken shell. When that same worker is still in charge and the server answers, its precache and registration are dropped first, so the reload reaches the build the server runs now. Offline, and in offline mode, the shell is kept. Every recovery reload, and the reload of the version handover, asks for the page under a one-off query parameter, so a proxy or CDN that keeps index.html against its headers cannot hand back the stale shell either. A chunk the entry imports statically fails before any of that code runs. shell-guard.js, a small classic script loaded ahead of the entry, watches for exactly that failure and runs the same recovery, once per session. A stylesheet, font or icon that holds a page raises no error at all, so once the app has started and the browser is idle, it looks through the precache for entries that hold a page under a build file's name, fetches each from the server past every cache and puts it back in place. A repaired stylesheet is loaded again, and so are the app's stylesheets when a font was repaired, since the browser does not try a face that failed again in the same document. Only the headers are read, a precache found sound is not looked at again until its entries change, nothing is fetched while nothing is broken, and nothing happens offline, in offline mode or without storage. An answer that is not the file (an auth wall, its login page, a server error) leaves the entry for the next launch. A page the recovery loaded from the network already runs the version the server reports, so the version handover now records it instead of reloading it a second time once the new worker takes over. A page loaded past an older worker that is still in charge, as a hard reload does, still asks for the new worker and records the version only once that has taken over, so the next tab or launch cannot get the old shell without a handover.
119 lines
4.5 KiB
TypeScript
119 lines
4.5 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.
|
|
}
|
|
}
|