mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 22:16:26 +00:00
fix(pwa): recover from a dead chunk and a broken precache after an update (#2524)
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. Dropping the worker ends a Web Push subscription with it, so on such a device the settings card shows push as off until it is switched on again. A missing sw-push.js now fails the worker install with a 404 rather than index.html; the comments that described the old answer say so.
This commit is contained in:
@@ -1,4 +1,11 @@
|
||||
import { isChunkLoadError, reloadOnceForChunk, clearChunkReloadMarker } from './chunkReload';
|
||||
import { isChunkLoadError, reloadOnceForChunk, clearChunkReloadMarker, reloadFresh, FRESH_LOAD_PARAM } from './chunkReload';
|
||||
|
||||
const net = vi.hoisted(() => ({
|
||||
probeNow: vi.fn(async () => 'online' as 'online' | 'offline' | 'proxy-wall'),
|
||||
isEffectivelyOffline: vi.fn(() => false),
|
||||
}));
|
||||
vi.mock('../sync/connectivity', () => ({ probeNow: net.probeNow }));
|
||||
vi.mock('../sync/networkMode', () => ({ isEffectivelyOffline: net.isEffectivelyOffline }));
|
||||
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
@@ -27,12 +34,54 @@ describe('isChunkLoadError', () => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* A recovery reload is a location.replace onto a fresh URL (reloadFresh), so the
|
||||
* stub records the replace. `plain` is the location.reload() it must not use.
|
||||
*/
|
||||
function stubLocation(href = 'http://localhost/trips/4?tab=plan#day-2') {
|
||||
const reload = vi.fn();
|
||||
const plain = vi.fn();
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { ...window.location, href, replace: reload, reload: plain },
|
||||
writable: true,
|
||||
});
|
||||
return { reload, plain };
|
||||
}
|
||||
|
||||
describe('reloadFresh', () => {
|
||||
it('FE-UTIL-CHUNK-015: loads the same page under a URL no cache has seen (#2524)', () => {
|
||||
const { reload, plain } = stubLocation();
|
||||
vi.spyOn(Date, 'now').mockReturnValue(1790358416100);
|
||||
reloadFresh();
|
||||
expect(plain).not.toHaveBeenCalled();
|
||||
expect(reload).toHaveBeenCalledTimes(1);
|
||||
const target = new URL(reload.mock.calls[0][0] as string);
|
||||
// Path, query and hash stay, so the app comes back where it was.
|
||||
expect(target.pathname).toBe('/trips/4');
|
||||
expect(target.searchParams.get('tab')).toBe('plan');
|
||||
expect(target.hash).toBe('#day-2');
|
||||
expect(target.searchParams.get(FRESH_LOAD_PARAM)).toBe((1790358416100).toString(36));
|
||||
});
|
||||
|
||||
it('FE-UTIL-CHUNK-016: two reloads never share a URL', () => {
|
||||
const { reload } = stubLocation('http://localhost/dashboard');
|
||||
const now = vi.spyOn(Date, 'now').mockReturnValue(1790358416100);
|
||||
reloadFresh();
|
||||
now.mockReturnValue(1790358416101);
|
||||
reloadFresh();
|
||||
expect(reload.mock.calls[0][0]).not.toBe(reload.mock.calls[1][0]);
|
||||
});
|
||||
|
||||
it('FE-UTIL-CHUNK-017: falls back to a plain reload when the URL cannot be built', () => {
|
||||
const { reload, plain } = stubLocation('not a url');
|
||||
reloadFresh();
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
expect(plain).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reloadOnceForChunk', () => {
|
||||
function stubReload() {
|
||||
const reload = vi.fn();
|
||||
Object.defineProperty(window, 'location', { value: { ...window.location, reload }, writable: true });
|
||||
return reload;
|
||||
}
|
||||
const stubReload = () => stubLocation().reload;
|
||||
|
||||
it('FE-UTIL-CHUNK-004: reloads the first time and refuses afterwards', () => {
|
||||
const reload = stubReload();
|
||||
@@ -71,3 +120,122 @@ describe('reloadOnceForChunk', () => {
|
||||
expect(() => clearChunkReloadMarker()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('reloadOntoCurrentBuild', () => {
|
||||
class FakeWorker {}
|
||||
|
||||
// The worker that served the page is read when serviceWorkerShell loads, so the
|
||||
// fake container goes in first and the modules are loaded afresh.
|
||||
async function setUp({ servedBy, controller }: { servedBy: FakeWorker | null; controller: FakeWorker | null }) {
|
||||
const registration = { unregister: vi.fn(async () => true) };
|
||||
const container = { controller: servedBy, getRegistration: vi.fn(async () => registration) };
|
||||
Object.defineProperty(navigator, 'serviceWorker', { value: container, configurable: true });
|
||||
const deleted: string[] = [];
|
||||
Object.defineProperty(globalThis, 'caches', {
|
||||
value: {
|
||||
keys: vi.fn(async () => ['workbox-precache-v2-http://localhost/', 'map-tiles']),
|
||||
delete: vi.fn(async (name: string) => { deleted.push(name); return true; }),
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
const { reload } = stubLocation();
|
||||
vi.resetModules();
|
||||
const mod = await import('./chunkReload');
|
||||
container.controller = controller;
|
||||
return { mod, reload, registration, deleted, container };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
net.probeNow.mockReset().mockResolvedValue('online');
|
||||
net.isEffectivelyOffline.mockReset().mockReturnValue(false);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Reflect.deleteProperty(navigator, 'serviceWorker');
|
||||
Reflect.deleteProperty(globalThis, 'caches');
|
||||
});
|
||||
|
||||
it('FE-UTIL-CHUNK-008: just reloads once a newer worker has taken over since the page loaded', async () => {
|
||||
const { mod, reload, registration, deleted } = await setUp({ servedBy: new FakeWorker(), controller: new FakeWorker() });
|
||||
mod.reloadOntoCurrentBuild();
|
||||
// That worker answers the reload with the new shell; nothing to throw away.
|
||||
expect(reload).toHaveBeenCalledTimes(1);
|
||||
expect(net.probeNow).not.toHaveBeenCalled();
|
||||
expect(registration.unregister).not.toHaveBeenCalled();
|
||||
expect(deleted).toEqual([]);
|
||||
});
|
||||
|
||||
it('FE-UTIL-CHUNK-009: just reloads when the page came from the network', async () => {
|
||||
const { mod, reload, registration } = await setUp({ servedBy: null, controller: null });
|
||||
mod.reloadOntoCurrentBuild();
|
||||
expect(reload).toHaveBeenCalledTimes(1);
|
||||
expect(registration.unregister).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-UTIL-CHUNK-010: drops the precache and the worker first when that worker would answer the reload again (#2524)', async () => {
|
||||
const worker = new FakeWorker();
|
||||
const { mod, reload, registration, deleted } = await setUp({ servedBy: worker, controller: worker });
|
||||
mod.reloadOntoCurrentBuild();
|
||||
// Not before the shell is gone: the reload would come back to the same error.
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
await vi.waitFor(() => expect(reload).toHaveBeenCalledTimes(1));
|
||||
expect(deleted).toEqual(['workbox-precache-v2-http://localhost/']);
|
||||
expect(registration.unregister).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each(['offline', 'proxy-wall'] as const)(
|
||||
'FE-UTIL-CHUNK-011: keeps the shell when the server does not answer (%s)',
|
||||
async (state) => {
|
||||
net.probeNow.mockResolvedValue(state);
|
||||
const worker = new FakeWorker();
|
||||
const { mod, reload, registration, deleted } = await setUp({ servedBy: worker, controller: worker });
|
||||
mod.reloadOntoCurrentBuild();
|
||||
await vi.waitFor(() => expect(reload).toHaveBeenCalledTimes(1));
|
||||
// Offline, the precache is the only way the app starts at all.
|
||||
expect(deleted).toEqual([]);
|
||||
expect(registration.unregister).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it('FE-UTIL-CHUNK-012: keeps the shell in offline mode without even asking the server', async () => {
|
||||
net.isEffectivelyOffline.mockReturnValue(true);
|
||||
const worker = new FakeWorker();
|
||||
const { mod, reload, registration } = await setUp({ servedBy: worker, controller: worker });
|
||||
mod.reloadOntoCurrentBuild();
|
||||
await vi.waitFor(() => expect(reload).toHaveBeenCalledTimes(1));
|
||||
expect(net.probeNow).not.toHaveBeenCalled();
|
||||
expect(registration.unregister).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-UTIL-CHUNK-013: still reloads when dropping the shell fails', async () => {
|
||||
const worker = new FakeWorker();
|
||||
const { mod, reload, registration } = await setUp({ servedBy: worker, controller: worker });
|
||||
registration.unregister.mockRejectedValue(new Error('InvalidStateError'));
|
||||
mod.reloadOntoCurrentBuild();
|
||||
await vi.waitFor(() => expect(reload).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it('FE-UTIL-CHUNK-018: drops nothing when a newer worker takes over while the server is asked', async () => {
|
||||
const worker = new FakeWorker();
|
||||
const { mod, reload, registration, deleted, container } = await setUp({ servedBy: worker, controller: worker });
|
||||
net.probeNow.mockImplementation(async () => {
|
||||
// The browser's own sw.js check finished the handover in the meantime. Its
|
||||
// precache is the new build's, and dropping it would cost a full download.
|
||||
container.controller = new FakeWorker();
|
||||
return 'online';
|
||||
});
|
||||
mod.reloadOntoCurrentBuild();
|
||||
await vi.waitFor(() => expect(reload).toHaveBeenCalledTimes(1));
|
||||
expect(deleted).toEqual([]);
|
||||
expect(registration.unregister).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-UTIL-CHUNK-014: the automatic reload after a dead chunk takes the same way, once', async () => {
|
||||
const worker = new FakeWorker();
|
||||
const { mod, reload, registration } = await setUp({ servedBy: worker, controller: worker });
|
||||
expect(mod.reloadOnceForChunk()).toBe(true);
|
||||
await vi.waitFor(() => expect(reload).toHaveBeenCalledTimes(1));
|
||||
expect(registration.unregister).toHaveBeenCalledTimes(1);
|
||||
expect(mod.reloadOnceForChunk()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user