diff --git a/client/index.html b/client/index.html
index d31606d03..bc8f8325c 100644
--- a/client/index.html
+++ b/client/index.html
@@ -8,6 +8,9 @@
+
+
diff --git a/client/public/shell-guard.js b/client/public/shell-guard.js
new file mode 100644
index 000000000..7ecdf1624
--- /dev/null
+++ b/client/public/shell-guard.js
@@ -0,0 +1,156 @@
+/*
+ * Boot guard for the app shell (#2524).
+ *
+ * Loaded as an external classic script in
(NOT a module), like
+ * theme-boot.js, so it runs before the entry module and complies with the
+ * production CSP (script-src 'self').
+ *
+ * The entry module and every chunk it imports statically have to load before
+ * any of the app's own code runs. When one of them does not (a service worker
+ * precache that keeps index.html under a chunk's name, a proxy that hands out a
+ * stale file), the module script fails as a whole: nothing renders, and no error
+ * screen or reload button of the app is there to help. A reload changes nothing
+ * either, since the same precache answers it. This notices exactly that failure
+ * and, while the server answers, loads the page again from the server under a
+ * URL no cache has seen. When a service worker served the page, the app shell
+ * it keeps (the Workbox precache and the worker itself) is thrown away first,
+ * since that is what answered with the broken file. A Web Push subscription
+ * ends with the worker and has to be switched on again, the price of an app
+ * that starts again. Once per session, so a build that is broken on the server
+ * cannot put the page in a reload loop. Offline nothing happens: the precache
+ * is then the only way the app can start at all.
+ *
+ * It also strips the query parameter such a reload carries, before the app
+ * reads the URL. Keep the parameter and the cache filter in sync with
+ * src/utils/chunkReload.ts and src/utils/serviceWorkerShell.ts.
+ *
+ * It must never throw.
+ */
+(function () {
+ var PARAM = 'trek-reload';
+ var MARKER = 'trek:shell-reload';
+ var HEALTH_TIMEOUT_MS = 3000;
+
+ try {
+ var here = new URL(window.location.href);
+ if (here.searchParams.has(PARAM)) {
+ here.searchParams.delete(PARAM);
+ window.history.replaceState(window.history.state, '', here.pathname + here.search + here.hash);
+ }
+ } catch (e) {
+ /* the parameter stays in the address bar, which is harmless */
+ }
+
+ // The built entry lives under /assets/. The dev server's /src/main.tsx does
+ // not, so a syntax error while developing is left to Vite's own overlay.
+ function isEntry(el) {
+ return !!el && el.tagName === 'SCRIPT' && el.type === 'module' && /\/assets\//.test(el.src || '');
+ }
+
+ function alreadyTried() {
+ try {
+ return !!sessionStorage.getItem(MARKER);
+ } catch (e) {
+ // Without the marker there is no loop protection, so do nothing.
+ return true;
+ }
+ }
+
+ function markTried() {
+ try {
+ sessionStorage.setItem(MARKER, String(Date.now()));
+ return true;
+ } catch (e) {
+ return false;
+ }
+ }
+
+ // Same test as probeNow() in src/sync/connectivity.ts: TREK's own health JSON,
+ // not an edge proxy's login page and not a request that never came back.
+ function serverAnswers() {
+ var ctrl = typeof AbortController === 'function' ? new AbortController() : null;
+ var timer = ctrl ? setTimeout(function () { ctrl.abort(); }, HEALTH_TIMEOUT_MS) : null;
+ return fetch('/api/health', {
+ credentials: 'include',
+ cache: 'no-store',
+ redirect: 'manual',
+ signal: ctrl ? ctrl.signal : undefined,
+ }).then(function (res) {
+ var type = res.headers.get('content-type') || '';
+ return res.ok && type.indexOf('application/json') !== -1;
+ }, function () {
+ return false;
+ }).then(function (ok) {
+ if (timer) clearTimeout(timer);
+ return ok;
+ });
+ }
+
+ function servedByWorker() {
+ try {
+ return !!(navigator.serviceWorker && navigator.serviceWorker.controller);
+ } catch (e) {
+ return false;
+ }
+ }
+
+ function dropAppShell() {
+ var jobs = [];
+ if (typeof caches !== 'undefined') {
+ jobs.push(caches.keys().then(function (names) {
+ return Promise.all(names.filter(function (name) {
+ return name.indexOf('-precache-') !== -1;
+ }).map(function (name) {
+ return caches.delete(name);
+ }));
+ }));
+ }
+ if (navigator.serviceWorker) {
+ jobs.push(navigator.serviceWorker.getRegistration().then(function (reg) {
+ return reg ? reg.unregister() : false;
+ }));
+ }
+ return Promise.all(jobs);
+ }
+
+ function reloadFresh() {
+ var url = new URL(window.location.href);
+ url.searchParams.set(PARAM, Date.now().toString(36));
+ window.location.replace(url.href);
+ }
+
+ function recover() {
+ if (alreadyTried()) return;
+ // Read now: a page the worker did not serve failed on the network, and its
+ // precache has nothing to do with it.
+ var fromWorker = servedByWorker();
+ serverAnswers().then(function (ok) {
+ if (!ok || alreadyTried() || !markTried()) return;
+ var dropped = fromWorker ? dropAppShell() : Promise.resolve();
+ return dropped.then(null, function () {
+ /* reload regardless, the server has the current build */
+ }).then(reloadFresh);
+ }).then(null, function () {
+ /* never block boot */
+ });
+ }
+
+ try {
+ // Error and load events of a script do not bubble, so both are caught on
+ // their way down. On the document, since a load event never reaches window.
+ document.addEventListener('error', function (event) {
+ if (isEntry(event.target)) recover();
+ }, true);
+ // The shell started, so a later update in this session may recover too.
+ document.addEventListener('load', function (event) {
+ if (!isEntry(event.target)) return;
+ try {
+ sessionStorage.removeItem(MARKER);
+ } catch (e) {
+ /* nothing to clear */
+ }
+ }, true);
+ } catch (e) {
+ /* never block boot */
+ }
+})();
diff --git a/client/src/components/shared/ErrorBoundary.test.tsx b/client/src/components/shared/ErrorBoundary.test.tsx
index 62d18bf47..be5e7b7ed 100644
--- a/client/src/components/shared/ErrorBoundary.test.tsx
+++ b/client/src/components/shared/ErrorBoundary.test.tsx
@@ -1,6 +1,14 @@
import React from 'react';
import { render, screen, fireEvent } from '../../../tests/helpers/render';
import ErrorBoundary, { RootErrorFallback } from './ErrorBoundary';
+import * as chunkReload from '../../utils/chunkReload';
+
+// Only the Reload button's way out is replaced; the boundary's own automatic reload
+// (reloadOnceForChunk) keeps running the real code.
+vi.mock('../../utils/chunkReload', async (importOriginal) => ({
+ ...(await importOriginal()),
+ reloadOntoCurrentBuild: vi.fn(),
+}));
// React logs every caught error itself; without this each of these tests prints a
// full component stack and buries the real output.
@@ -146,9 +154,10 @@ describe('ErrorBoundary', () => {
});
it('FE-COMP-ERRBOUND-011: a chunk failure reloads once, then stops', () => {
+ // The recovery reload is a location.replace onto a fresh URL (chunkReload).
const reload = vi.fn();
Object.defineProperty(window, 'location', {
- value: { ...window.location, reload },
+ value: { ...window.location, replace: reload },
writable: true,
});
@@ -214,3 +223,40 @@ describe('RootErrorFallback', () => {
expect(screen.getByText('A new version is available')).toBeInTheDocument();
});
});
+
+describe('Reload button', () => {
+ let reload: ReturnType;
+ beforeEach(() => {
+ reload = vi.fn();
+ Object.defineProperty(window, 'location', { value: { ...window.location, reload }, writable: true });
+ vi.mocked(chunkReload.reloadOntoCurrentBuild).mockClear();
+ });
+
+ // A plain reload is answered by the service worker with the shell that just
+ // failed, which is how the update screen outlived every reload (#2524).
+ it('FE-COMP-ERRBOUND-017: after a dead chunk it reloads onto the current build', () => {
+ render( {}} isChunkError />);
+ fireEvent.click(screen.getByText('Reload page'));
+ expect(chunkReload.reloadOntoCurrentBuild).toHaveBeenCalledTimes(1);
+ expect(reload).not.toHaveBeenCalled();
+ });
+
+ it('FE-COMP-ERRBOUND-018: the route fallback for a dead chunk does the same', () => {
+ sessionStorage.setItem('trek:chunk-reload', '1');
+ render(
+
+
+ ,
+ );
+ fireEvent.click(screen.getByText('Reload page'));
+ expect(chunkReload.reloadOntoCurrentBuild).toHaveBeenCalledTimes(1);
+ expect(reload).not.toHaveBeenCalled();
+ });
+
+ it('FE-COMP-ERRBOUND-019: any other failure keeps the plain reload', () => {
+ render( {}} isChunkError={false} />);
+ fireEvent.click(screen.getByText('Reload page'));
+ expect(reload).toHaveBeenCalledTimes(1);
+ expect(chunkReload.reloadOntoCurrentBuild).not.toHaveBeenCalled();
+ });
+});
diff --git a/client/src/components/shared/ErrorBoundary.tsx b/client/src/components/shared/ErrorBoundary.tsx
index de75be4e0..78452e614 100644
--- a/client/src/components/shared/ErrorBoundary.tsx
+++ b/client/src/components/shared/ErrorBoundary.tsx
@@ -1,7 +1,7 @@
import React from 'react'
import { AlertTriangle, RefreshCw, RotateCcw } from 'lucide-react'
import { useTranslation } from '../../i18n'
-import { isChunkLoadError, reloadOnceForChunk } from '../../utils/chunkReload'
+import { isChunkLoadError, reloadOnceForChunk, reloadOntoCurrentBuild } from '../../utils/chunkReload'
/**
* The app had none of these, so a single throw during render unmounted the whole
@@ -95,6 +95,16 @@ export default class ErrorBoundary extends React.Component window.location.reload()}
+ onClick={() => reloadPage(isChunkError)}
className={[
'inline-flex items-center gap-1.5 rounded-lg border px-3 py-2 text-caption font-medium',
mobile ? 'border-[color:var(--m-rowbr)] text-m-ink' : 'border-edge text-content',
@@ -201,7 +211,7 @@ export function RootErrorFallback({ error, isChunkError }: FallbackState) {
{message && {message}}
window.location.reload()}
+ onClick={() => reloadPage(isChunkError)}
className="mt-1 inline-flex items-center gap-1.5 rounded-lg bg-accent px-3 py-2 text-caption font-medium text-white"
>
diff --git a/client/src/main.tsx b/client/src/main.tsx
index 7d06410e9..0b2a9f575 100644
--- a/client/src/main.tsx
+++ b/client/src/main.tsx
@@ -36,6 +36,7 @@ import { startConnectivityProbe } from './sync/connectivity'
import { requestPersistentStorage } from './sync/persistentStorage'
import ErrorBoundary, { RootErrorFallback } from './components/shared/ErrorBoundary'
import { installGlobalErrorHandlers } from './utils/globalErrorHandlers'
+import { schedulePrecacheRepair } from './utils/serviceWorkerShell'
maybeInstallTouchDragPolyfill()
startConnectivityProbe()
@@ -43,6 +44,9 @@ startConnectivityProbe()
requestPersistentStorage()
// Event handlers and async code never reach a boundary; this is where they land.
installGlobalErrorHandlers()
+// A page the precache keeps under a stylesheet's or a script's name would be
+// handed on to every later release (#2524).
+schedulePrecacheRepair()
ReactDOM.createRoot(document.getElementById('root')!).render(
diff --git a/client/src/utils/chunkReload.test.ts b/client/src/utils/chunkReload.test.ts
index 5465a0cd9..535a27322 100644
--- a/client/src/utils/chunkReload.test.ts
+++ b/client/src/utils/chunkReload.test.ts
@@ -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);
+ });
+});
diff --git a/client/src/utils/chunkReload.ts b/client/src/utils/chunkReload.ts
index 6eda06900..35e646fea 100644
--- a/client/src/utils/chunkReload.ts
+++ b/client/src/utils/chunkReload.ts
@@ -10,6 +10,10 @@
* 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. */
@@ -28,6 +32,64 @@ export function isChunkLoadError(error: unknown): boolean {
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
@@ -42,7 +104,7 @@ export function reloadOnceForChunk(): boolean {
// loop, so prefer showing the fallback with its manual reload button.
return false;
}
- window.location.reload();
+ reloadOntoCurrentBuild();
return true;
}
diff --git a/client/src/utils/globalErrorHandlers.test.ts b/client/src/utils/globalErrorHandlers.test.ts
index db192b6aa..aa6a02fdf 100644
--- a/client/src/utils/globalErrorHandlers.test.ts
+++ b/client/src/utils/globalErrorHandlers.test.ts
@@ -3,13 +3,14 @@ import { installGlobalErrorHandlers } from './globalErrorHandlers';
let teardown: () => void;
let consoleError: ReturnType;
+// A chunk failure reloads through chunkReload's fresh reload, a location.replace.
let reload: ReturnType;
beforeEach(() => {
sessionStorage.clear();
consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
reload = vi.fn();
- Object.defineProperty(window, 'location', { value: { ...window.location, reload }, writable: true });
+ Object.defineProperty(window, 'location', { value: { ...window.location, replace: reload }, writable: true });
teardown = installGlobalErrorHandlers();
});
diff --git a/client/src/utils/serviceWorkerShell.test.ts b/client/src/utils/serviceWorkerShell.test.ts
new file mode 100644
index 000000000..a125ff2f9
--- /dev/null
+++ b/client/src/utils/serviceWorkerShell.test.ts
@@ -0,0 +1,481 @@
+/**
+ * The worker that served the page is read once, when the module loads, so every
+ * test installs its fake service worker container first and imports afresh.
+ */
+
+class FakeWorker {
+ constructor(public scriptURL = '/sw.js') {}
+}
+
+interface FakeRegistration {
+ unregister: ReturnType
+}
+
+function installContainer(controller: FakeWorker | null, registration?: FakeRegistration) {
+ const container = {
+ controller,
+ getRegistration: vi.fn(async () => registration),
+ }
+ Object.defineProperty(navigator, 'serviceWorker', { value: container, configurable: true })
+ return container
+}
+
+function installCaches(names: string[]) {
+ const deleted: string[] = []
+ const store = {
+ keys: vi.fn(async () => names),
+ delete: vi.fn(async (name: string) => { deleted.push(name); return true }),
+ }
+ Object.defineProperty(globalThis, 'caches', { value: store, configurable: true })
+ return deleted
+}
+
+const net = vi.hoisted(() => ({ isEffectivelyOffline: vi.fn(() => false) }))
+vi.mock('../sync/networkMode', () => ({ isEffectivelyOffline: net.isEffectivelyOffline }))
+
+async function load() {
+ vi.resetModules()
+ return import('./serviceWorkerShell')
+}
+
+afterEach(() => {
+ Reflect.deleteProperty(navigator, 'serviceWorker')
+ Reflect.deleteProperty(globalThis, 'caches')
+ vi.unstubAllGlobals()
+ net.isEffectivelyOffline.mockReturnValue(false)
+})
+
+const ORIGIN = 'http://localhost:3000'
+const PAGE = ''
+
+function file(body: string, type: string, status = 200): Response {
+ return new Response(body, { status, headers: { 'content-type': type } })
+}
+
+/** One Workbox precache, as Cache Storage hands it to the page. */
+class FakePrecache {
+ readonly entries: Map
+ constructor(files: Record) {
+ this.entries = new Map(Object.entries(files).map(([path, res]) => [ORIGIN + path, res]))
+ }
+ keys = vi.fn(async () => [...this.entries.keys()].map(url => new Request(url)))
+ match = vi.fn(async (key: Request) => this.entries.get(key.url)?.clone())
+ // Cache Storage lists a replaced entry last, as if it were new.
+ put = vi.fn(async (key: Request, res: Response) => { this.entries.delete(key.url); this.entries.set(key.url, res) })
+ delete = vi.fn(async (key: Request) => this.entries.delete(key.url))
+ typeOf(path: string): string | null | undefined {
+ return this.entries.get(ORIGIN + path)?.headers.get('content-type')
+ }
+ async text(path: string): Promise {
+ return this.entries.get(ORIGIN + path)?.clone().text()
+ }
+}
+
+function installPrecache(files: Record) {
+ const precache = new FakePrecache(files)
+ const others = new FakePrecache({ '/tiles/1/2/3.png': file('png', 'text/html') })
+ const byName: Record = {
+ [`workbox-precache-v2-${ORIGIN}/`]: precache,
+ // Not the precache: whatever a tile server put there is not ours to judge.
+ 'map-tiles': others,
+ }
+ Object.defineProperty(globalThis, 'caches', {
+ value: { keys: vi.fn(async () => Object.keys(byName)), open: vi.fn(async (name: string) => byName[name]) },
+ configurable: true,
+ })
+ return { precache, others }
+}
+
+/** The server behind the precache: what it answers for each path it is asked for. */
+function serve(files: Record) {
+ const fetch = vi.fn(async (url: string) => {
+ const answer = files[new URL(url).pathname]
+ if (answer instanceof Error) throw answer
+ return answer ? answer.clone() : file('{"error":"Not found"}', 'application/json', 404)
+ })
+ vi.stubGlobal('fetch', fetch)
+ return fetch
+}
+
+const CSS = 'body{font-family:Poppins}'
+
+describe('serviceWorkerShell', () => {
+ it('FE-UTIL-SWSHELL-001: notes the worker that served the page', async () => {
+ const worker = new FakeWorker()
+ installContainer(worker)
+ const shell = await load()
+ expect(shell.servingWorker()).toBe(worker)
+ expect(shell.currentController()).toBe(worker)
+ })
+
+ it('FE-UTIL-SWSHELL-002: a reload hits the same worker until another one takes over', async () => {
+ const container = installContainer(new FakeWorker())
+ const shell = await load()
+ expect(shell.reloadHitsSameWorker()).toBe(true)
+
+ container.controller = new FakeWorker()
+ expect(shell.reloadHitsSameWorker()).toBe(false)
+ })
+
+ it('FE-UTIL-SWSHELL-003: a page that came from the network has no worker to blame', async () => {
+ const container = installContainer(null)
+ const shell = await load()
+ expect(shell.servingWorker()).toBeNull()
+ expect(shell.reloadHitsSameWorker()).toBe(false)
+
+ // A worker that claims the page later did not serve it.
+ container.controller = new FakeWorker()
+ expect(shell.reloadHitsSameWorker()).toBe(false)
+ })
+
+ it('FE-UTIL-SWSHELL-004: without service worker support there is no controller', async () => {
+ const shell = await load()
+ expect(shell.currentController()).toBeNull()
+ expect(shell.reloadHitsSameWorker()).toBe(false)
+ })
+
+ it('FE-UTIL-SWSHELL-005: reading the controller where site data is blocked counts as none', async () => {
+ Object.defineProperty(navigator, 'serviceWorker', {
+ get() { throw new Error('SecurityError') },
+ configurable: true,
+ })
+ const shell = await load()
+ expect(shell.currentController()).toBeNull()
+ })
+
+ it('FE-UTIL-SWSHELL-006: dropping the shell deletes the precache and the worker, and nothing else', async () => {
+ const registration = { unregister: vi.fn(async () => true) }
+ installContainer(new FakeWorker(), registration)
+ const deleted = installCaches([
+ 'workbox-precache-v2-https://trek.example/',
+ 'map-tiles',
+ 'gl-map-styles',
+ 'gl-map-offline',
+ 'user-uploads',
+ ])
+ const shell = await load()
+
+ await shell.dropAppShell()
+
+ // Offline map regions and cached uploads were downloaded on purpose.
+ expect(deleted).toEqual(['workbox-precache-v2-https://trek.example/'])
+ expect(registration.unregister).toHaveBeenCalledTimes(1)
+ })
+
+ it('FE-UTIL-SWSHELL-007: dropping the shell copes with no registration and no Cache Storage', async () => {
+ installContainer(null, undefined)
+ const shell = await load()
+ await expect(shell.dropAppShell()).resolves.toBeUndefined()
+ })
+})
+
+describe('repairPrecache (#2524)', () => {
+ it('FE-UTIL-SWSHELL-010: puts a stylesheet kept as a page back, fetched past the precache', async () => {
+ const { precache, others } = installPrecache({
+ '/assets/index-N6AFa1mB.css': file(PAGE, 'text/html; charset=utf-8'),
+ '/assets/index-a1.js': file('export {}', 'text/javascript'),
+ '/index.html?__WB_REVISION__=131d93be': file(PAGE, 'text/html'),
+ '/?__WB_REVISION__=1': file(PAGE, 'text/html'),
+ })
+ const fetch = serve({ '/assets/index-N6AFa1mB.css': file(CSS, 'text/css; charset=UTF-8') })
+ const shell = await load()
+
+ expect(await shell.repairPrecache()).toEqual(['/assets/index-N6AFa1mB.css'])
+
+ expect(precache.typeOf('/assets/index-N6AFa1mB.css')).toBe('text/css; charset=UTF-8')
+ expect(await precache.text('/assets/index-N6AFa1mB.css')).toBe(CSS)
+ // Only the broken entry is fetched, under a URL the precache route does not know.
+ expect(fetch).toHaveBeenCalledTimes(1)
+ const [url, init] = fetch.mock.calls[0] as unknown as [string, RequestInit]
+ expect(new URL(url).pathname).toBe('/assets/index-N6AFa1mB.css')
+ expect(new URL(url).searchParams.get('trek-repair')).toBeTruthy()
+ expect(init).toMatchObject({ cache: 'no-store', credentials: 'same-origin', redirect: 'error' })
+ // Kept without the one-off URL it came from, since a module is known by it.
+ const kept = precache.put.mock.calls[0][1] as Response
+ expect(kept.url).toBe('')
+ // Pages are pages, and other caches are not the precache.
+ expect(precache.typeOf('/index.html?__WB_REVISION__=131d93be')).toBe('text/html')
+ expect(precache.typeOf('/?__WB_REVISION__=1')).toBe('text/html')
+ expect(others.match).not.toHaveBeenCalled()
+ })
+
+ it('FE-UTIL-SWSHELL-011: a revisioned file is fetched without its revision and kept under it', async () => {
+ const { precache } = installPrecache({
+ '/fonts/Poppins-Bold.ttf?__WB_REVISION__=92934d92': file(PAGE, 'text/html'),
+ '/icons/icon-192x192.png?__WB_REVISION__=5f1a': file(PAGE, 'text/html'),
+ '/assets/vendor-core-Dbh_uXoX.js': file(PAGE, 'text/html'),
+ })
+ const fetch = serve({
+ '/fonts/Poppins-Bold.ttf': file('ttf', 'font/ttf'),
+ '/icons/icon-192x192.png': file('png', 'image/png'),
+ '/assets/vendor-core-Dbh_uXoX.js': file('export {}', 'text/javascript; charset=UTF-8'),
+ })
+ const shell = await load()
+
+ expect((await shell.repairPrecache()).sort()).toEqual([
+ '/assets/vendor-core-Dbh_uXoX.js', '/fonts/Poppins-Bold.ttf', '/icons/icon-192x192.png',
+ ])
+
+ expect(precache.typeOf('/fonts/Poppins-Bold.ttf?__WB_REVISION__=92934d92')).toBe('font/ttf')
+ expect(precache.typeOf('/icons/icon-192x192.png?__WB_REVISION__=5f1a')).toBe('image/png')
+ expect(precache.typeOf('/assets/vendor-core-Dbh_uXoX.js')).toBe('text/javascript; charset=UTF-8')
+ const fontUrl = new URL((fetch.mock.calls as unknown as [string][]).map(([u]) => u).find(u => u.includes('Poppins'))!)
+ expect(fontUrl.searchParams.has('__WB_REVISION__')).toBe(false)
+ })
+
+ it('FE-UTIL-SWSHELL-012: an entry the server has no file for is removed, so the network and the next install get it', async () => {
+ const { precache } = installPrecache({ '/assets/DashboardPage-v4ODOTOr.js': file(PAGE, 'text/html') })
+ serve({})
+ const shell = await load()
+
+ expect(await shell.repairPrecache()).toEqual([])
+
+ expect(precache.entries.size).toBe(0)
+ expect(localStorage.getItem('trek:precache-checked')).toBeTruthy()
+ })
+
+ it.each([
+ ['no network', new TypeError('Failed to fetch')],
+ ['a redirect to an auth wall', new TypeError('Failed to fetch: redirect')],
+ ['the login page of a proxy', file(PAGE, 'text/html; charset=utf-8')],
+ ['an auth wall that refuses', file('Unauthorized', 'text/plain', 401)],
+ ['a server error', file('{"error":"Internal"}', 'application/json', 500)],
+ ])('FE-UTIL-SWSHELL-013: with %s the entry stays, and the next launch tries again', async (_label, answer) => {
+ const { precache } = installPrecache({ '/assets/index-N6AFa1mB.css': file(PAGE, 'text/html') })
+ const fetch = serve({ '/assets/index-N6AFa1mB.css': answer })
+ const shell = await load()
+
+ expect(await shell.repairPrecache()).toEqual([])
+ expect(precache.typeOf('/assets/index-N6AFa1mB.css')).toBe('text/html')
+ expect(precache.delete).not.toHaveBeenCalled()
+ expect(localStorage.getItem('trek:precache-checked')).toBeNull()
+
+ fetch.mockImplementation(async () => file(CSS, 'text/css'))
+ expect(await shell.repairPrecache()).toEqual(['/assets/index-N6AFa1mB.css'])
+ })
+
+ it('FE-UTIL-SWSHELL-014: a sound precache is looked through once, until its entries change', async () => {
+ const { precache } = installPrecache({
+ '/assets/index-N6AFa1mB.css': file(CSS, 'text/css'),
+ '/assets/index-a1.js': file('export {}', 'text/javascript'),
+ })
+ const fetch = serve({})
+ const shell = await load()
+
+ await shell.repairPrecache()
+ expect(precache.match).toHaveBeenCalledTimes(2)
+
+ await shell.repairPrecache()
+ expect(precache.match).toHaveBeenCalledTimes(2)
+ expect(fetch).not.toHaveBeenCalled()
+
+ // A new install brought entries, and with them maybe a broken one.
+ precache.entries.set(`${ORIGIN}/assets/index-b2.js`, file(PAGE, 'text/html'))
+ fetch.mockImplementation(async () => file('export {}', 'text/javascript'))
+ expect(await shell.repairPrecache()).toEqual(['/assets/index-b2.js'])
+ expect(precache.match).toHaveBeenCalledTimes(5)
+
+ // The repaired entry moved to the end of the list, which changes nothing.
+ await shell.repairPrecache()
+ expect(precache.match).toHaveBeenCalledTimes(5)
+ })
+
+ it('FE-UTIL-SWSHELL-015: without Cache Storage there is nothing to put right', async () => {
+ const shell = await load()
+ await expect(shell.repairPrecache()).resolves.toEqual([])
+ })
+
+ it('FE-UTIL-SWSHELL-016: with storage blocked it does nothing, rather than look through the precache on every launch', async () => {
+ const { precache } = installPrecache({ '/assets/index-N6AFa1mB.css': file(PAGE, 'text/html') })
+ const fetch = serve({ '/assets/index-N6AFa1mB.css': file(CSS, 'text/css') })
+ vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { throw new DOMException('blocked', 'SecurityError') })
+ const shell = await load()
+
+ expect(await shell.repairPrecache()).toEqual([])
+
+ expect(precache.keys).not.toHaveBeenCalled()
+ expect(fetch).not.toHaveBeenCalled()
+ vi.restoreAllMocks()
+ })
+
+ it('FE-UTIL-SWSHELL-017: a failed fetch leaves the rest for the next launch, and keeps what it put right', async () => {
+ const { precache } = installPrecache({
+ '/assets/index-N6AFa1mB.css': file(PAGE, 'text/html'),
+ '/assets/maplibre-B2k4QVOw.css': file(PAGE, 'text/html'),
+ '/assets/Inter-latin.woff2': file(PAGE, 'text/html'),
+ })
+ const fetch = serve({
+ '/assets/index-N6AFa1mB.css': file(CSS, 'text/css'),
+ '/assets/maplibre-B2k4QVOw.css': new TypeError('Failed to fetch'),
+ '/assets/Inter-latin.woff2': file('woff2', 'font/woff2'),
+ })
+ const shell = await load()
+
+ expect(await shell.repairPrecache()).toEqual(['/assets/index-N6AFa1mB.css'])
+
+ expect(fetch).toHaveBeenCalledTimes(2)
+ expect(precache.typeOf('/assets/Inter-latin.woff2')).toBe('text/html')
+ expect(localStorage.getItem('trek:precache-checked')).toBeNull()
+ })
+
+ it('FE-UTIL-SWSHELL-018: a server that does not answer is given up on after a while', async () => {
+ vi.useFakeTimers()
+ try {
+ installPrecache({ '/assets/index-N6AFa1mB.css': file(PAGE, 'text/html') })
+ vi.stubGlobal('fetch', vi.fn((_url: string, init: RequestInit) => new Promise((_resolve, reject) => {
+ init.signal!.addEventListener('abort', () => reject(new DOMException('aborted', 'AbortError')))
+ })))
+ const shell = await load()
+
+ const done = shell.repairPrecache()
+ await vi.advanceTimersByTimeAsync(15_000)
+
+ await expect(done).resolves.toEqual([])
+ expect(localStorage.getItem('trek:precache-checked')).toBeNull()
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+})
+
+describe('restyle and schedulePrecacheRepair (#2524)', () => {
+ function stylesheet(href: string): HTMLLinkElement {
+ const link = document.createElement('link')
+ link.rel = 'stylesheet'
+ link.crossOrigin = ''
+ link.href = href
+ document.head.appendChild(link)
+ return link
+ }
+
+ const sheets = () => [...document.head.querySelectorAll('link[rel="stylesheet"]')]
+
+ afterEach(() => {
+ document.head.querySelectorAll('link').forEach(link => link.remove())
+ })
+
+ it('FE-UTIL-SWSHELL-020: loads a stylesheet taken from a broken entry again, and drops the old link once it has', async () => {
+ const broken = stylesheet('/assets/index-N6AFa1mB.css')
+ const sound = stylesheet('/assets/leaflet-B1.css')
+ const shell = await load()
+
+ shell.restyle(['/assets/index-N6AFa1mB.css'])
+
+ const fresh = sheets().find(l => l !== broken && l !== sound)!
+ expect(new URL(fresh.href).pathname).toBe('/assets/index-N6AFa1mB.css')
+ expect(fresh.crossOrigin).toBe('')
+ expect(broken.isConnected).toBe(true)
+
+ fresh.dispatchEvent(new Event('load'))
+ expect(broken.isConnected).toBe(false)
+ // In the old link's place, so the cascade stays as the build ordered it.
+ expect(sheets()).toEqual([fresh, sound])
+ })
+
+ it('FE-UTIL-SWSHELL-021: a stylesheet that fails to load again leaves the page as it was', async () => {
+ const broken = stylesheet('/assets/index-N6AFa1mB.css')
+ const shell = await load()
+
+ shell.restyle(['/assets/index-N6AFa1mB.css'])
+ sheets().find(l => l !== broken)!.dispatchEvent(new Event('error'))
+
+ expect(sheets()).toEqual([broken])
+ })
+
+ it('FE-UTIL-SWSHELL-027: a font put right loads the app\'s own stylesheets again, where its face is declared', async () => {
+ const entry = stylesheet('/assets/index-N6AFa1mB.css')
+ const lazy = stylesheet('/assets/maplibre-B2k4QVOw.css')
+ const foreign = stylesheet('https://tiles.example.org/assets/style.css')
+ const other = stylesheet('/custom/theme.css')
+ const shell = await load()
+
+ shell.restyle(['/assets/poppins-latin-400-normal-cpxAROuN.woff2'])
+
+ const fresh = sheets().filter(l => ![entry, lazy, foreign, other].includes(l))
+ expect(fresh.map(l => new URL(l.href).pathname)).toEqual(['/assets/index-N6AFa1mB.css', '/assets/maplibre-B2k4QVOw.css'])
+ fresh.forEach(l => l.dispatchEvent(new Event('load')))
+ expect(sheets()).toEqual([...fresh, foreign, other])
+ })
+
+ it('FE-UTIL-SWSHELL-028: an icon or a script put right leaves the stylesheets alone', async () => {
+ const entry = stylesheet('/assets/index-N6AFa1mB.css')
+ const shell = await load()
+
+ shell.restyle(['/icons/icon-192x192.png', '/assets/dashboardModel-C_5ygTOX.js'])
+
+ expect(sheets()).toEqual([entry])
+ })
+
+ it('FE-UTIL-SWSHELL-022: once the page has loaded and the browser is idle, the repair runs and restyles the page', async () => {
+ const idle = vi.fn((run: () => void) => { run(); return 1 })
+ vi.stubGlobal('requestIdleCallback', idle)
+ const { precache } = installPrecache({ '/assets/index-N6AFa1mB.css': file(PAGE, 'text/html') })
+ serve({ '/assets/index-N6AFa1mB.css': file(CSS, 'text/css') })
+ const broken = stylesheet('/assets/index-N6AFa1mB.css')
+ const shell = await load()
+
+ shell.schedulePrecacheRepair()
+
+ // jsdom's document has finished loading, so it only waits for idle time.
+ expect(idle).toHaveBeenCalledTimes(1)
+ await vi.waitFor(() => expect(sheets()).toHaveLength(2))
+ expect(precache.typeOf('/assets/index-N6AFa1mB.css')).toBe('text/css')
+ sheets()[1].dispatchEvent(new Event('load'))
+ expect(broken.isConnected).toBe(false)
+ })
+
+ it('FE-UTIL-SWSHELL-023: offline, or in offline mode, the precache is left as it is', async () => {
+ vi.stubGlobal('requestIdleCallback', (run: () => void) => { run(); return 1 })
+ net.isEffectivelyOffline.mockReturnValue(true)
+ const { precache } = installPrecache({ '/assets/index-N6AFa1mB.css': file(PAGE, 'text/html') })
+ const fetch = serve({})
+ const shell = await load()
+
+ shell.schedulePrecacheRepair()
+ await new Promise(r => setTimeout(r, 0))
+
+ expect(precache.keys).not.toHaveBeenCalled()
+ expect(fetch).not.toHaveBeenCalled()
+ })
+
+ it('FE-UTIL-SWSHELL-024: a page still loading waits for its load event first', async () => {
+ const idle = vi.fn()
+ vi.stubGlobal('requestIdleCallback', idle)
+ const readyState = vi.spyOn(document, 'readyState', 'get').mockReturnValue('interactive')
+ const shell = await load()
+
+ shell.schedulePrecacheRepair()
+ expect(idle).not.toHaveBeenCalled()
+
+ readyState.mockRestore()
+ window.dispatchEvent(new Event('load'))
+ expect(idle).toHaveBeenCalledTimes(1)
+ })
+
+ it('FE-UTIL-SWSHELL-025: without idle callbacks it waits a moment instead, and a refusing Cache Storage is left for the next launch', async () => {
+ vi.useFakeTimers()
+ try {
+ vi.stubGlobal('requestIdleCallback', undefined)
+ const keys = vi.fn(async () => { throw new DOMException('blocked', 'SecurityError') })
+ Object.defineProperty(globalThis, 'caches', { value: { keys }, configurable: true })
+ const shell = await load()
+
+ shell.schedulePrecacheRepair()
+ expect(keys).not.toHaveBeenCalled()
+ await vi.advanceTimersByTimeAsync(2_000)
+
+ expect(keys).toHaveBeenCalledTimes(1)
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+
+ it('FE-UTIL-SWSHELL-026: nothing put right leaves the stylesheets alone', async () => {
+ const link = stylesheet('/assets/index-N6AFa1mB.css')
+ const shell = await load()
+
+ shell.restyle([])
+
+ expect(sheets()).toEqual([link])
+ })
+})
diff --git a/client/src/utils/serviceWorkerShell.ts b/client/src/utils/serviceWorkerShell.ts
new file mode 100644
index 000000000..f68e1043b
--- /dev/null
+++ b/client/src/utils/serviceWorkerShell.ts
@@ -0,0 +1,244 @@
+/**
+ * The service worker that answers this page's navigations, and the way out when
+ * the app shell it keeps can no longer start.
+ *
+ * Kept free of React so main.tsx and the error handlers can use it early.
+ */
+
+import { isEffectivelyOffline } from '../sync/networkMode'
+
+/** Part of the name of every Workbox precache, and of no other cache the app keeps. */
+const PRECACHE = '-precache-'
+
+/** The worker controlling this page, or null. Reading it can throw where site data is blocked. */
+export function currentController(): ServiceWorker | null {
+ try {
+ return 'serviceWorker' in navigator ? navigator.serviceWorker.controller : null
+ } catch {
+ return null
+ }
+}
+
+// The worker that served this page, noted when the app loads. The browser checks
+// sw.js on every navigation by itself, so a new build can take over while this
+// page still runs the previous one. A controller other than this one means the
+// new shell is only a reload away. A page served without a worker came from the
+// network, so a worker claiming it later is no takeover.
+const servedBy = currentController()
+
+/** The worker that served this page when it loaded, or null when it came from the network. */
+export function servingWorker(): ServiceWorker | null {
+ return servedBy
+}
+
+/**
+ * True when a reload would be answered by the same worker that served this page,
+ * which means with the same app shell.
+ */
+export function reloadHitsSameWorker(): boolean {
+ const worker = currentController()
+ return worker !== null && worker === servedBy
+}
+
+/**
+ * Throws away the app shell this browser keeps: the Workbox precache and the
+ * worker that serves it. The next navigation then goes to the server, gets the
+ * index.html of the build it runs now, and registers that build's worker, which
+ * precaches from scratch instead of reusing an entry of the broken cache.
+ *
+ * Map tiles, GL styles and cached uploads live in caches of their own and stay.
+ * A Web Push subscription does not: it belongs to the registration and ends with
+ * it, so the settings card shows push as off on this device until it is switched
+ * on again. Only call this while the server answers: offline, the precache is
+ * the only way the app can start at all.
+ *
+ * public/shell-guard.js does the same for a shell whose entry cannot load, before
+ * any of this code runs. Keep the two in step.
+ */
+export async function dropAppShell(): Promise {
+ if (typeof caches !== 'undefined') {
+ const names = await caches.keys()
+ await Promise.all(names.filter(name => name.includes(PRECACHE)).map(name => caches.delete(name)))
+ }
+ const reg = 'serviceWorker' in navigator ? await navigator.serviceWorker.getRegistration() : undefined
+ if (reg) await reg.unregister()
+}
+
+// Workbox keys a file without a hash in its name as url?__WB_REVISION__=.
+const REVISION_PARAM = '__WB_REVISION__'
+// A build file asked for under this parameter gets past the precache, whose route
+// only knows the plain URL, and past any cache in between that keeps files by URL.
+const REPAIR_PARAM = 'trek-repair'
+const REPAIR_TIMEOUT_MS = 15_000
+// The entries the precache held when it was last found sound, so that an
+// unchanged precache is not looked through again on every launch.
+const CHECKED_KEY = 'trek:precache-checked'
+// What the precache keeps besides index.html (globPatterns in vite.config.js,
+// plus the web manifest). None of these may ever be a page.
+const BUILD_FILE = /\.(?:js|css|svg|png|woff2?|ttf|webmanifest)$/i
+
+interface Precache { name: string, cache: Cache, keys: readonly Request[] }
+
+function isPage(response: Response): boolean {
+ return (response.headers.get('content-type') ?? '').toLowerCase().includes('text/html')
+}
+
+/**
+ * Which entries the precaches hold, in a few characters. Order does not count:
+ * a repaired entry moves to the end of the list.
+ */
+function fingerprint(precaches: readonly Precache[]): string {
+ const urls = precaches.flatMap(({ name, keys }) => keys.map(key => `${name} ${key.url}`)).sort((a, b) => a.localeCompare(b))
+ let hash = 0x811c9dc5
+ for (const text of urls) {
+ for (let i = 0; i < text.length; i++) hash = Math.imul(hash ^ text.charCodeAt(i), 0x01000193)
+ }
+ return `${urls.length}.${(hash >>> 0).toString(36)}`
+}
+
+/** The last fingerprint found sound, '' for none, or null when storage is blocked. */
+function lastChecked(): string | null {
+ try {
+ return localStorage.getItem(CHECKED_KEY) ?? ''
+ } catch {
+ return null
+ }
+}
+
+function noteChecked(print: string): void {
+ try {
+ localStorage.setItem(CHECKED_KEY, print)
+ } catch {
+ // The next launch looks through the precache once more.
+ }
+}
+
+/**
+ * The build file behind a precache entry, fetched past every cache. 'gone' when
+ * the server says it has no such file. Throws when no usable answer came: no
+ * network, a timeout, a redirect to an auth wall, its login page, a server error.
+ */
+async function fetchBuildFile(key: Request): Promise {
+ const url = new URL(key.url)
+ url.searchParams.delete(REVISION_PARAM)
+ url.searchParams.set(REPAIR_PARAM, Date.now().toString(36))
+ const ctrl = new AbortController()
+ const timer = setTimeout(() => ctrl.abort(), REPAIR_TIMEOUT_MS)
+ try {
+ const res = await fetch(url.href, { cache: 'no-store', credentials: 'same-origin', redirect: 'error', signal: ctrl.signal })
+ if (res.status === 404 || res.status === 410) return 'gone'
+ if (res.status !== 200 || isPage(res)) throw new Error(`${res.status} for ${url.pathname}`)
+ // A response of its own, without the one-off URL it was fetched from: a
+ // module is known by its response URL. The body streams into the cache.
+ return new Response(res.body, { status: 200, statusText: res.statusText, headers: res.headers })
+ } finally {
+ clearTimeout(timer)
+ }
+}
+
+/**
+ * Puts right precache entries that hold a page instead of the build file their
+ * URL names, and returns the paths of the ones it put right (#2524).
+ *
+ * A precache install keeps whatever answered with a 200. A server that answered
+ * a missing build file with index.html, as 4.3.2 and older did, or a proxy that
+ * answered with its login page, left a page under the name of a script, a
+ * stylesheet or a font. Workbox reuses an entry for as long as a later release
+ * lists its URL, without fetching it again, and every hashed file under
+ * /assets/ keeps its URL until its content changes. So the broken entry is
+ * carried from release to release. A broken script at least fails its import,
+ * and the app recovers from that (chunkReload, shell-guard.js). A broken
+ * stylesheet, font or icon raises no error at all: the app just loses its
+ * layout or its typeface, on every launch, and offline too.
+ *
+ * Each such entry is fetched from the server again and put back in its place.
+ * One the server does not have is removed, so the file is asked for on the
+ * network and the next install stores it. When a fetch fails, the rest is left
+ * for the next launch. A precache found sound is not looked at again until its
+ * entries change, and without storage to note that in, nothing is done.
+ */
+export async function repairPrecache(): Promise {
+ const checked = lastChecked()
+ if (checked === null || typeof caches === 'undefined') return []
+ const names = (await caches.keys()).filter(name => name.includes(PRECACHE))
+ const precaches = await Promise.all(names.map(async (name): Promise => {
+ const cache = await caches.open(name)
+ return { name, cache, keys: await cache.keys() }
+ }))
+ const print = fingerprint(precaches)
+ if (checked === print) return []
+
+ const repaired: string[] = []
+ for (const { cache, keys } of precaches) {
+ // Only the headers are read here, never a body.
+ const broken = (await Promise.all(keys.map(async key => {
+ if (!BUILD_FILE.test(new URL(key.url).pathname)) return null
+ const response = await cache.match(key)
+ return response && isPage(response) ? key : null
+ }))).filter((key): key is Request => key !== null)
+
+ // One at a time: this is a way out, not a race, and a big chunk is not held
+ // in memory next to another.
+ for (const key of broken) {
+ let fresh: Response | 'gone'
+ try {
+ fresh = await fetchBuildFile(key)
+ } catch {
+ return repaired
+ }
+ if (fresh === 'gone') {
+ await cache.delete(key)
+ } else {
+ await cache.put(key, fresh)
+ repaired.push(new URL(key.url).pathname)
+ }
+ }
+ }
+ noteChecked(print)
+ return repaired
+}
+
+const FONT_FILE = /\.(?:woff2?|ttf)$/i
+
+/**
+ * Loads the stylesheets this page took from a broken precache entry again, now
+ * that the entry holds the stylesheet. The old link goes once the new one has
+ * loaded, so the page keeps what styles it has until then.
+ *
+ * A font face that failed is not tried again in the same document. When a font
+ * was put right, the app's own stylesheets, which declare every face it uses,
+ * are loaded again too, and the faces they declare anew load the repaired file.
+ */
+export function restyle(paths: readonly string[]): void {
+ if (paths.length === 0) return
+ const fontRepaired = paths.some(path => FONT_FILE.test(path))
+ for (const link of document.querySelectorAll('link[rel~="stylesheet"][href]')) {
+ const url = new URL(link.href, document.baseURI)
+ const declaresFonts = fontRepaired && url.origin === location.origin && url.pathname.startsWith('/assets/')
+ if (!declaresFonts && !paths.includes(url.pathname)) continue
+ const fresh = link.cloneNode() as HTMLLinkElement
+ fresh.addEventListener('load', () => link.remove(), { once: true })
+ fresh.addEventListener('error', () => fresh.remove(), { once: true })
+ link.after(fresh)
+ }
+}
+
+/**
+ * Runs {@link repairPrecache} once the page has loaded and the browser is idle,
+ * and only while the app is online: offline there is nothing to fetch the files
+ * from.
+ */
+export function schedulePrecacheRepair(): void {
+ const run = () => {
+ if (isEffectivelyOffline()) return
+ repairPrecache().then(restyle, () => {
+ // Cache Storage refused, so the next launch tries again.
+ })
+ }
+ const whenIdle = () => {
+ if (typeof requestIdleCallback === 'function') requestIdleCallback(run, { timeout: 10_000 })
+ else setTimeout(run, 2_000)
+ }
+ if (document.readyState === 'complete') whenIdle()
+ else window.addEventListener('load', whenIdle, { once: true })
+}
diff --git a/client/src/utils/versionHandover.test.ts b/client/src/utils/versionHandover.test.ts
index 318614cc5..a48c1639e 100644
--- a/client/src/utils/versionHandover.test.ts
+++ b/client/src/utils/versionHandover.test.ts
@@ -19,6 +19,7 @@ class FakeWorker extends EventTarget {
}
class FakeRegistration {
+ active: FakeWorker | null = null
installing: FakeWorker | null = null
waiting: FakeWorker | null = null
update = vi.fn(async () => this)
@@ -40,6 +41,7 @@ class FakeContainer extends EventTarget {
function installWorker(controller: FakeWorker | null = new FakeWorker('activated')) {
const registration = new FakeRegistration()
+ registration.active = controller
const container = new FakeContainer(controller)
container.getRegistration.mockResolvedValue(registration)
Object.defineProperty(navigator, 'serviceWorker', { configurable: true, value: container })
@@ -71,11 +73,20 @@ function breakStorage(method: 'getItem' | 'setItem', broken: () => Storage) {
}
const marker = () => localStorage.getItem('trek_app_version')
+// The version the bundle under test is built as (vite define, from package.json).
+const BUILT = __TREK_UI_VERSION__
let reload: ReturnType
+let plainReload: ReturnType
beforeEach(() => {
+ // Every reload here asks for the page under a fresh URL (reloadFresh), which is
+ // a location.replace. `plainReload` is the location.reload() it must not use.
reload = vi.fn()
- Object.defineProperty(window, 'location', { writable: true, value: { ...window.location, reload } })
+ plainReload = vi.fn()
+ Object.defineProperty(window, 'location', {
+ writable: true,
+ value: { ...window.location, href: 'http://localhost/dashboard', replace: reload, reload: plainReload },
+ })
})
afterEach(() => {
@@ -216,6 +227,80 @@ describe('reconcileAppVersion: a new version without a handover to wait for', ()
container.takeOver()
expect(reload).not.toHaveBeenCalled()
})
+
+ it('FE-UTIL-VERSION-015: a page from the network built as the new version records it without a reload (#2524)', async () => {
+ const { container } = installWorker(null)
+ container.getRegistration.mockResolvedValue(undefined)
+ const { reconcileAppVersion } = await loadModule()
+ localStorage.setItem('trek_app_version', '4.3.0')
+
+ await reconcileAppVersion(BUILT)
+
+ expect(marker()).toBe(BUILT)
+ expect(reload).not.toHaveBeenCalled()
+ expect(plainReload).not.toHaveBeenCalled()
+ })
+
+ it('FE-UTIL-VERSION-016: ...and the worker registered after it claims the page without a reload (#2524)', async () => {
+ // Where the way out of a broken shell lands: the precache and the worker were
+ // dropped, the page came from the server, and the new worker is installing.
+ const { container, registration } = installWorker(null)
+ updateInstalls(registration)
+ const { reconcileAppVersion } = await loadModule()
+ localStorage.setItem('trek_app_version', '4.3.0')
+
+ await reconcileAppVersion(BUILT)
+ container.takeOver()
+
+ expect(marker()).toBe(BUILT)
+ expect(registration.update).not.toHaveBeenCalled()
+ expect(reload).not.toHaveBeenCalled()
+ })
+
+ it('FE-UTIL-VERSION-019: ...and so does a page the worker registered after it has claimed already (#2524)', async () => {
+ const { container, registration } = installWorker(null)
+ const { reconcileAppVersion } = await loadModule()
+ localStorage.setItem('trek_app_version', '4.3.0')
+ // The fresh precache was quick, and its worker took the page before the server answered.
+ container.takeOver()
+ registration.active = container.controller
+
+ await reconcileAppVersion(BUILT)
+
+ expect(marker()).toBe(BUILT)
+ expect(registration.update).not.toHaveBeenCalled()
+ expect(reload).not.toHaveBeenCalled()
+ })
+
+ it('FE-UTIL-VERSION-017: a page a worker served goes through the handover whatever it was built as', async () => {
+ const { container, registration } = installWorker()
+ updateInstalls(registration)
+ const { reconcileAppVersion } = await loadModule()
+ localStorage.setItem('trek_app_version', '4.3.0')
+
+ await reconcileAppVersion(BUILT)
+ expect(marker()).toBe('4.3.0')
+
+ container.takeOver()
+ expect(reload).toHaveBeenCalledTimes(1)
+ })
+
+ it('FE-UTIL-VERSION-018: the reload asks for the page under a fresh URL, past a proxy that keeps index.html (#2524)', async () => {
+ const { container } = installWorker(null)
+ container.getRegistration.mockResolvedValue(undefined)
+ const { reconcileAppVersion } = await loadModule()
+ localStorage.setItem('trek_app_version', '4.3.0')
+
+ // A kept index.html runs an older bundle than the server reports.
+ await reconcileAppVersion('4.3.1')
+
+ expect(plainReload).not.toHaveBeenCalled()
+ expect(reload).toHaveBeenCalledTimes(1)
+ const target = new URL(reload.mock.calls[0][0] as string)
+ expect(target.pathname).toBe('/dashboard')
+ expect(target.searchParams.get('trek-reload')).toBeTruthy()
+ })
+
})
describe('reconcileAppVersion: handing over to the new build', () => {
@@ -289,6 +374,79 @@ describe('reconcileAppVersion: handing over to the new build', () => {
})
})
+describe('reconcileAppVersion: a page loaded past an older worker', () => {
+ // A hard reload, or a load with the worker bypassed, gets the new bundle from
+ // the network while the previous build's worker stays registered and would
+ // serve its own shell to the next tab or launch (#2524).
+ function pastOlderWorker() {
+ const installed = installWorker(null)
+ installed.registration.active = new FakeWorker('activated')
+ return installed
+ }
+
+ it('FE-UTIL-VERSION-025: asks for the new worker and records the version only once it has taken over', async () => {
+ const { container, registration } = pastOlderWorker()
+ const incoming = updateInstalls(registration)
+ const { reconcileAppVersion } = await loadModule()
+ localStorage.setItem('trek_app_version', '4.3.0')
+
+ await reconcileAppVersion(BUILT)
+
+ expect(registration.update).toHaveBeenCalledTimes(1)
+ expect(marker()).toBe('4.3.0')
+
+ incoming.become('activated')
+ container.takeOver()
+ expect(marker()).toBe(BUILT)
+ // The page runs that bundle already, so there is nothing to reload for.
+ expect(reload).not.toHaveBeenCalled()
+ expect(plainReload).not.toHaveBeenCalled()
+ })
+
+ it('FE-UTIL-VERSION-026: an install that goes redundant leaves the marker for the next launch', async () => {
+ const { container, registration } = pastOlderWorker()
+ const incoming = updateInstalls(registration)
+ const { reconcileAppVersion } = await loadModule()
+ localStorage.setItem('trek_app_version', '4.3.0')
+
+ await reconcileAppVersion(BUILT)
+ incoming.become('redundant')
+ container.takeOver()
+
+ expect(marker()).toBe('4.3.0')
+ expect(reload).not.toHaveBeenCalled()
+ })
+
+ it('FE-UTIL-VERSION-027: an unchanged sw.js means that worker is this build, so the version is recorded', async () => {
+ const { container, registration } = pastOlderWorker()
+ const { reconcileAppVersion } = await loadModule()
+ localStorage.setItem('trek_app_version', '4.3.0')
+ // It claimed the page while the update was being checked.
+ registration.update.mockImplementation(async () => {
+ container.controller = registration.active
+ return registration
+ })
+
+ await reconcileAppVersion(BUILT)
+
+ expect(registration.update).toHaveBeenCalledTimes(1)
+ expect(marker()).toBe(BUILT)
+ expect(reload).not.toHaveBeenCalled()
+ })
+
+ it('FE-UTIL-VERSION-028: an update that fails leaves the marker for the next launch', async () => {
+ const { registration } = pastOlderWorker()
+ registration.update.mockRejectedValue(new TypeError('Failed to update a ServiceWorker: network error'))
+ const { reconcileAppVersion } = await loadModule()
+ localStorage.setItem('trek_app_version', '4.3.0')
+
+ await reconcileAppVersion(BUILT)
+
+ expect(marker()).toBe('4.3.0')
+ expect(reload).not.toHaveBeenCalled()
+ })
+})
+
describe('reconcileAppVersion: the reload guard', () => {
it('FE-UTIL-VERSION-030: a marker that cannot be written does not turn into a reload loop', async () => {
const { reconcileAppVersion } = await loadModule()
@@ -309,9 +467,9 @@ describe('reconcileAppVersion: the reload guard', () => {
localStorage.setItem('trek_app_version', '4.3.0')
await reconcileAppVersion('4.3.1')
- await reconcileAppVersion('4.3.2')
+ await reconcileAppVersion('4.3.1-1')
- expect(marker()).toBe('4.3.2')
+ expect(marker()).toBe('4.3.1-1')
expect(reload).toHaveBeenCalledTimes(2)
})
diff --git a/client/src/utils/versionHandover.ts b/client/src/utils/versionHandover.ts
index 71e06ed12..94b2867ba 100644
--- a/client/src/utils/versionHandover.ts
+++ b/client/src/utils/versionHandover.ts
@@ -17,25 +17,20 @@
* version and session.
*/
+import { reloadFresh } from './chunkReload'
+import { currentController, servingWorker } from './serviceWorkerShell'
+
const VERSION_KEY = 'trek_app_version'
const RELOAD_KEY = 'trek_app_version_reload'
-/** The worker controlling this page, or null. Reading it can throw where site data is blocked. */
-function currentController(): ServiceWorker | null {
- try {
- return 'serviceWorker' in navigator ? navigator.serviceWorker.controller : null
- } catch {
- return null
- }
-}
+// The version this bundle was built as (vite.config.js). A bare tsc run has none,
+// and then no version counts as this bundle's own.
+const BUNDLE_VERSION: string = typeof __TREK_UI_VERSION__ === 'string' ? __TREK_UI_VERSION__ : ''
-// The worker that served this page, noted when the app loads. The browser checks
-// sw.js on every navigation by itself, and when that check installs the new build
-// before the server has told us its version, the takeover is over before anyone
-// listens for it. A controller other than this one means the new shell is only a
-// reload away. A page served without a worker came from the network, so a worker
-// claiming it later is no takeover.
-const servedBy = currentController()
+// The worker that served this page, noted when the app loads (serviceWorkerShell).
+// When the browser's own check of sw.js installs the new build before the server
+// has told us its version, the takeover is over before anyone listens for it.
+const servedBy = servingWorker()
function markApplied(version: string): void {
try { localStorage.setItem(VERSION_KEY, version) } catch { /* site data blocked */ }
@@ -54,10 +49,18 @@ function reloadOnce(version: string): void {
} catch {
return
}
- window.location.reload()
+ // Under a URL no cache has seen yet: without a worker, a proxy that keeps
+ // index.html against its headers would answer a plain reload with the very
+ // shell this page runs, and the marker has moved on already (#2524).
+ reloadFresh()
}
-async function handOver(reg: ServiceWorkerRegistration, version: string): Promise {
+/**
+ * Asks the worker for the new build and moves the marker once it has taken over.
+ * `runsIt` is a page that already runs the new bundle, so the takeover needs no
+ * reload to show it.
+ */
+async function handOver(reg: ServiceWorkerRegistration, version: string, runsIt: boolean): Promise {
// Listening before asking for the update, so a quick takeover is not missed.
// Giving up disarms the listener: the reload belongs to this launch, and one
// arriving later in the session would hit the user in the middle of an edit.
@@ -65,7 +68,7 @@ async function handOver(reg: ServiceWorkerRegistration, version: string): Promis
navigator.serviceWorker.addEventListener('controllerchange', () => {
if (!armed) return
markApplied(version)
- reloadOnce(version)
+ if (!runsIt) reloadOnce(version)
}, { once: true })
try {
@@ -80,7 +83,7 @@ async function handOver(reg: ServiceWorkerRegistration, version: string): Promis
// sw.js is unchanged, so the release changed nothing the precache holds and
// the bundle this page runs is the current one.
armed = false
- if (currentController() === servedBy) markApplied(version)
+ if (runsIt || currentController() === servedBy) markApplied(version)
return
}
// Precaching is all or nothing: one failed request out of several hundred and
@@ -129,14 +132,31 @@ export async function reconcileAppVersion(reported: unknown): Promise {
// Left for the next launch. A reload now would only reach the old worker again.
return
}
- // Without a worker the page came from the network and runs the new bundle
- // already, so the reload only confirms it. A controller other than the one
- // that served the page means the browser finished the handover by itself,
- // and the reload is what shows the new build.
+
+ // A page that came from the network and was built as this version runs it
+ // already. That is where the way out of a broken app shell lands, before the
+ // new app could move the marker (#2524): the old worker is gone, and the one
+ // registered since is this build. Reloading once it has taken over would only
+ // interrupt the user a few seconds in, with nothing new to show. An older
+ // worker still in charge (this page was loaded past it, as a hard reload
+ // does) would serve its own build to the next tab or launch, so it is handed
+ // over like any other, just without the reload.
+ const runsIt = !servedBy && version === BUNDLE_VERSION
+ if (runsIt && (!reg?.active || reg.active === currentController())) {
+ markApplied(version)
+ return
+ }
+
+ // Without a worker the page came from the network, yet was not built as this
+ // version: a proxy or CDN handed out an index.html it kept, or the server
+ // reports a version its bundle was not built as, and then the reload only
+ // confirms what runs. A controller other than the one that served the page
+ // means the browser finished the handover by itself, and the reload is what
+ // shows the new build.
if (!reg || (servedBy && currentController() !== servedBy)) {
markApplied(version)
reloadOnce(version)
return
}
- await handOver(reg, version)
+ await handOver(reg, version, runsIt)
}
diff --git a/client/tests/unit/pwa/swPush.test.ts b/client/tests/unit/pwa/swPush.test.ts
index abbd28e5c..8c4cde10b 100644
--- a/client/tests/unit/pwa/swPush.test.ts
+++ b/client/tests/unit/pwa/swPush.test.ts
@@ -13,8 +13,8 @@
// - a renewed subscription reaches the server, and the old one is forgotten
// only once the new one is stored.
// Plus the drift guard: vite.config.js must import the file, and it must exist,
-// or the SPA fallback answers importScripts with index.html and the new worker
-// never installs.
+// or importScripts gets a 404 instead of a script and the new worker never
+// installs.
import { describe, it, expect, vi, afterEach } from 'vitest';
import { existsSync, readFileSync } from 'node:fs';
import { resolve } from 'node:path';
diff --git a/client/tests/unit/utils/shellGuard.test.ts b/client/tests/unit/utils/shellGuard.test.ts
new file mode 100644
index 000000000..18dc3a4bb
--- /dev/null
+++ b/client/tests/unit/utils/shellGuard.test.ts
@@ -0,0 +1,236 @@
+/**
+ * public/shell-guard.js (#2524): the classic boot script that recovers a shell
+ * whose entry module cannot load. It is not a module, so it is run here from its
+ * source, the way the browser runs it from index.html.
+ */
+import { readFileSync } from 'node:fs';
+import { resolve } from 'node:path';
+
+const SOURCE = readFileSync(resolve(process.cwd(), 'public/shell-guard.js'), 'utf8');
+const ENTRY = '/assets/index-BZcJ9-lk.js';
+const MARKER = 'trek:shell-reload';
+
+type Listener = [string, EventListenerOrEventListenerObject, boolean | AddEventListenerOptions | undefined];
+let listeners: Listener[] = [];
+let scripts: HTMLElement[] = [];
+
+/** Runs the guard and keeps its listeners, so every test starts with exactly one set. */
+function runGuard(): void {
+ const add = vi.spyOn(document, 'addEventListener');
+ new Function(SOURCE)();
+ listeners = add.mock.calls.map(call => call as unknown as Listener);
+ add.mockRestore();
+}
+
+function moduleScript(src: string, type = 'module'): HTMLScriptElement {
+ const el = document.createElement('script');
+ el.type = type;
+ el.src = src;
+ document.head.appendChild(el);
+ scripts.push(el);
+ return el;
+}
+
+function fail(el: Element): void {
+ el.dispatchEvent(new Event('error'));
+}
+
+/** Lets the guard's promise chain run to its end. */
+async function settle(): Promise {
+ for (let i = 0; i < 10; i++) await new Promise(r => setTimeout(r, 0));
+}
+
+function healthAnswer(ok: boolean, type: string) {
+ return { ok, headers: { get: (name: string) => (name.toLowerCase() === 'content-type' ? type : null) } };
+}
+
+interface Env {
+ replace: ReturnType;
+ fetch: ReturnType;
+ unregister: ReturnType;
+ deleted: string[];
+}
+
+/** A page a service worker served, unless `controlled` is false. */
+function install(href = 'http://localhost/trips/4?tab=plan#day-2', controlled = true): Env {
+ const replace = vi.fn();
+ Object.defineProperty(window, 'location', { value: { ...window.location, href, replace }, writable: true, configurable: true });
+ const fetch = vi.fn(async () => healthAnswer(true, 'application/json; charset=utf-8'));
+ vi.stubGlobal('fetch', fetch);
+ const unregister = vi.fn(async () => true);
+ Object.defineProperty(navigator, 'serviceWorker', {
+ value: { controller: controlled ? { scriptURL: 'http://localhost/sw.js' } : null, getRegistration: vi.fn(async () => ({ unregister })) },
+ configurable: true,
+ });
+ const deleted: string[] = [];
+ Object.defineProperty(globalThis, 'caches', {
+ value: {
+ keys: vi.fn(async () => ['workbox-precache-v2-http://localhost/', 'map-tiles', 'gl-map-offline', 'user-uploads']),
+ delete: vi.fn(async (name: string) => { deleted.push(name); return true; }),
+ },
+ configurable: true,
+ });
+ return { replace, fetch, unregister, deleted };
+}
+
+const originalLocation = Object.getOwnPropertyDescriptor(window, 'location');
+
+beforeEach(() => {
+ sessionStorage.clear();
+});
+
+afterEach(() => {
+ for (const [type, fn, options] of listeners) document.removeEventListener(type, fn, options);
+ listeners = [];
+ for (const el of scripts) el.remove();
+ scripts = [];
+ vi.unstubAllGlobals();
+ vi.restoreAllMocks();
+ Reflect.deleteProperty(navigator, 'serviceWorker');
+ Reflect.deleteProperty(globalThis, 'caches');
+ if (originalLocation) Object.defineProperty(window, 'location', originalLocation);
+});
+
+describe('shell-guard.js', () => {
+ it('FE-SHELLGUARD-001: strips the fresh-reload parameter and keeps the rest of the URL', () => {
+ window.history.replaceState({ usr: 1 }, '', '/trips/4?tab=plan&trek-reload=mfz3x1&x=2#day-2');
+ runGuard();
+ expect(window.location.pathname + window.location.search + window.location.hash).toBe('/trips/4?tab=plan&x=2#day-2');
+ expect(window.history.state).toEqual({ usr: 1 });
+ window.history.replaceState(null, '', '/');
+ });
+
+ it('FE-SHELLGUARD-002: leaves a URL without the parameter alone', () => {
+ window.history.replaceState(null, '', '/dashboard?tab=trips');
+ const replaceState = vi.spyOn(window.history, 'replaceState');
+ runGuard();
+ expect(replaceState).not.toHaveBeenCalled();
+ window.history.replaceState(null, '', '/');
+ });
+
+ it('FE-SHELLGUARD-003: an entry that cannot load drops the app shell and loads the page afresh', async () => {
+ const env = install();
+ vi.spyOn(Date, 'now').mockReturnValue(1790358416100);
+ runGuard();
+ fail(moduleScript(ENTRY));
+ await vi.waitFor(() => expect(env.replace).toHaveBeenCalledTimes(1));
+
+ expect(env.fetch).toHaveBeenCalledWith('/api/health', expect.objectContaining({ cache: 'no-store', redirect: 'manual' }));
+ // Only the precache: offline map regions and cached uploads were downloaded on purpose.
+ expect(env.deleted).toEqual(['workbox-precache-v2-http://localhost/']);
+ expect(env.unregister).toHaveBeenCalledTimes(1);
+ const target = new URL(env.replace.mock.calls[0][0] as string);
+ expect(target.pathname + target.hash).toBe('/trips/4#day-2');
+ expect(target.searchParams.get('tab')).toBe('plan');
+ expect(target.searchParams.get('trek-reload')).toBe((1790358416100).toString(36));
+ });
+
+ it.each([
+ ['offline', () => Promise.reject(new TypeError('Failed to fetch'))],
+ ['a proxy login page', async () => healthAnswer(true, 'text/html')],
+ ['a server error', async () => healthAnswer(false, 'application/json')],
+ ])('FE-SHELLGUARD-004: keeps the shell when the server does not answer (%s)', async (_case, answer) => {
+ const env = install();
+ env.fetch.mockImplementation(answer);
+ runGuard();
+ fail(moduleScript(ENTRY));
+ await settle();
+ // Offline, the precache is the only way the app starts at all.
+ expect(env.fetch).toHaveBeenCalledTimes(1);
+ expect(env.deleted).toEqual([]);
+ expect(env.unregister).not.toHaveBeenCalled();
+ expect(env.replace).not.toHaveBeenCalled();
+ // Nothing was tried, so a later load may still recover.
+ expect(sessionStorage.getItem(MARKER)).toBeNull();
+ });
+
+ it('FE-SHELLGUARD-005: recovers once per session, so a broken build cannot loop', async () => {
+ const env = install();
+ runGuard();
+ fail(moduleScript(ENTRY));
+ await vi.waitFor(() => expect(env.replace).toHaveBeenCalledTimes(1));
+
+ // The reloaded page fails the same way: the marker is still set.
+ fail(moduleScript(ENTRY));
+ await settle();
+ expect(env.fetch).toHaveBeenCalledTimes(1);
+ expect(env.replace).toHaveBeenCalledTimes(1);
+ });
+
+ it('FE-SHELLGUARD-006: an entry that loaded clears the marker, so a later update may recover too', async () => {
+ const env = install();
+ sessionStorage.setItem(MARKER, '1790358416100');
+ runGuard();
+ moduleScript(ENTRY).dispatchEvent(new Event('load'));
+ expect(sessionStorage.getItem(MARKER)).toBeNull();
+
+ fail(moduleScript(ENTRY));
+ await vi.waitFor(() => expect(env.replace).toHaveBeenCalledTimes(1));
+ });
+
+ it('FE-SHELLGUARD-007: does nothing without session storage, where it could not stop a loop', async () => {
+ const env = install();
+ vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
+ throw new Error('storage blocked');
+ });
+ runGuard();
+ fail(moduleScript(ENTRY));
+ await settle();
+ expect(env.fetch).not.toHaveBeenCalled();
+ expect(env.replace).not.toHaveBeenCalled();
+ });
+
+ it('FE-SHELLGUARD-008: still reloads when dropping the shell fails', async () => {
+ const env = install();
+ env.unregister.mockRejectedValue(new Error('InvalidStateError'));
+ runGuard();
+ fail(moduleScript(ENTRY));
+ await vi.waitFor(() => expect(env.replace).toHaveBeenCalledTimes(1));
+ });
+
+ it('FE-SHELLGUARD-011: a page that came from the network is reloaded afresh and its worker kept', async () => {
+ // Nothing the worker keeps answered this load, so throwing it away would only
+ // cost the offline shell.
+ const env = install('http://localhost/dashboard', false);
+ runGuard();
+ fail(moduleScript(ENTRY));
+ await vi.waitFor(() => expect(env.replace).toHaveBeenCalledTimes(1));
+ expect(env.deleted).toEqual([]);
+ expect(env.unregister).not.toHaveBeenCalled();
+ expect(new URL(env.replace.mock.calls[0][0] as string).searchParams.has('trek-reload')).toBe(true);
+ });
+
+ it('FE-SHELLGUARD-009: works where there is no service worker and no Cache Storage', async () => {
+ const env = install();
+ Reflect.deleteProperty(navigator, 'serviceWorker');
+ Reflect.deleteProperty(globalThis, 'caches');
+ runGuard();
+ fail(moduleScript(ENTRY));
+ // A proxy that kept a stale index.html is bypassed by the fresh URL alone.
+ await vi.waitFor(() => expect(env.replace).toHaveBeenCalledTimes(1));
+ });
+
+ it('FE-SHELLGUARD-010: ignores every other failure', async () => {
+ const env = install();
+ runGuard();
+ // Vite's dev entry, a classic script, a lazy chunk's stylesheet, an image.
+ fail(moduleScript('/src/main.tsx'));
+ fail(moduleScript('/registerSW.js', 'text/javascript'));
+ const link = document.createElement('link');
+ link.rel = 'stylesheet';
+ link.href = '/assets/DashboardPage-v4ODOTOr.css';
+ document.head.appendChild(link);
+ scripts.push(link);
+ fail(link);
+ const img = document.createElement('img');
+ img.src = '/uploads/covers/missing.jpg';
+ document.body.appendChild(img);
+ scripts.push(img);
+ fail(img);
+ // A runtime error of the app is an ErrorEvent on window, not a failed script.
+ window.dispatchEvent(new Event('error'));
+ await settle();
+ expect(env.fetch).not.toHaveBeenCalled();
+ expect(env.replace).not.toHaveBeenCalled();
+ });
+});
diff --git a/client/vite.config.js b/client/vite.config.js
index a13053abf..a14484031 100644
--- a/client/vite.config.js
+++ b/client/vite.config.js
@@ -53,9 +53,10 @@ export default defineConfig(({ mode }) => ({
workbox: {
// The Web Push handlers (push, notificationclick, pushsubscriptionchange).
// Workbox writes importScripts('sw-push.js') at the top of the generated
- // worker, in dev too. The file lives in public/: were it missing, the SPA
- // fallback would answer with index.html and the new worker would fail to
- // install, which tests/unit/pwa/swPush.test.ts guards against.
+ // worker, in dev too. The file lives in public/: were it missing,
+ // importScripts would get no script (the server answers a missing build
+ // file with a 404) and the new worker would fail to install, which
+ // tests/unit/pwa/swPush.test.ts guards against.
importScripts: ['sw-push.js'],
// Anything above this is dropped from the precache manifest. The build does
// not fail over it, it only prints "won't be precached", so the ceiling has
diff --git a/server/src/nest/platform/platform.routes.ts b/server/src/nest/platform/platform.routes.ts
index 1d30bf779..de4bcd059 100644
--- a/server/src/nest/platform/platform.routes.ts
+++ b/server/src/nest/platform/platform.routes.ts
@@ -20,6 +20,43 @@ import { StorageInvalidKeyError, StorageNotFoundError, type StorageCategory } fr
export const PUBLIC_DIR = path.join(__dirname, '../../../public');
+// File types the client build emits. No page of the app ends in one of these.
+const BUILD_FILE_RE = /\.(?:m?js|css|map|wasm|webmanifest|json|png|jpe?g|gif|webp|avif|svg|ico|woff2?|ttf|otf)$/i;
+
+/**
+ * Whether a request asks for a file of the built client rather than a page of it.
+ *
+ * Such a request that the static handler could not answer is a file that does not
+ * exist, and it must end in a 404. It used to get index.html like a page (#2524).
+ * A tab still running the previous build asks for a chunk the update removed and
+ * gets HTML with a 200, which the module loader rejects. Worse, a 200 is a success
+ * to everything that caches: a service worker's precache stores the HTML under the
+ * chunk's name and keeps reusing it in every later version that keeps the name,
+ * and a reverse proxy that caches static files by extension hands it out as the
+ * chunk. A 404 is refused by the precache and never stands in for the file.
+ */
+export function isBuildFilePath(pathname: string): boolean {
+ return pathname.startsWith('/assets/') || BUILD_FILE_RE.test(pathname);
+}
+
+/**
+ * The production answer to a GET that nothing else matched: index.html for a page
+ * of the app, a plain 404 in the TREK envelope for a build file that is not there.
+ * Shared by both fallback forms (applyPlatformSpa and SpaFallbackFilter).
+ */
+export function answerUnmatchedGet(req: Request, res: Response, message = 'Not Found'): void {
+ if (isBuildFilePath(req.path)) {
+ res.setHeader('Cache-Control', 'no-store');
+ res.status(404).json({ error: message });
+ return;
+ }
+ res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
+ // Relative to root, so that send() checks only 'index.html' for dot segments. Given
+ // the absolute path it refused an install living under a dot directory (a
+ // ~/.local checkout, say) and every deep link came back as a 404.
+ res.sendFile('index.html', { root: PUBLIC_DIR });
+}
+
/**
* express.static replacement for the four public /uploads mounts (storage
* slice 3). Parity contract (spec §Serving): identical ETag / Last-Modified /
@@ -172,17 +209,14 @@ export function applyPlatformUploads(app: express.Application, storage: StorageS
* NOT use this (its router terminates unmatched requests with a 404 before any
* post-init route runs, and Express 5's path-to-regexp rejects a bare '*'); it serves
* the SPA via the SpaFallbackFilter instead. Both produce the identical result:
- * unmatched GET → index.html in production.
+ * unmatched GET → index.html in production, or a 404 for a missing build file.
*/
export function applyPlatformSpa(app: express.Application): void {
applyPlatformStatic(app);
// Case-sensitive on purpose (legacy parity).
if (readEnv().app.nodeEnv !== 'production') return;
// /.*/ rather than '*' so the helper is Express-4 and Express-5 safe.
- app.get(/.*/, (_req: Request, res: Response) => {
- res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
- res.sendFile(path.join(PUBLIC_DIR, 'index.html'));
- });
+ app.get(/.*/, (req: Request, res: Response) => answerUnmatchedGet(req, res));
}
/**
diff --git a/server/src/nest/platform/spa-fallback.filter.ts b/server/src/nest/platform/spa-fallback.filter.ts
index 0cde149ba..4f232e024 100644
--- a/server/src/nest/platform/spa-fallback.filter.ts
+++ b/server/src/nest/platform/spa-fallback.filter.ts
@@ -1,8 +1,7 @@
import { ArgumentsHost, Catch, ExceptionFilter, NotFoundException } from '@nestjs/common';
import type { Request, Response } from 'express';
-import path from 'node:path';
import { readEnv } from '../../app-config';
-import { PUBLIC_DIR } from './platform.routes';
+import { answerUnmatchedGet } from './platform.routes';
/**
* Serves the built SPA (index.html) for any request the NestJS router did not
@@ -17,6 +16,9 @@ import { PUBLIC_DIR } from './platform.routes';
* client) keeps the standard TREK `{ error }` 404 envelope. The `@Catch(NotFoundException)`
* is more specific than the global TrekExceptionFilter, so Nest routes 404s here
* while every other error still flows through TrekExceptionFilter.
+ *
+ * A GET for a build file that is not on disk (a chunk of the previous release,
+ * say) is not a page and gets the 404 envelope too, never index.html (#2524).
*/
@Catch(NotFoundException)
export class SpaFallbackFilter implements ExceptionFilter {
@@ -27,8 +29,7 @@ export class SpaFallbackFilter implements ExceptionFilter {
// Case-sensitive on purpose (legacy parity).
if (readEnv().app.nodeEnv === 'production' && req.method === 'GET') {
- res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
- res.sendFile(path.join(PUBLIC_DIR, 'index.html'));
+ answerUnmatchedGet(req, res, exception.message || 'Not Found');
return;
}
diff --git a/server/tests/e2e/spa-fallback.e2e.test.ts b/server/tests/e2e/spa-fallback.e2e.test.ts
new file mode 100644
index 000000000..698a972cc
--- /dev/null
+++ b/server/tests/e2e/spa-fallback.e2e.test.ts
@@ -0,0 +1,112 @@
+/**
+ * SPA fallback e2e (#2524). Boots a Nest app with the production static layer and
+ * the two global filters wired the way AppModule and bootstrap wire them, then asks
+ * over HTTP for a page, for a build file that exists, and for build files an
+ * update removed.
+ *
+ * The last case is the bug: a tab or service worker still on the previous release
+ * asks for its chunks, and those requests used to be answered with index.html and a
+ * 200, which a precache then kept as the chunk. They must be a plain 404.
+ */
+import { describe, it, expect, beforeAll, afterAll } from 'vitest';
+import fs from 'node:fs';
+import path from 'node:path';
+import request from 'supertest';
+import type { Server } from 'http';
+import { Module, type INestApplication } from '@nestjs/common';
+import { Test } from '@nestjs/testing';
+import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
+import { SpaFallbackFilter } from '../../src/nest/platform/spa-fallback.filter';
+import { applyPlatformStatic, PUBLIC_DIR } from '../../src/nest/platform/platform.routes';
+
+@Module({})
+class NoRoutesModule {}
+
+const PRESENT_CHUNK = 'assets/e2e-2524-present-Q1w2E3r4.js';
+const created: string[] = [];
+const createdDirs: string[] = [];
+
+/** Writes a file below the served public dir unless the build already put one there. */
+function ensurePublicFile(rel: string, content: string): void {
+ const file = path.join(PUBLIC_DIR, rel);
+ if (fs.existsSync(file)) return;
+ const dir = path.dirname(file);
+ if (!fs.existsSync(dir)) {
+ fs.mkdirSync(dir, { recursive: true });
+ createdDirs.push(dir);
+ }
+ fs.writeFileSync(file, content);
+ created.push(file);
+}
+
+describe('SPA fallback e2e: pages get index.html, missing build files a 404 (#2524)', () => {
+ const previousEnv = process.env.NODE_ENV;
+ let app: INestApplication;
+ let server: Server;
+
+ beforeAll(async () => {
+ ensurePublicFile('index.html', '
');
+ ensurePublicFile(PRESENT_CHUNK, 'export default 1;\n');
+ process.env.NODE_ENV = 'production';
+
+ const moduleRef = await Test.createTestingModule({ imports: [NoRoutesModule] }).compile();
+ app = moduleRef.createNestApplication();
+ // Before init, like bootstrap.ts: a real file is answered ahead of Nest's router.
+ applyPlatformStatic(app.getHttpAdapter().getInstance());
+ // Same pair and order as the APP_FILTER providers in AppModule.
+ app.useGlobalFilters(new TrekExceptionFilter(), new SpaFallbackFilter());
+ await app.init();
+ server = app.getHttpServer();
+ });
+
+ afterAll(async () => {
+ await app?.close();
+ for (const file of created) fs.rmSync(file, { force: true });
+ for (const dir of createdDirs.reverse()) fs.rmSync(dir, { recursive: true, force: true });
+ if (previousEnv === undefined) delete process.env.NODE_ENV;
+ else process.env.NODE_ENV = previousEnv;
+ });
+
+ it('answers a page of the app with index.html', async () => {
+ const res = await request(server).get('/dashboard');
+ expect(res.status).toBe(200);
+ expect(res.headers['content-type']).toContain('text/html');
+ expect(res.headers['cache-control']).toBe('no-cache, no-store, must-revalidate');
+ expect(res.text.toLowerCase()).toContain(' {
+ const res = await request(server).get(`/${PRESENT_CHUNK}`);
+ expect(res.status).toBe(200);
+ expect(res.headers['content-type']).toContain('javascript');
+ });
+
+ it('answers a chunk of the previous release with a 404, not index.html', async () => {
+ const res = await request(server).get('/assets/DashboardPage-v4ODOTOr.js');
+ expect(res.status).toBe(404);
+ expect(res.headers['content-type']).toContain('application/json');
+ expect(res.headers['cache-control']).toBe('no-store');
+ expect(res.body).toEqual({ error: 'Cannot GET /assets/DashboardPage-v4ODOTOr.js' });
+ });
+
+ // The whole answer is asserted, not just the status: a checkout under a dot
+ // directory (~/.local) made the old fallback fail with a 404 of its own, which
+ // a status check alone would count as the fix.
+ it.each([
+ ['a cache-busted retry of that chunk', '/assets/DashboardPage-v4ODOTOr.js?t=1790358416100'],
+ ['its stylesheet', '/assets/DashboardPage-v4ODOTOr.css'],
+ ['a top-level build file', '/manifest-e2e-2524.webmanifest'],
+ ])('does the same for %s', async (_case, url) => {
+ const res = await request(server).get(url);
+ expect(res.status).toBe(404);
+ expect(res.headers['content-type']).toContain('application/json');
+ expect(res.headers['cache-control']).toBe('no-store');
+ expect(res.body).toEqual({ error: `Cannot GET ${url}` });
+ });
+
+ it('keeps the JSON envelope for a non-GET miss', async () => {
+ const res = await request(server).post('/dashboard').send({});
+ expect(res.status).toBe(404);
+ expect(res.body).toEqual({ error: 'Cannot POST /dashboard' });
+ });
+});
diff --git a/server/tests/unit/nest/platform.controller.test.ts b/server/tests/unit/nest/platform.controller.test.ts
index b48c44b9a..1292efbd8 100644
--- a/server/tests/unit/nest/platform.controller.test.ts
+++ b/server/tests/unit/nest/platform.controller.test.ts
@@ -17,6 +17,8 @@ import {
applyPlatformSpa,
applyPlatformStatic,
storageStaticHandler,
+ isBuildFilePath,
+ PUBLIC_DIR,
} from '../../../src/nest/platform/platform.routes';
import { SpaFallbackFilter } from '../../../src/nest/platform/spa-fallback.filter';
import { StorageNotFoundError, StorageInvalidKeyError } from '../../../src/nest/storage/storage.types';
@@ -405,10 +407,56 @@ describe('applyPlatformSpa', () => {
const catchAll = calls.find((c) => c.method === 'get');
expect(catchAll).toBeDefined();
const res = makeRes();
- catchAll!.handlers[0]({}, res);
+ catchAll!.handlers[0]({ path: '/trips/7' }, res);
expect(res.headers['Cache-Control']).toBe('no-cache, no-store, must-revalidate');
expect(String(res.body)).toContain('FILE:');
expect(String(res.body)).toContain('index.html');
+ expect(res.sendFile).toHaveBeenCalledWith('index.html', { root: PUBLIC_DIR });
+ });
+
+ it('answers a missing build file with a 404, not index.html (#2524)', () => {
+ process.env.NODE_ENV = 'production';
+ const { app, calls } = fakeApp();
+ applyPlatformSpa(app);
+ const catchAll = calls.find((c) => c.method === 'get');
+ const res = makeRes();
+ catchAll!.handlers[0]({ path: '/assets/DashboardPage-v4ODOTOr.js' }, res);
+ expect(res.statusCode).toBe(404);
+ expect(res.body).toEqual({ error: 'Not Found' });
+ expect(res.headers['Cache-Control']).toBe('no-store');
+ expect(res.sendFile).not.toHaveBeenCalled();
+ });
+});
+
+describe('isBuildFilePath', () => {
+ it.each([
+ '/assets/DashboardPage-v4ODOTOr.js',
+ '/assets/index-cFGeBWen.css',
+ '/assets/poppins-latin-400-normal-abc.woff2',
+ '/assets/anything-without-a-known-extension',
+ '/sw.js',
+ '/workbox-2da51cb1.js',
+ '/registerSW.js',
+ '/manifest.webmanifest',
+ '/icons/icon-192x192.png',
+ '/favicon.ico',
+ '/fonts/Inter.TTF',
+ ])('treats %s as a build file', (p) => {
+ expect(isBuildFilePath(p)).toBe(true);
+ });
+
+ it.each([
+ '/',
+ '/dashboard',
+ '/trips/12/files',
+ '/journey/3/studio',
+ '/plugins/trip-todos',
+ '/help/Atlas',
+ '/shared/0f3a9c',
+ '/public/journey/AbC_-12',
+ '/oauth/consent',
+ ])('treats the page %s as a page', (p) => {
+ expect(isBuildFilePath(p)).toBe(false);
});
});
@@ -416,22 +464,55 @@ describe('SpaFallbackFilter', () => {
const original = process.env.NODE_ENV;
afterEach(() => { process.env.NODE_ENV = original; });
- function host(req: { method: string }, res: ReturnType) {
+ function host(req: { method: string; path?: string }, res: ReturnType) {
return { switchToHttp: () => ({ getRequest: () => req, getResponse: () => res }) } as never;
}
it('serves index.html for an unmatched GET in production', () => {
process.env.NODE_ENV = 'production';
const res = makeRes();
- new SpaFallbackFilter().catch(new NotFoundException('nope'), host({ method: 'GET' }, res));
+ new SpaFallbackFilter().catch(new NotFoundException('nope'), host({ method: 'GET', path: '/dashboard' }, res));
expect(res.headers['Cache-Control']).toBe('no-cache, no-store, must-revalidate');
expect(String(res.body)).toContain('index.html');
});
+ it('answers a GET for a chunk that is not on disk with a 404 in production (#2524)', () => {
+ process.env.NODE_ENV = 'production';
+ const res = makeRes();
+ new SpaFallbackFilter().catch(
+ new NotFoundException('Cannot GET /assets/DashboardPage-v4ODOTOr.js'),
+ host({ method: 'GET', path: '/assets/DashboardPage-v4ODOTOr.js' }, res),
+ );
+ // A 200 with index.html here is what a service worker precached as the chunk.
+ expect(res.statusCode).toBe(404);
+ expect(res.body).toEqual({ error: 'Cannot GET /assets/DashboardPage-v4ODOTOr.js' });
+ expect(res.headers['Cache-Control']).toBe('no-store');
+ expect(res.sendFile).not.toHaveBeenCalled();
+ });
+
+ it('answers a missing top-level build file with a 404 too', () => {
+ process.env.NODE_ENV = 'production';
+ const res = makeRes();
+ new SpaFallbackFilter().catch(new NotFoundException(), host({ method: 'GET', path: '/workbox-0000aaaa.js' }, res));
+ expect(res.statusCode).toBe(404);
+ expect(res.body).toEqual({ error: 'Not Found' });
+ });
+
+ it('answers a missing build file whose exception has no message with Not Found (#2524)', () => {
+ process.env.NODE_ENV = 'production';
+ const res = makeRes();
+ const exc = new NotFoundException();
+ Object.defineProperty(exc, 'message', { value: '' });
+ new SpaFallbackFilter().catch(exc, host({ method: 'GET', path: '/assets/gone-0000aaaa.js' }, res));
+ expect(res.statusCode).toBe(404);
+ expect(res.body).toEqual({ error: 'Not Found' });
+ expect(res.sendFile).not.toHaveBeenCalled();
+ });
+
it('keeps the JSON 404 envelope for a non-GET miss in production', () => {
process.env.NODE_ENV = 'production';
const res = makeRes();
- new SpaFallbackFilter().catch(new NotFoundException('gone'), host({ method: 'POST' }, res));
+ new SpaFallbackFilter().catch(new NotFoundException('gone'), host({ method: 'POST', path: '/dashboard' }, res));
expect(res.statusCode).toBe(404);
expect(res.body).toEqual({ error: 'gone' });
});
@@ -439,7 +520,7 @@ describe('SpaFallbackFilter', () => {
it('keeps the JSON 404 envelope outside production even for GET', () => {
process.env.NODE_ENV = 'development';
const res = makeRes();
- new SpaFallbackFilter().catch(new NotFoundException('missing'), host({ method: 'GET' }, res));
+ new SpaFallbackFilter().catch(new NotFoundException('missing'), host({ method: 'GET', path: '/dashboard' }, res));
expect(res.statusCode).toBe(404);
expect(res.body).toEqual({ error: 'missing' });
});
@@ -450,7 +531,7 @@ describe('SpaFallbackFilter', () => {
const exc = new NotFoundException();
// force an empty message so the || branch is taken
Object.defineProperty(exc, 'message', { value: '' });
- new SpaFallbackFilter().catch(exc, host({ method: 'GET' }, res));
+ new SpaFallbackFilter().catch(exc, host({ method: 'GET', path: '/dashboard' }, res));
expect(res.body).toEqual({ error: 'Not Found' });
});
});