From e75e99c044e143e58b87ba2e2d44cc11b65ad127 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Jasikowski?= Date: Tue, 2 Jun 2026 10:49:27 +0200 Subject: [PATCH 01/12] Clear stale SW caches before reloading on ChunkLoadError Both the manual Refresh path (usePageRefresh) and the automatic lazyRetry path were calling window.location.reload() without first clearing the Workbox service worker caches. In Safari PWA standalone mode the new SW (skipWaiting + clientsClaim) intercepts the reload and re-serves the same stale precached shell, reproducing the identical ChunkLoadError on every refresh attempt. Route both reloads through clearWorkboxRecoveryCaches() first, which unregisters the SW and deletes all Cache Storage so the reload fetches a fresh, internally-consistent shell from the CDN. Adds regression tests for both paths. --- src/hooks/usePageRefresh/index.ts | 3 +- src/utils/lazyRetry.ts | 4 +- tests/unit/chunkLoadErrorRecoveryTest.ts | 124 +++++++++++++++++++++++ 3 files changed, 128 insertions(+), 3 deletions(-) create mode 100644 tests/unit/chunkLoadErrorRecoveryTest.ts diff --git a/src/hooks/usePageRefresh/index.ts b/src/hooks/usePageRefresh/index.ts index e8f4f9bc6877..3079cec74989 100644 --- a/src/hooks/usePageRefresh/index.ts +++ b/src/hooks/usePageRefresh/index.ts @@ -1,5 +1,6 @@ import {differenceInMilliseconds} from 'date-fns/differenceInMilliseconds'; import {useErrorBoundary} from 'react-error-boundary'; +import clearWorkboxRecoveryCaches from '@libs/clearWorkboxRecoveryCaches'; import CONST from '@src/CONST'; import type UsePageRefresh from './type'; @@ -16,8 +17,8 @@ const usePageRefresh: UsePageRefresh = () => { return; } - window.location.reload(); sessionStorage.removeItem(CONST.SESSION_STORAGE_KEYS.LAST_REFRESH_TIMESTAMP); + clearWorkboxRecoveryCaches().then(() => window.location.reload()); }; }; diff --git a/src/utils/lazyRetry.ts b/src/utils/lazyRetry.ts index 35c6b444e612..f25fff3eff86 100644 --- a/src/utils/lazyRetry.ts +++ b/src/utils/lazyRetry.ts @@ -1,4 +1,5 @@ import type {ComponentType} from 'react'; +import clearWorkboxRecoveryCaches from '@libs/clearWorkboxRecoveryCaches'; import CONST from '@src/CONST'; type Import = Promise<{default: T}>; @@ -27,9 +28,8 @@ const lazyRetry = function >(componentImport: Compo .catch((component: ComponentImport) => { if (!hasRefreshed) { console.error('Failed to lazily import a React component, refreshing the page in order to retry the operation.', component); - // Set the retry status to 'true' and refresh the page sessionStorage.setItem(CONST.SESSION_STORAGE_KEYS.RETRY_LAZY_REFRESHED, 'true'); - window.location.reload(); // Refresh the page to retry the import + clearWorkboxRecoveryCaches().then(() => window.location.reload()); } else { console.error('Failed to lazily import a React component after the retry operation!', component); // If the import fails again reject with the error to trigger default error handling diff --git a/tests/unit/chunkLoadErrorRecoveryTest.ts b/tests/unit/chunkLoadErrorRecoveryTest.ts new file mode 100644 index 000000000000..2cd12721a107 --- /dev/null +++ b/tests/unit/chunkLoadErrorRecoveryTest.ts @@ -0,0 +1,124 @@ +/** + * Regression tests for the Safari PWA ChunkLoadError crash loop. + * + * Before the fix, both recovery paths used a bare window.location.reload(). + * In Safari PWA standalone mode this re-serves the stale service-worker + * precache, reproducing the identical ChunkLoadError on every refresh. + * The fix routes each reload through clearWorkboxRecoveryCaches() first, + * which unregisters the SW and clears Cache Storage so the next load fetches + * a fresh, internally-consistent shell from the CDN. + */ +import {renderHook} from '@testing-library/react-native'; +import type {ComponentType} from 'react'; +import usePageRefresh from '@hooks/usePageRefresh'; +import CONST from '@src/CONST'; +import lazyRetry from '@src/utils/lazyRetry'; + +type ComponentImport = () => Promise<{default: T}>; + +const mockClearWorkboxRecoveryCaches = jest.fn(); +jest.mock('@libs/clearWorkboxRecoveryCaches', () => ({ + __esModule: true, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + default: (...args: unknown[]): any => mockClearWorkboxRecoveryCaches(...args), +})); + +// jest-expo resolves @hooks/usePageRefresh to the .native.ts variant (which does not use +// clearWorkboxRecoveryCaches). Override the alias to load the web file so this test covers +// the web-specific reload path that was changed. The web file's own imports (including +// clearWorkboxRecoveryCaches) still go through the normal mock registry. +// eslint-disable-next-line @typescript-eslint/no-unsafe-return +jest.mock('@hooks/usePageRefresh', () => jest.requireActual('../../src/hooks/usePageRefresh/index.ts')); + +jest.mock('react-error-boundary', () => ({ + useErrorBoundary: () => ({resetBoundary: jest.fn()}), + ErrorBoundary: ({children}: {children: React.ReactNode}) => children, +})); + +/** + * Flush enough microtask turns to let clearWorkboxRecoveryCaches().then(reload) + * run to completion. Two yields are sufficient: one for the rejection/resolution + * handler, one for the .then() chain off the resolved clearWorkboxRecoveryCaches promise. + */ +function flushMicrotasks(turns = 3): Promise { + let chain = Promise.resolve(); + for (let i = 0; i < turns; i++) { + chain = chain.then(() => Promise.resolve()); + } + return chain; +} + +describe('ChunkLoadError recovery', () => { + let reloadMock: jest.Mock; + // Records the order in which clear and reload are called within each test. + const callOrder: string[] = []; + + beforeAll(() => { + reloadMock = jest.fn().mockImplementation(() => { + callOrder.push('reload'); + }); + Object.defineProperty(window, 'location', { + configurable: true, + value: {reload: reloadMock}, + }); + }); + + beforeEach(() => { + callOrder.length = 0; + mockClearWorkboxRecoveryCaches.mockImplementation(() => { + callOrder.push('clear'); + return Promise.resolve(); + }); + reloadMock.mockClear(); + mockClearWorkboxRecoveryCaches.mockClear(); + sessionStorage.clear(); + }); + + describe('usePageRefresh (web)', () => { + it('calls clearWorkboxRecoveryCaches before reload when isChunkLoadError is true', async () => { + const {result} = renderHook(() => usePageRefresh()); + + result.current(true); + await flushMicrotasks(); + + expect(mockClearWorkboxRecoveryCaches).toHaveBeenCalledTimes(1); + expect(reloadMock).toHaveBeenCalledTimes(1); + expect(callOrder).toEqual(['clear', 'reload']); + }); + + it('does not reload at all when isChunkLoadError is false and no prior refresh', async () => { + const {result} = renderHook(() => usePageRefresh()); + + result.current(false); + await flushMicrotasks(); + + expect(mockClearWorkboxRecoveryCaches).not.toHaveBeenCalled(); + expect(reloadMock).not.toHaveBeenCalled(); + }); + }); + + describe('lazyRetry', () => { + it('calls clearWorkboxRecoveryCaches before reload on the first chunk load failure', async () => { + sessionStorage.removeItem(CONST.SESSION_STORAGE_KEYS.RETRY_LAZY_REFRESHED); + const failingImport = jest.fn().mockRejectedValue(new Error('chunk failed')) as unknown as ComponentImport; + + lazyRetry(failingImport); + await flushMicrotasks(); + + expect(mockClearWorkboxRecoveryCaches).toHaveBeenCalledTimes(1); + expect(reloadMock).toHaveBeenCalledTimes(1); + expect(callOrder).toEqual(['clear', 'reload']); + }); + + it('does not reload on successful import', async () => { + sessionStorage.removeItem(CONST.SESSION_STORAGE_KEYS.RETRY_LAZY_REFRESHED); + const successfulImport = jest.fn().mockResolvedValue({default: () => null}) as unknown as ComponentImport; + + await lazyRetry(successfulImport); + await flushMicrotasks(); + + expect(mockClearWorkboxRecoveryCaches).not.toHaveBeenCalled(); + expect(reloadMock).not.toHaveBeenCalled(); + }); + }); +}); From 8a74faf181b78a72452b00a9fc158c4933f81e1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Jasikowski?= Date: Wed, 3 Jun 2026 10:20:40 +0200 Subject: [PATCH 02/12] Bypass HTTP cache on ChunkLoadError recovery reload index.html is served with Cache-Control: max-age=86400, so after the service worker and Cache Storage are cleared, a plain window.location.reload() can still be handed a stale shell from Safari's HTTP cache (or the CDN edge) that references dead chunk hashes, keeping the loop alive on Safari PWA. Add reloadWithCacheBust(), which navigates to a unique URL via location.replace so the document misses the HTTP/edge cache and is fetched fresh, and use it in both recovery paths after clearing caches. Falls back to a plain reload if the navigation throws. --- src/hooks/usePageRefresh/index.ts | 3 +- src/libs/reloadWithCacheBust/index.ts | 29 ++++++++++++ src/utils/lazyRetry.ts | 3 +- tests/unit/chunkLoadErrorRecoveryTest.ts | 45 +++++++++---------- tests/unit/reloadWithCacheBustTest.ts | 57 ++++++++++++++++++++++++ 5 files changed, 112 insertions(+), 25 deletions(-) create mode 100644 src/libs/reloadWithCacheBust/index.ts create mode 100644 tests/unit/reloadWithCacheBustTest.ts diff --git a/src/hooks/usePageRefresh/index.ts b/src/hooks/usePageRefresh/index.ts index 3079cec74989..5cd7a73113a8 100644 --- a/src/hooks/usePageRefresh/index.ts +++ b/src/hooks/usePageRefresh/index.ts @@ -1,6 +1,7 @@ import {differenceInMilliseconds} from 'date-fns/differenceInMilliseconds'; import {useErrorBoundary} from 'react-error-boundary'; import clearWorkboxRecoveryCaches from '@libs/clearWorkboxRecoveryCaches'; +import reloadWithCacheBust from '@libs/reloadWithCacheBust'; import CONST from '@src/CONST'; import type UsePageRefresh from './type'; @@ -18,7 +19,7 @@ const usePageRefresh: UsePageRefresh = () => { } sessionStorage.removeItem(CONST.SESSION_STORAGE_KEYS.LAST_REFRESH_TIMESTAMP); - clearWorkboxRecoveryCaches().then(() => window.location.reload()); + clearWorkboxRecoveryCaches().then(() => reloadWithCacheBust()); }; }; diff --git a/src/libs/reloadWithCacheBust/index.ts b/src/libs/reloadWithCacheBust/index.ts new file mode 100644 index 000000000000..0360b96aca96 --- /dev/null +++ b/src/libs/reloadWithCacheBust/index.ts @@ -0,0 +1,29 @@ +/** + * Reloads the web app while bypassing the HTTP cache for the navigation document. + * + * `index.html` is served with `Cache-Control: public, max-age=86400`, so Safari's HTTP + * disk cache (and the CDN edge) can hold a stale shell that still references chunk hashes + * which no longer exist. A plain `window.location.reload()` may be served that stale shell + * - especially in a Safari standalone PWA, where reload cache semantics are unreliable - + * keeping a ChunkLoadError loop alive even after the service worker and Cache Storage are + * cleared. Navigating to a unique URL forces a cache miss at both the browser HTTP cache and + * the edge, so the current shell (with valid chunk hashes) is fetched from the network. We + * use `replace` so the cache-bust entry does not pollute history. + */ +const CACHE_BUST_PARAM = 'forceReload'; + +function reloadWithCacheBust(): void { + if (typeof window === 'undefined' || !window.location) { + return; + } + + try { + const url = new URL(window.location.href); + url.searchParams.set(CACHE_BUST_PARAM, Date.now().toString()); + window.location.replace(url.toString()); + } catch { + window.location.reload(); + } +} + +export default reloadWithCacheBust; diff --git a/src/utils/lazyRetry.ts b/src/utils/lazyRetry.ts index f25fff3eff86..896c74dbdee1 100644 --- a/src/utils/lazyRetry.ts +++ b/src/utils/lazyRetry.ts @@ -1,5 +1,6 @@ import type {ComponentType} from 'react'; import clearWorkboxRecoveryCaches from '@libs/clearWorkboxRecoveryCaches'; +import reloadWithCacheBust from '@libs/reloadWithCacheBust'; import CONST from '@src/CONST'; type Import = Promise<{default: T}>; @@ -29,7 +30,7 @@ const lazyRetry = function >(componentImport: Compo if (!hasRefreshed) { console.error('Failed to lazily import a React component, refreshing the page in order to retry the operation.', component); sessionStorage.setItem(CONST.SESSION_STORAGE_KEYS.RETRY_LAZY_REFRESHED, 'true'); - clearWorkboxRecoveryCaches().then(() => window.location.reload()); + clearWorkboxRecoveryCaches().then(() => reloadWithCacheBust()); } else { console.error('Failed to lazily import a React component after the retry operation!', component); // If the import fails again reject with the error to trigger default error handling diff --git a/tests/unit/chunkLoadErrorRecoveryTest.ts b/tests/unit/chunkLoadErrorRecoveryTest.ts index 2cd12721a107..79fbc4eb163a 100644 --- a/tests/unit/chunkLoadErrorRecoveryTest.ts +++ b/tests/unit/chunkLoadErrorRecoveryTest.ts @@ -2,11 +2,12 @@ * Regression tests for the Safari PWA ChunkLoadError crash loop. * * Before the fix, both recovery paths used a bare window.location.reload(). - * In Safari PWA standalone mode this re-serves the stale service-worker - * precache, reproducing the identical ChunkLoadError on every refresh. - * The fix routes each reload through clearWorkboxRecoveryCaches() first, - * which unregisters the SW and clears Cache Storage so the next load fetches - * a fresh, internally-consistent shell from the CDN. + * In Safari PWA standalone mode this re-serves the stale service-worker precache + * (and an HTTP-cached index.html), reproducing the identical ChunkLoadError on every + * refresh. The fix routes each reload through clearWorkboxRecoveryCaches() (unregisters + * the SW and clears Cache Storage) and then reloadWithCacheBust() (navigates to a unique + * URL so the HTTP/edge cache is bypassed), so the next load fetches a fresh, + * internally-consistent shell from the CDN. */ import {renderHook} from '@testing-library/react-native'; import type {ComponentType} from 'react'; @@ -23,6 +24,12 @@ jest.mock('@libs/clearWorkboxRecoveryCaches', () => ({ default: (...args: unknown[]): any => mockClearWorkboxRecoveryCaches(...args), })); +const mockReloadWithCacheBust = jest.fn(); +jest.mock('@libs/reloadWithCacheBust', () => ({ + __esModule: true, + default: () => mockReloadWithCacheBust() as unknown, +})); + // jest-expo resolves @hooks/usePageRefresh to the .native.ts variant (which does not use // clearWorkboxRecoveryCaches). Override the alias to load the web file so this test covers // the web-specific reload path that was changed. The web file's own imports (including @@ -49,40 +56,32 @@ function flushMicrotasks(turns = 3): Promise { } describe('ChunkLoadError recovery', () => { - let reloadMock: jest.Mock; // Records the order in which clear and reload are called within each test. const callOrder: string[] = []; - beforeAll(() => { - reloadMock = jest.fn().mockImplementation(() => { - callOrder.push('reload'); - }); - Object.defineProperty(window, 'location', { - configurable: true, - value: {reload: reloadMock}, - }); - }); - beforeEach(() => { callOrder.length = 0; mockClearWorkboxRecoveryCaches.mockImplementation(() => { callOrder.push('clear'); return Promise.resolve(); }); - reloadMock.mockClear(); + mockReloadWithCacheBust.mockImplementation(() => { + callOrder.push('reload'); + }); + mockReloadWithCacheBust.mockClear(); mockClearWorkboxRecoveryCaches.mockClear(); sessionStorage.clear(); }); describe('usePageRefresh (web)', () => { - it('calls clearWorkboxRecoveryCaches before reload when isChunkLoadError is true', async () => { + it('clears caches before the cache-busting reload when isChunkLoadError is true', async () => { const {result} = renderHook(() => usePageRefresh()); result.current(true); await flushMicrotasks(); expect(mockClearWorkboxRecoveryCaches).toHaveBeenCalledTimes(1); - expect(reloadMock).toHaveBeenCalledTimes(1); + expect(mockReloadWithCacheBust).toHaveBeenCalledTimes(1); expect(callOrder).toEqual(['clear', 'reload']); }); @@ -93,12 +92,12 @@ describe('ChunkLoadError recovery', () => { await flushMicrotasks(); expect(mockClearWorkboxRecoveryCaches).not.toHaveBeenCalled(); - expect(reloadMock).not.toHaveBeenCalled(); + expect(mockReloadWithCacheBust).not.toHaveBeenCalled(); }); }); describe('lazyRetry', () => { - it('calls clearWorkboxRecoveryCaches before reload on the first chunk load failure', async () => { + it('clears caches before the cache-busting reload on the first chunk load failure', async () => { sessionStorage.removeItem(CONST.SESSION_STORAGE_KEYS.RETRY_LAZY_REFRESHED); const failingImport = jest.fn().mockRejectedValue(new Error('chunk failed')) as unknown as ComponentImport; @@ -106,7 +105,7 @@ describe('ChunkLoadError recovery', () => { await flushMicrotasks(); expect(mockClearWorkboxRecoveryCaches).toHaveBeenCalledTimes(1); - expect(reloadMock).toHaveBeenCalledTimes(1); + expect(mockReloadWithCacheBust).toHaveBeenCalledTimes(1); expect(callOrder).toEqual(['clear', 'reload']); }); @@ -118,7 +117,7 @@ describe('ChunkLoadError recovery', () => { await flushMicrotasks(); expect(mockClearWorkboxRecoveryCaches).not.toHaveBeenCalled(); - expect(reloadMock).not.toHaveBeenCalled(); + expect(mockReloadWithCacheBust).not.toHaveBeenCalled(); }); }); }); diff --git a/tests/unit/reloadWithCacheBustTest.ts b/tests/unit/reloadWithCacheBustTest.ts new file mode 100644 index 000000000000..dcae6cc80b39 --- /dev/null +++ b/tests/unit/reloadWithCacheBustTest.ts @@ -0,0 +1,57 @@ +/** + * Tests for reloadWithCacheBust. + * + * index.html is served with Cache-Control: max-age=86400, so a plain reload can be + * handed a stale shell from Safari's HTTP cache (or the CDN edge) that still references + * dead chunk hashes. reloadWithCacheBust navigates to a unique URL so the document is + * fetched fresh from the network, breaking the ChunkLoadError loop. + */ +import reloadWithCacheBust from '@libs/reloadWithCacheBust'; + +const ORIGINAL_HREF = 'https://new.expensify.com/r/123'; + +describe('reloadWithCacheBust', () => { + let replacedUrl: string | undefined; + let replaceMock: jest.Mock; + let reloadMock: jest.Mock; + + beforeEach(() => { + replacedUrl = undefined; + replaceMock = jest.fn((url: string) => { + replacedUrl = url; + }); + reloadMock = jest.fn(); + Object.defineProperty(window, 'location', { + configurable: true, + value: {href: ORIGINAL_HREF, replace: replaceMock, reload: reloadMock}, + }); + }); + + it('navigates to a unique cache-busting URL via replace (bypasses HTTP/edge cache)', () => { + reloadWithCacheBust(); + + expect(replaceMock).toHaveBeenCalledTimes(1); + const target = new URL(replacedUrl ?? ''); + // Preserves the current route while adding a unique param that forces a cache miss. + expect(target.pathname).toBe('/r/123'); + expect(target.searchParams.get('forceReload')).toBeTruthy(); + expect(reloadMock).not.toHaveBeenCalled(); + }); + + it('falls back to a plain reload when navigation throws', () => { + Object.defineProperty(window, 'location', { + configurable: true, + value: { + href: ORIGINAL_HREF, + replace: () => { + throw new Error('replace blocked'); + }, + reload: reloadMock, + }, + }); + + reloadWithCacheBust(); + + expect(reloadMock).toHaveBeenCalledTimes(1); + }); +}); From ee6f4d6b9169e81c8d9701d83853de48ce3a87b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Jasikowski?= Date: Wed, 3 Jun 2026 11:53:13 +0200 Subject: [PATCH 03/12] Drop reloadWithCacheBust, keep SW-clear recovery The service-worker precache is the confirmed source of the stale shell, and clearWorkboxRecoveryCaches() before a plain reload addresses it. A reload navigation already bypasses Safari's local HTTP cache (cache-mode "reload"), so the cache-busting URL added little beyond defeating a stale CDN edge entry (better handled by edge no-cache headers) while leaving a forceReload query param in the address bar. Revert it back to a plain window.location.reload(). --- src/hooks/usePageRefresh/index.ts | 3 +- src/libs/reloadWithCacheBust/index.ts | 29 ------------ src/utils/lazyRetry.ts | 3 +- tests/unit/chunkLoadErrorRecoveryTest.ts | 45 ++++++++++--------- tests/unit/reloadWithCacheBustTest.ts | 57 ------------------------ 5 files changed, 25 insertions(+), 112 deletions(-) delete mode 100644 src/libs/reloadWithCacheBust/index.ts delete mode 100644 tests/unit/reloadWithCacheBustTest.ts diff --git a/src/hooks/usePageRefresh/index.ts b/src/hooks/usePageRefresh/index.ts index 5cd7a73113a8..3079cec74989 100644 --- a/src/hooks/usePageRefresh/index.ts +++ b/src/hooks/usePageRefresh/index.ts @@ -1,7 +1,6 @@ import {differenceInMilliseconds} from 'date-fns/differenceInMilliseconds'; import {useErrorBoundary} from 'react-error-boundary'; import clearWorkboxRecoveryCaches from '@libs/clearWorkboxRecoveryCaches'; -import reloadWithCacheBust from '@libs/reloadWithCacheBust'; import CONST from '@src/CONST'; import type UsePageRefresh from './type'; @@ -19,7 +18,7 @@ const usePageRefresh: UsePageRefresh = () => { } sessionStorage.removeItem(CONST.SESSION_STORAGE_KEYS.LAST_REFRESH_TIMESTAMP); - clearWorkboxRecoveryCaches().then(() => reloadWithCacheBust()); + clearWorkboxRecoveryCaches().then(() => window.location.reload()); }; }; diff --git a/src/libs/reloadWithCacheBust/index.ts b/src/libs/reloadWithCacheBust/index.ts deleted file mode 100644 index 0360b96aca96..000000000000 --- a/src/libs/reloadWithCacheBust/index.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * Reloads the web app while bypassing the HTTP cache for the navigation document. - * - * `index.html` is served with `Cache-Control: public, max-age=86400`, so Safari's HTTP - * disk cache (and the CDN edge) can hold a stale shell that still references chunk hashes - * which no longer exist. A plain `window.location.reload()` may be served that stale shell - * - especially in a Safari standalone PWA, where reload cache semantics are unreliable - - * keeping a ChunkLoadError loop alive even after the service worker and Cache Storage are - * cleared. Navigating to a unique URL forces a cache miss at both the browser HTTP cache and - * the edge, so the current shell (with valid chunk hashes) is fetched from the network. We - * use `replace` so the cache-bust entry does not pollute history. - */ -const CACHE_BUST_PARAM = 'forceReload'; - -function reloadWithCacheBust(): void { - if (typeof window === 'undefined' || !window.location) { - return; - } - - try { - const url = new URL(window.location.href); - url.searchParams.set(CACHE_BUST_PARAM, Date.now().toString()); - window.location.replace(url.toString()); - } catch { - window.location.reload(); - } -} - -export default reloadWithCacheBust; diff --git a/src/utils/lazyRetry.ts b/src/utils/lazyRetry.ts index 896c74dbdee1..f25fff3eff86 100644 --- a/src/utils/lazyRetry.ts +++ b/src/utils/lazyRetry.ts @@ -1,6 +1,5 @@ import type {ComponentType} from 'react'; import clearWorkboxRecoveryCaches from '@libs/clearWorkboxRecoveryCaches'; -import reloadWithCacheBust from '@libs/reloadWithCacheBust'; import CONST from '@src/CONST'; type Import = Promise<{default: T}>; @@ -30,7 +29,7 @@ const lazyRetry = function >(componentImport: Compo if (!hasRefreshed) { console.error('Failed to lazily import a React component, refreshing the page in order to retry the operation.', component); sessionStorage.setItem(CONST.SESSION_STORAGE_KEYS.RETRY_LAZY_REFRESHED, 'true'); - clearWorkboxRecoveryCaches().then(() => reloadWithCacheBust()); + clearWorkboxRecoveryCaches().then(() => window.location.reload()); } else { console.error('Failed to lazily import a React component after the retry operation!', component); // If the import fails again reject with the error to trigger default error handling diff --git a/tests/unit/chunkLoadErrorRecoveryTest.ts b/tests/unit/chunkLoadErrorRecoveryTest.ts index 79fbc4eb163a..8e47700ed926 100644 --- a/tests/unit/chunkLoadErrorRecoveryTest.ts +++ b/tests/unit/chunkLoadErrorRecoveryTest.ts @@ -2,12 +2,11 @@ * Regression tests for the Safari PWA ChunkLoadError crash loop. * * Before the fix, both recovery paths used a bare window.location.reload(). - * In Safari PWA standalone mode this re-serves the stale service-worker precache - * (and an HTTP-cached index.html), reproducing the identical ChunkLoadError on every - * refresh. The fix routes each reload through clearWorkboxRecoveryCaches() (unregisters - * the SW and clears Cache Storage) and then reloadWithCacheBust() (navigates to a unique - * URL so the HTTP/edge cache is bypassed), so the next load fetches a fresh, - * internally-consistent shell from the CDN. + * In Safari PWA standalone mode this re-serves the stale service-worker + * precache, reproducing the identical ChunkLoadError on every refresh. + * The fix routes each reload through clearWorkboxRecoveryCaches() first, + * which unregisters the SW and clears Cache Storage so the next load fetches + * a fresh, internally-consistent shell from the CDN. */ import {renderHook} from '@testing-library/react-native'; import type {ComponentType} from 'react'; @@ -24,12 +23,6 @@ jest.mock('@libs/clearWorkboxRecoveryCaches', () => ({ default: (...args: unknown[]): any => mockClearWorkboxRecoveryCaches(...args), })); -const mockReloadWithCacheBust = jest.fn(); -jest.mock('@libs/reloadWithCacheBust', () => ({ - __esModule: true, - default: () => mockReloadWithCacheBust() as unknown, -})); - // jest-expo resolves @hooks/usePageRefresh to the .native.ts variant (which does not use // clearWorkboxRecoveryCaches). Override the alias to load the web file so this test covers // the web-specific reload path that was changed. The web file's own imports (including @@ -56,32 +49,40 @@ function flushMicrotasks(turns = 3): Promise { } describe('ChunkLoadError recovery', () => { + let reloadMock: jest.Mock; // Records the order in which clear and reload are called within each test. const callOrder: string[] = []; + beforeAll(() => { + reloadMock = jest.fn().mockImplementation(() => { + callOrder.push('reload'); + }); + Object.defineProperty(window, 'location', { + configurable: true, + value: {reload: reloadMock}, + }); + }); + beforeEach(() => { callOrder.length = 0; mockClearWorkboxRecoveryCaches.mockImplementation(() => { callOrder.push('clear'); return Promise.resolve(); }); - mockReloadWithCacheBust.mockImplementation(() => { - callOrder.push('reload'); - }); - mockReloadWithCacheBust.mockClear(); + reloadMock.mockClear(); mockClearWorkboxRecoveryCaches.mockClear(); sessionStorage.clear(); }); describe('usePageRefresh (web)', () => { - it('clears caches before the cache-busting reload when isChunkLoadError is true', async () => { + it('clears caches before reloading when isChunkLoadError is true', async () => { const {result} = renderHook(() => usePageRefresh()); result.current(true); await flushMicrotasks(); expect(mockClearWorkboxRecoveryCaches).toHaveBeenCalledTimes(1); - expect(mockReloadWithCacheBust).toHaveBeenCalledTimes(1); + expect(reloadMock).toHaveBeenCalledTimes(1); expect(callOrder).toEqual(['clear', 'reload']); }); @@ -92,12 +93,12 @@ describe('ChunkLoadError recovery', () => { await flushMicrotasks(); expect(mockClearWorkboxRecoveryCaches).not.toHaveBeenCalled(); - expect(mockReloadWithCacheBust).not.toHaveBeenCalled(); + expect(reloadMock).not.toHaveBeenCalled(); }); }); describe('lazyRetry', () => { - it('clears caches before the cache-busting reload on the first chunk load failure', async () => { + it('clears caches before reloading on the first chunk load failure', async () => { sessionStorage.removeItem(CONST.SESSION_STORAGE_KEYS.RETRY_LAZY_REFRESHED); const failingImport = jest.fn().mockRejectedValue(new Error('chunk failed')) as unknown as ComponentImport; @@ -105,7 +106,7 @@ describe('ChunkLoadError recovery', () => { await flushMicrotasks(); expect(mockClearWorkboxRecoveryCaches).toHaveBeenCalledTimes(1); - expect(mockReloadWithCacheBust).toHaveBeenCalledTimes(1); + expect(reloadMock).toHaveBeenCalledTimes(1); expect(callOrder).toEqual(['clear', 'reload']); }); @@ -117,7 +118,7 @@ describe('ChunkLoadError recovery', () => { await flushMicrotasks(); expect(mockClearWorkboxRecoveryCaches).not.toHaveBeenCalled(); - expect(mockReloadWithCacheBust).not.toHaveBeenCalled(); + expect(reloadMock).not.toHaveBeenCalled(); }); }); }); diff --git a/tests/unit/reloadWithCacheBustTest.ts b/tests/unit/reloadWithCacheBustTest.ts deleted file mode 100644 index dcae6cc80b39..000000000000 --- a/tests/unit/reloadWithCacheBustTest.ts +++ /dev/null @@ -1,57 +0,0 @@ -/** - * Tests for reloadWithCacheBust. - * - * index.html is served with Cache-Control: max-age=86400, so a plain reload can be - * handed a stale shell from Safari's HTTP cache (or the CDN edge) that still references - * dead chunk hashes. reloadWithCacheBust navigates to a unique URL so the document is - * fetched fresh from the network, breaking the ChunkLoadError loop. - */ -import reloadWithCacheBust from '@libs/reloadWithCacheBust'; - -const ORIGINAL_HREF = 'https://new.expensify.com/r/123'; - -describe('reloadWithCacheBust', () => { - let replacedUrl: string | undefined; - let replaceMock: jest.Mock; - let reloadMock: jest.Mock; - - beforeEach(() => { - replacedUrl = undefined; - replaceMock = jest.fn((url: string) => { - replacedUrl = url; - }); - reloadMock = jest.fn(); - Object.defineProperty(window, 'location', { - configurable: true, - value: {href: ORIGINAL_HREF, replace: replaceMock, reload: reloadMock}, - }); - }); - - it('navigates to a unique cache-busting URL via replace (bypasses HTTP/edge cache)', () => { - reloadWithCacheBust(); - - expect(replaceMock).toHaveBeenCalledTimes(1); - const target = new URL(replacedUrl ?? ''); - // Preserves the current route while adding a unique param that forces a cache miss. - expect(target.pathname).toBe('/r/123'); - expect(target.searchParams.get('forceReload')).toBeTruthy(); - expect(reloadMock).not.toHaveBeenCalled(); - }); - - it('falls back to a plain reload when navigation throws', () => { - Object.defineProperty(window, 'location', { - configurable: true, - value: { - href: ORIGINAL_HREF, - replace: () => { - throw new Error('replace blocked'); - }, - reload: reloadMock, - }, - }); - - reloadWithCacheBust(); - - expect(reloadMock).toHaveBeenCalledTimes(1); - }); -}); From af4d2f1d5cb24faf269b4cfb9e15ba528b92caf1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Jasikowski?= Date: Wed, 3 Jun 2026 12:04:43 +0200 Subject: [PATCH 04/12] Two-phase lazyRetry: plain reload first, SW-clear on second failure A chunk load can fail from a transient network blip, not just a stale post-deploy shell. Routing the first failure through clearWorkboxRecoveryCaches() nuked all caches and re-precached the full app unnecessarily for a problem a plain reload would fix. First failure: plain window.location.reload() (cheap, handles blips). Second failure: clearWorkboxRecoveryCaches() then reload (handles the stale SW precache scenario where the plain reload did not help). lazyRetry no longer rejects on second failure - it always reloads. --- src/utils/lazyRetry.ts | 26 ++++++++++++++++------- tests/unit/chunkLoadErrorRecoveryTest.ts | 27 ++++++++++++++++++------ 2 files changed, 38 insertions(+), 15 deletions(-) diff --git a/src/utils/lazyRetry.ts b/src/utils/lazyRetry.ts index f25fff3eff86..c5933917207c 100644 --- a/src/utils/lazyRetry.ts +++ b/src/utils/lazyRetry.ts @@ -7,15 +7,21 @@ type ComponentImport = () => Import; /** * Attempts to lazily import a React component with a retry mechanism on failure. - * If the initial import fails the function will refresh the page once and retry the import. - * If the import fails again after the refresh, the error is propagated. + * + * On the first failure a plain reload is attempted — this handles transient network + * blips cheaply without touching the service-worker caches. + * + * If the chunk still fails after that reload, the service-worker precache and Cache + * Storage are cleared before a second reload. This handles the post-deploy stale-shell + * scenario where the SW has pinned an old index.html that references chunk hashes which + * no longer exist on the CDN. * * @param componentImport - A function that returns a promise resolving to a lazily imported React component. - * @returns A promise that resolves to the imported component or rejects with an error after a retry attempt. + * @returns A promise that resolves to the imported component. If all attempts fail the page is reloaded and the promise never settles. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any const lazyRetry = function >(componentImport: ComponentImport): Import { - return new Promise((resolve, reject) => { + return new Promise((resolve) => { // Retrieve the retry status from sessionStorage, defaulting to 'false' if not set const hasRefreshed = JSON.parse(sessionStorage.getItem(CONST.SESSION_STORAGE_KEYS.RETRY_LAZY_REFRESHED) ?? 'false') as boolean; @@ -27,13 +33,17 @@ const lazyRetry = function >(componentImport: Compo }) .catch((component: ComponentImport) => { if (!hasRefreshed) { + // First failure: plain reload to handle transient network errors cheaply. console.error('Failed to lazily import a React component, refreshing the page in order to retry the operation.', component); sessionStorage.setItem(CONST.SESSION_STORAGE_KEYS.RETRY_LAZY_REFRESHED, 'true'); - clearWorkboxRecoveryCaches().then(() => window.location.reload()); + window.location.reload(); } else { - console.error('Failed to lazily import a React component after the retry operation!', component); - // If the import fails again reject with the error to trigger default error handling - reject(component); + // Second failure: the plain reload did not fix it — likely a stale + // service-worker precache after a deploy. Clear SW caches before reloading + // so the next load fetches a fresh, internally-consistent shell from the CDN. + console.error('Failed to lazily import a React component after the retry operation, clearing SW caches and reloading.', component); + sessionStorage.removeItem(CONST.SESSION_STORAGE_KEYS.RETRY_LAZY_REFRESHED); + clearWorkboxRecoveryCaches().then(() => window.location.reload()); } }); }); diff --git a/tests/unit/chunkLoadErrorRecoveryTest.ts b/tests/unit/chunkLoadErrorRecoveryTest.ts index 8e47700ed926..7e21f3a3b3c3 100644 --- a/tests/unit/chunkLoadErrorRecoveryTest.ts +++ b/tests/unit/chunkLoadErrorRecoveryTest.ts @@ -1,12 +1,13 @@ /** * Regression tests for the Safari PWA ChunkLoadError crash loop. * - * Before the fix, both recovery paths used a bare window.location.reload(). - * In Safari PWA standalone mode this re-serves the stale service-worker - * precache, reproducing the identical ChunkLoadError on every refresh. - * The fix routes each reload through clearWorkboxRecoveryCaches() first, - * which unregisters the SW and clears Cache Storage so the next load fetches - * a fresh, internally-consistent shell from the CDN. + * usePageRefresh: clears SW caches then reloads on any chunk-load error (the Refresh + * button is only shown after the automatic lazyRetry cycle has already run, so we are + * already on the second failure by the time the user taps it). + * + * lazyRetry uses a two-phase approach: + * - First failure → plain reload (cheap; handles transient network blips). + * - Second failure → clear SW caches then reload (handles stale post-deploy shell). */ import {renderHook} from '@testing-library/react-native'; import type {ComponentType} from 'react'; @@ -98,13 +99,25 @@ describe('ChunkLoadError recovery', () => { }); describe('lazyRetry', () => { - it('clears caches before reloading on the first chunk load failure', async () => { + it('plain-reloads on the first chunk load failure without clearing caches', async () => { sessionStorage.removeItem(CONST.SESSION_STORAGE_KEYS.RETRY_LAZY_REFRESHED); const failingImport = jest.fn().mockRejectedValue(new Error('chunk failed')) as unknown as ComponentImport; lazyRetry(failingImport); await flushMicrotasks(); + expect(reloadMock).toHaveBeenCalledTimes(1); + expect(mockClearWorkboxRecoveryCaches).not.toHaveBeenCalled(); + expect(callOrder).toEqual(['reload']); + }); + + it('clears SW caches before reloading on the second chunk load failure', async () => { + sessionStorage.setItem(CONST.SESSION_STORAGE_KEYS.RETRY_LAZY_REFRESHED, 'true'); + const failingImport = jest.fn().mockRejectedValue(new Error('chunk failed')) as unknown as ComponentImport; + + lazyRetry(failingImport); + await flushMicrotasks(); + expect(mockClearWorkboxRecoveryCaches).toHaveBeenCalledTimes(1); expect(reloadMock).toHaveBeenCalledTimes(1); expect(callOrder).toEqual(['clear', 'reload']); From 566a6fde96955d3f6896a5da40aceb2f7e93c347 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Jasikowski?= Date: Wed, 3 Jun 2026 14:01:47 +0200 Subject: [PATCH 05/12] Add justification to eslint-disable comments in test --- tests/unit/chunkLoadErrorRecoveryTest.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/chunkLoadErrorRecoveryTest.ts b/tests/unit/chunkLoadErrorRecoveryTest.ts index 7e21f3a3b3c3..dd4b5cd76445 100644 --- a/tests/unit/chunkLoadErrorRecoveryTest.ts +++ b/tests/unit/chunkLoadErrorRecoveryTest.ts @@ -20,7 +20,7 @@ type ComponentImport = () => Promise<{default: T}>; const mockClearWorkboxRecoveryCaches = jest.fn(); jest.mock('@libs/clearWorkboxRecoveryCaches', () => ({ __esModule: true, - // eslint-disable-next-line @typescript-eslint/no-explicit-any + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- mock factory must return `any` to satisfy the dynamic module shape Jest expects default: (...args: unknown[]): any => mockClearWorkboxRecoveryCaches(...args), })); @@ -28,7 +28,7 @@ jest.mock('@libs/clearWorkboxRecoveryCaches', () => ({ // clearWorkboxRecoveryCaches). Override the alias to load the web file so this test covers // the web-specific reload path that was changed. The web file's own imports (including // clearWorkboxRecoveryCaches) still go through the normal mock registry. -// eslint-disable-next-line @typescript-eslint/no-unsafe-return +// eslint-disable-next-line @typescript-eslint/no-unsafe-return -- jest.requireActual returns an untyped module; the unsafe return is unavoidable here jest.mock('@hooks/usePageRefresh', () => jest.requireActual('../../src/hooks/usePageRefresh/index.ts')); jest.mock('react-error-boundary', () => ({ From 6f6b475a126b8b2f898bea8f72ef4d962647f118 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Jasikowski?= Date: Wed, 3 Jun 2026 14:28:54 +0200 Subject: [PATCH 06/12] Address review: three-state lazyRetry, fix infinite loop and offline concern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Three-state retry machine (false → true → cache-cleared) prevents the infinite reload loop that occurred when the flag was removed on the second failure and the third load was treated as a first failure again. - Gate the SW cache clear on isChunkLoadError so a second failure caused by a network/offline error does not destroy the offline precache. - Non-ChunkLoadError second failures and any third failure now reject to the error boundary instead of looping. - Rename the catch parameter from component to error. - Restore window.location in afterAll so the override does not leak. - Add tests for the non-ChunkLoadError and third-failure paths. --- src/utils/lazyRetry.ts | 75 ++++++++++++++++-------- tests/unit/chunkLoadErrorRecoveryTest.ts | 50 +++++++++++++--- 2 files changed, 92 insertions(+), 33 deletions(-) diff --git a/src/utils/lazyRetry.ts b/src/utils/lazyRetry.ts index c5933917207c..b88769d15398 100644 --- a/src/utils/lazyRetry.ts +++ b/src/utils/lazyRetry.ts @@ -5,45 +5,68 @@ import CONST from '@src/CONST'; type Import = Promise<{default: T}>; type ComponentImport = () => Import; +// Three-state retry machine stored in sessionStorage: +// 'false' — no reload attempted yet (default) +// 'true' — one plain reload has been attempted +// 'cache-cleared'— SW caches were cleared and a second reload was attempted +const RETRY_STATE = { + INITIAL: 'false', + RELOADED: 'true', + CACHE_CLEARED: 'cache-cleared', +} as const; + +function isChunkLoadError(error: unknown): boolean { + if (!(error instanceof Error)) { + return false; + } + return error.name === CONST.CHUNK_LOAD_ERROR || /Loading chunk \S+ failed/i.test(error.message); +} + /** - * Attempts to lazily import a React component with a retry mechanism on failure. - * - * On the first failure a plain reload is attempted — this handles transient network - * blips cheaply without touching the service-worker caches. + * Attempts to lazily import a React component with a graduated retry strategy. * - * If the chunk still fails after that reload, the service-worker precache and Cache - * Storage are cleared before a second reload. This handles the post-deploy stale-shell - * scenario where the SW has pinned an old index.html that references chunk hashes which - * no longer exist on the CDN. + * - First failure: plain reload — handles transient network blips without touching caches. + * - Second failure that is a ChunkLoadError: clear the SW precache and reload — handles the + * post-deploy stale-shell scenario where the SW pinned an old index.html that references + * chunk hashes no longer on the CDN. Caches are left intact for non-ChunkLoadErrors (e.g. + * offline) so the offline-capable precache is not destroyed for a recoverable situation. + * - Any subsequent failure (or a second failure that is not a ChunkLoadError): propagate the + * error to the React error boundary so the user sees the error page instead of looping. * * @param componentImport - A function that returns a promise resolving to a lazily imported React component. - * @returns A promise that resolves to the imported component. If all attempts fail the page is reloaded and the promise never settles. + * @returns A promise that resolves to the imported component or rejects after all recovery attempts. */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ComponentType requires any for the generic constraint to accept all component shapes const lazyRetry = function >(componentImport: ComponentImport): Import { - return new Promise((resolve) => { - // Retrieve the retry status from sessionStorage, defaulting to 'false' if not set - const hasRefreshed = JSON.parse(sessionStorage.getItem(CONST.SESSION_STORAGE_KEYS.RETRY_LAZY_REFRESHED) ?? 'false') as boolean; + return new Promise((resolve, reject) => { + const retryState = sessionStorage.getItem(CONST.SESSION_STORAGE_KEYS.RETRY_LAZY_REFRESHED) ?? RETRY_STATE.INITIAL; componentImport() .then((component) => { - // Reset the retry status to 'false' on successful import - sessionStorage.setItem(CONST.SESSION_STORAGE_KEYS.RETRY_LAZY_REFRESHED, 'false'); // success so reset the refresh + sessionStorage.setItem(CONST.SESSION_STORAGE_KEYS.RETRY_LAZY_REFRESHED, RETRY_STATE.INITIAL); resolve(component); }) - .catch((component: ComponentImport) => { - if (!hasRefreshed) { - // First failure: plain reload to handle transient network errors cheaply. - console.error('Failed to lazily import a React component, refreshing the page in order to retry the operation.', component); - sessionStorage.setItem(CONST.SESSION_STORAGE_KEYS.RETRY_LAZY_REFRESHED, 'true'); + .catch((error: unknown) => { + if (retryState === RETRY_STATE.INITIAL) { + // First failure: plain reload to handle transient errors cheaply. + console.error('Failed to lazily import a React component, refreshing the page in order to retry the operation.', error); + sessionStorage.setItem(CONST.SESSION_STORAGE_KEYS.RETRY_LAZY_REFRESHED, RETRY_STATE.RELOADED); window.location.reload(); - } else { - // Second failure: the plain reload did not fix it — likely a stale - // service-worker precache after a deploy. Clear SW caches before reloading - // so the next load fetches a fresh, internally-consistent shell from the CDN. - console.error('Failed to lazily import a React component after the retry operation, clearing SW caches and reloading.', component); - sessionStorage.removeItem(CONST.SESSION_STORAGE_KEYS.RETRY_LAZY_REFRESHED); + } else if (retryState === RETRY_STATE.RELOADED && isChunkLoadError(error)) { + // Second failure and it is a ChunkLoadError: the plain reload did not help — + // likely a stale SW precache. Clear caches and reload. Keep the flag at + // CACHE_CLEARED so that a third failure will surface the error boundary + // rather than starting the cycle over. + console.error('Failed to lazily import a React component after reload, clearing SW caches and reloading.', error); + sessionStorage.setItem(CONST.SESSION_STORAGE_KEYS.RETRY_LAZY_REFRESHED, RETRY_STATE.CACHE_CLEARED); clearWorkboxRecoveryCaches().then(() => window.location.reload()); + } else { + // All recovery options exhausted, or second failure is not a ChunkLoadError + // (e.g. offline): propagate to the error boundary and reset the flag so the + // retry cycle is available again next time the user navigates. + console.error('Failed to lazily import a React component after all recovery attempts.', error); + sessionStorage.setItem(CONST.SESSION_STORAGE_KEYS.RETRY_LAZY_REFRESHED, RETRY_STATE.INITIAL); + reject(error instanceof Error ? error : new Error(String(error))); } }); }); diff --git a/tests/unit/chunkLoadErrorRecoveryTest.ts b/tests/unit/chunkLoadErrorRecoveryTest.ts index dd4b5cd76445..b81d197fc1f0 100644 --- a/tests/unit/chunkLoadErrorRecoveryTest.ts +++ b/tests/unit/chunkLoadErrorRecoveryTest.ts @@ -5,9 +5,11 @@ * button is only shown after the automatic lazyRetry cycle has already run, so we are * already on the second failure by the time the user taps it). * - * lazyRetry uses a two-phase approach: - * - First failure → plain reload (cheap; handles transient network blips). - * - Second failure → clear SW caches then reload (handles stale post-deploy shell). + * lazyRetry uses a three-state strategy: + * - First failure → plain reload (cheap; handles transient network blips). + * - Second failure (chunk) → clear SW caches then reload (handles stale post-deploy shell). + * - Second failure (non-chunk)→ reject to error boundary (avoids nuking the offline precache). + * - Third failure → reject to error boundary (loop prevention). */ import {renderHook} from '@testing-library/react-native'; import type {ComponentType} from 'react'; @@ -53,6 +55,8 @@ describe('ChunkLoadError recovery', () => { let reloadMock: jest.Mock; // Records the order in which clear and reload are called within each test. const callOrder: string[] = []; + // Preserve the original location so the override does not leak between test files. + const originalLocation = window.location; beforeAll(() => { reloadMock = jest.fn().mockImplementation(() => { @@ -64,6 +68,13 @@ describe('ChunkLoadError recovery', () => { }); }); + afterAll(() => { + Object.defineProperty(window, 'location', { + configurable: true, + value: originalLocation, + }); + }); + beforeEach(() => { callOrder.length = 0; mockClearWorkboxRecoveryCaches.mockImplementation(() => { @@ -99,9 +110,11 @@ describe('ChunkLoadError recovery', () => { }); describe('lazyRetry', () => { - it('plain-reloads on the first chunk load failure without clearing caches', async () => { + const chunkError = Object.assign(new Error('Loading chunk 3851 failed.'), {name: 'ChunkLoadError'}); + + it('plain-reloads on the first failure without clearing caches', async () => { sessionStorage.removeItem(CONST.SESSION_STORAGE_KEYS.RETRY_LAZY_REFRESHED); - const failingImport = jest.fn().mockRejectedValue(new Error('chunk failed')) as unknown as ComponentImport; + const failingImport = jest.fn().mockRejectedValue(chunkError) as unknown as ComponentImport; lazyRetry(failingImport); await flushMicrotasks(); @@ -111,9 +124,9 @@ describe('ChunkLoadError recovery', () => { expect(callOrder).toEqual(['reload']); }); - it('clears SW caches before reloading on the second chunk load failure', async () => { + it('clears SW caches before reloading on the second ChunkLoadError failure', async () => { sessionStorage.setItem(CONST.SESSION_STORAGE_KEYS.RETRY_LAZY_REFRESHED, 'true'); - const failingImport = jest.fn().mockRejectedValue(new Error('chunk failed')) as unknown as ComponentImport; + const failingImport = jest.fn().mockRejectedValue(chunkError) as unknown as ComponentImport; lazyRetry(failingImport); await flushMicrotasks(); @@ -123,6 +136,29 @@ describe('ChunkLoadError recovery', () => { expect(callOrder).toEqual(['clear', 'reload']); }); + it('rejects to the error boundary on second failure when the error is not a ChunkLoadError', async () => { + sessionStorage.setItem(CONST.SESSION_STORAGE_KEYS.RETRY_LAZY_REFRESHED, 'true'); + const networkError = new Error('Failed to fetch'); + const failingImport = jest.fn().mockRejectedValue(networkError) as unknown as ComponentImport; + + await expect(lazyRetry(failingImport)).rejects.toThrow('Failed to fetch'); + await flushMicrotasks(); + + expect(mockClearWorkboxRecoveryCaches).not.toHaveBeenCalled(); + expect(reloadMock).not.toHaveBeenCalled(); + }); + + it('rejects to the error boundary on the third failure to prevent an infinite reload loop', async () => { + sessionStorage.setItem(CONST.SESSION_STORAGE_KEYS.RETRY_LAZY_REFRESHED, 'cache-cleared'); + const failingImport = jest.fn().mockRejectedValue(chunkError) as unknown as ComponentImport; + + await expect(lazyRetry(failingImport)).rejects.toBeDefined(); + await flushMicrotasks(); + + expect(mockClearWorkboxRecoveryCaches).not.toHaveBeenCalled(); + expect(reloadMock).not.toHaveBeenCalled(); + }); + it('does not reload on successful import', async () => { sessionStorage.removeItem(CONST.SESSION_STORAGE_KEYS.RETRY_LAZY_REFRESHED); const successfulImport = jest.fn().mockResolvedValue({default: () => null}) as unknown as ComponentImport; From 5d87a01ae274cc44226fb97d85fc2cde62681902 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Jasikowski?= Date: Wed, 3 Jun 2026 15:23:13 +0200 Subject: [PATCH 07/12] Gate SW cache clear on navigator.onLine to protect offline precache An offline chunk fetch also produces a ChunkLoadError, so the isChunkLoadError check alone was insufficient. Without the online guard, a user navigating to a lazy route while offline would trigger clearWorkboxRecoveryCaches() on the second failure, destroying the Workbox precache that is the only thing keeping the PWA usable until connectivity returns. Add navigator.onLine to the condition: only clear caches when the device is online (stale post-deploy SW shell). When offline, or when the error is not a ChunkLoadError, reject to the error boundary immediately so the precache is preserved. --- src/utils/lazyRetry.ts | 30 +++++++++++++----------- tests/unit/chunkLoadErrorRecoveryTest.ts | 24 +++++++++++++++---- 2 files changed, 35 insertions(+), 19 deletions(-) diff --git a/src/utils/lazyRetry.ts b/src/utils/lazyRetry.ts index b88769d15398..bed977b65c67 100644 --- a/src/utils/lazyRetry.ts +++ b/src/utils/lazyRetry.ts @@ -26,12 +26,14 @@ function isChunkLoadError(error: unknown): boolean { * Attempts to lazily import a React component with a graduated retry strategy. * * - First failure: plain reload — handles transient network blips without touching caches. - * - Second failure that is a ChunkLoadError: clear the SW precache and reload — handles the - * post-deploy stale-shell scenario where the SW pinned an old index.html that references - * chunk hashes no longer on the CDN. Caches are left intact for non-ChunkLoadErrors (e.g. - * offline) so the offline-capable precache is not destroyed for a recoverable situation. - * - Any subsequent failure (or a second failure that is not a ChunkLoadError): propagate the - * error to the React error boundary so the user sees the error page instead of looping. + * - Second failure that is a ChunkLoadError AND the device is online: clear the SW precache + * and reload — handles the post-deploy stale-shell scenario where the SW pinned an old + * index.html that references chunk hashes no longer on the CDN. + * The online guard is critical: a chunk fetch that fails while offline also produces a + * ChunkLoadError, and clearing the Workbox precaches in that case would destroy the offline + * app shell that is the only thing keeping the PWA usable until connectivity returns. + * - Any subsequent failure, a second failure that is not a ChunkLoadError, or a second failure + * while offline: propagate to the React error boundary so the user sees the error page. * * @param componentImport - A function that returns a promise resolving to a lazily imported React component. * @returns A promise that resolves to the imported component or rejects after all recovery attempts. @@ -52,18 +54,18 @@ const lazyRetry = function >(componentImport: Compo console.error('Failed to lazily import a React component, refreshing the page in order to retry the operation.', error); sessionStorage.setItem(CONST.SESSION_STORAGE_KEYS.RETRY_LAZY_REFRESHED, RETRY_STATE.RELOADED); window.location.reload(); - } else if (retryState === RETRY_STATE.RELOADED && isChunkLoadError(error)) { - // Second failure and it is a ChunkLoadError: the plain reload did not help — - // likely a stale SW precache. Clear caches and reload. Keep the flag at - // CACHE_CLEARED so that a third failure will surface the error boundary - // rather than starting the cycle over. + } else if (retryState === RETRY_STATE.RELOADED && isChunkLoadError(error) && navigator.onLine) { + // Second failure, it is a ChunkLoadError, and the device is online: the plain + // reload did not help — likely a stale SW precache after a deploy. Clear caches + // and reload. Keep the flag at CACHE_CLEARED so a third failure surfaces the + // error boundary rather than starting the cycle over. console.error('Failed to lazily import a React component after reload, clearing SW caches and reloading.', error); sessionStorage.setItem(CONST.SESSION_STORAGE_KEYS.RETRY_LAZY_REFRESHED, RETRY_STATE.CACHE_CLEARED); clearWorkboxRecoveryCaches().then(() => window.location.reload()); } else { - // All recovery options exhausted, or second failure is not a ChunkLoadError - // (e.g. offline): propagate to the error boundary and reset the flag so the - // retry cycle is available again next time the user navigates. + // All recovery options exhausted, or the device is offline, or the second + // failure is not a ChunkLoadError: propagate to the error boundary and reset + // the flag so the retry cycle is available again next time the user navigates. console.error('Failed to lazily import a React component after all recovery attempts.', error); sessionStorage.setItem(CONST.SESSION_STORAGE_KEYS.RETRY_LAZY_REFRESHED, RETRY_STATE.INITIAL); reject(error instanceof Error ? error : new Error(String(error))); diff --git a/tests/unit/chunkLoadErrorRecoveryTest.ts b/tests/unit/chunkLoadErrorRecoveryTest.ts index b81d197fc1f0..2b3e13fe86dc 100644 --- a/tests/unit/chunkLoadErrorRecoveryTest.ts +++ b/tests/unit/chunkLoadErrorRecoveryTest.ts @@ -6,10 +6,11 @@ * already on the second failure by the time the user taps it). * * lazyRetry uses a three-state strategy: - * - First failure → plain reload (cheap; handles transient network blips). - * - Second failure (chunk) → clear SW caches then reload (handles stale post-deploy shell). - * - Second failure (non-chunk)→ reject to error boundary (avoids nuking the offline precache). - * - Third failure → reject to error boundary (loop prevention). + * - First failure → plain reload. + * - Second failure, ChunkLoadError, online→ clear SW caches then reload. + * - Second failure, ChunkLoadError, offline→ reject to error boundary (preserve offline precache). + * - Second failure, non-ChunkLoadError → reject to error boundary. + * - Third failure → reject to error boundary (loop prevention). */ import {renderHook} from '@testing-library/react-native'; import type {ComponentType} from 'react'; @@ -124,8 +125,9 @@ describe('ChunkLoadError recovery', () => { expect(callOrder).toEqual(['reload']); }); - it('clears SW caches before reloading on the second ChunkLoadError failure', async () => { + it('clears SW caches before reloading on the second ChunkLoadError failure when online', async () => { sessionStorage.setItem(CONST.SESSION_STORAGE_KEYS.RETRY_LAZY_REFRESHED, 'true'); + jest.spyOn(navigator, 'onLine', 'get').mockReturnValue(true); const failingImport = jest.fn().mockRejectedValue(chunkError) as unknown as ComponentImport; lazyRetry(failingImport); @@ -136,6 +138,18 @@ describe('ChunkLoadError recovery', () => { expect(callOrder).toEqual(['clear', 'reload']); }); + it('rejects to the error boundary on second ChunkLoadError failure when offline to preserve the offline precache', async () => { + sessionStorage.setItem(CONST.SESSION_STORAGE_KEYS.RETRY_LAZY_REFRESHED, 'true'); + jest.spyOn(navigator, 'onLine', 'get').mockReturnValue(false); + const failingImport = jest.fn().mockRejectedValue(chunkError) as unknown as ComponentImport; + + await expect(lazyRetry(failingImport)).rejects.toBeDefined(); + await flushMicrotasks(); + + expect(mockClearWorkboxRecoveryCaches).not.toHaveBeenCalled(); + expect(reloadMock).not.toHaveBeenCalled(); + }); + it('rejects to the error boundary on second failure when the error is not a ChunkLoadError', async () => { sessionStorage.setItem(CONST.SESSION_STORAGE_KEYS.RETRY_LAZY_REFRESHED, 'true'); const networkError = new Error('Failed to fetch'); From 6259a0c54c87d02ce8be2684da11c3ebd583b1b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Jasikowski?= Date: Wed, 3 Jun 2026 15:26:51 +0200 Subject: [PATCH 08/12] Replace 'precache/precaches' with plain English in comments --- src/libs/clearWorkboxRecoveryCaches/index.ts | 4 ++-- src/utils/lazyRetry.ts | 14 +++++++------- tests/unit/chunkLoadErrorRecoveryTest.ts | 8 ++++---- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/libs/clearWorkboxRecoveryCaches/index.ts b/src/libs/clearWorkboxRecoveryCaches/index.ts index 003c23cad188..2bc9c5bad44d 100644 --- a/src/libs/clearWorkboxRecoveryCaches/index.ts +++ b/src/libs/clearWorkboxRecoveryCaches/index.ts @@ -4,8 +4,8 @@ import Log from '@libs/Log'; /** * Clears all Cache Storage entries for this origin and unregisters every * service worker. Used when the user runs Troubleshoot > Clear cache and - * restart so Workbox precache/runtime caches do not survive that recovery - * path (stale app shell / chunks would otherwise keep serving from SW). + * restart, and during ChunkLoadError recovery, so stale cached assets do + * not survive those recovery paths and keep re-serving broken chunks. */ async function clearWorkboxRecoveryCaches(): Promise { // Normally platform-specific behaviour is achieved with .native.ts / .ts file pairs. diff --git a/src/utils/lazyRetry.ts b/src/utils/lazyRetry.ts index bed977b65c67..9469b54e675b 100644 --- a/src/utils/lazyRetry.ts +++ b/src/utils/lazyRetry.ts @@ -26,11 +26,11 @@ function isChunkLoadError(error: unknown): boolean { * Attempts to lazily import a React component with a graduated retry strategy. * * - First failure: plain reload — handles transient network blips without touching caches. - * - Second failure that is a ChunkLoadError AND the device is online: clear the SW precache - * and reload — handles the post-deploy stale-shell scenario where the SW pinned an old - * index.html that references chunk hashes no longer on the CDN. + * - Second failure that is a ChunkLoadError AND the device is online: clear the service worker + * cache and reload — handles the post-deploy stale-shell scenario where the SW is serving an + * old index.html that references chunk hashes no longer on the CDN. * The online guard is critical: a chunk fetch that fails while offline also produces a - * ChunkLoadError, and clearing the Workbox precaches in that case would destroy the offline + * ChunkLoadError, and clearing the service worker cache in that case would destroy the cached * app shell that is the only thing keeping the PWA usable until connectivity returns. * - Any subsequent failure, a second failure that is not a ChunkLoadError, or a second failure * while offline: propagate to the React error boundary so the user sees the error page. @@ -56,9 +56,9 @@ const lazyRetry = function >(componentImport: Compo window.location.reload(); } else if (retryState === RETRY_STATE.RELOADED && isChunkLoadError(error) && navigator.onLine) { // Second failure, it is a ChunkLoadError, and the device is online: the plain - // reload did not help — likely a stale SW precache after a deploy. Clear caches - // and reload. Keep the flag at CACHE_CLEARED so a third failure surfaces the - // error boundary rather than starting the cycle over. + // reload did not fix it — likely the SW is serving a stale shell after a deploy. + // Clear the service worker cache and reload. Keep the flag at CACHE_CLEARED so + // a third failure surfaces the error boundary instead of starting over. console.error('Failed to lazily import a React component after reload, clearing SW caches and reloading.', error); sessionStorage.setItem(CONST.SESSION_STORAGE_KEYS.RETRY_LAZY_REFRESHED, RETRY_STATE.CACHE_CLEARED); clearWorkboxRecoveryCaches().then(() => window.location.reload()); diff --git a/tests/unit/chunkLoadErrorRecoveryTest.ts b/tests/unit/chunkLoadErrorRecoveryTest.ts index 2b3e13fe86dc..82fc89a4eaf6 100644 --- a/tests/unit/chunkLoadErrorRecoveryTest.ts +++ b/tests/unit/chunkLoadErrorRecoveryTest.ts @@ -7,10 +7,10 @@ * * lazyRetry uses a three-state strategy: * - First failure → plain reload. - * - Second failure, ChunkLoadError, online→ clear SW caches then reload. - * - Second failure, ChunkLoadError, offline→ reject to error boundary (preserve offline precache). - * - Second failure, non-ChunkLoadError → reject to error boundary. - * - Third failure → reject to error boundary (loop prevention). + * - Second failure, ChunkLoadError, online → clear SW cache then reload. + * - Second failure, ChunkLoadError, offline→ reject to error boundary (keep cached offline assets). + * - Second failure, non-ChunkLoadError → reject to error boundary. + * - Third failure → reject to error boundary (loop prevention). */ import {renderHook} from '@testing-library/react-native'; import type {ComponentType} from 'react'; From 437df2931007265b7fc70ad485fa8268ff893cea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Jasikowski?= Date: Thu, 4 Jun 2026 12:08:20 +0200 Subject: [PATCH 09/12] Extract isChunkLoadError to shared util; fix usePageRefresh non-chunk path isChunkLoadError was defined three times with slight differences (LazyModalSlot.tsx, GenericErrorPage.tsx, lazyRetry.ts). Extract it to src/libs/isChunkLoadError.ts and import from there in all three places. Uses CONST.CHUNK_LOAD_ERROR and the broader \S+ pattern everywhere. usePageRefresh was calling clearWorkboxRecoveryCaches() for all reload paths including non-chunk errors. When Refresh was clicked twice within ERROR_WINDOW_RELOAD_TIMEOUT for a generic render error the fall-through branch would unnecessarily clear the service worker cache. Gate the cache-clear on isChunkLoadError and use a plain reload otherwise. --- src/components/LazyModalSlot.tsx | 3 +-- src/hooks/usePageRefresh/index.ts | 9 ++++++++- src/libs/isChunkLoadError.ts | 14 ++++++++++++++ src/pages/ErrorPage/GenericErrorPage.tsx | 5 +++-- src/utils/lazyRetry.ts | 8 +------- tests/unit/chunkLoadErrorRecoveryTest.ts | 12 ++++++++++++ 6 files changed, 39 insertions(+), 12 deletions(-) create mode 100644 src/libs/isChunkLoadError.ts diff --git a/src/components/LazyModalSlot.tsx b/src/components/LazyModalSlot.tsx index 7c60f6b8abc9..e9f33387ec95 100644 --- a/src/components/LazyModalSlot.tsx +++ b/src/components/LazyModalSlot.tsx @@ -1,10 +1,9 @@ import * as Sentry from '@sentry/react-native'; import React, {Suspense} from 'react'; import {ErrorBoundary as ReactErrorBoundary} from 'react-error-boundary'; +import isChunkLoadError from '@libs/isChunkLoadError'; import Log from '@libs/Log'; -const isChunkLoadError = (error: Error) => error.name === 'ChunkLoadError' || /Loading chunk \S+ failed/i.test(error.message); - const logModalError = (error: Error, info: {componentStack?: string | null}) => { const componentStack = info.componentStack ?? undefined; diff --git a/src/hooks/usePageRefresh/index.ts b/src/hooks/usePageRefresh/index.ts index 3079cec74989..7b8f62c8fe48 100644 --- a/src/hooks/usePageRefresh/index.ts +++ b/src/hooks/usePageRefresh/index.ts @@ -18,7 +18,14 @@ const usePageRefresh: UsePageRefresh = () => { } sessionStorage.removeItem(CONST.SESSION_STORAGE_KEYS.LAST_REFRESH_TIMESTAMP); - clearWorkboxRecoveryCaches().then(() => window.location.reload()); + if (isChunkLoadError) { + // The error page is shown after lazyRetry has already done a plain reload + // and it did not fix the problem. Clear the service worker cache so the + // next load fetches a fresh app shell from the CDN. + clearWorkboxRecoveryCaches().then(() => window.location.reload()); + } else { + window.location.reload(); + } }; }; diff --git a/src/libs/isChunkLoadError.ts b/src/libs/isChunkLoadError.ts new file mode 100644 index 000000000000..174a291e3946 --- /dev/null +++ b/src/libs/isChunkLoadError.ts @@ -0,0 +1,14 @@ +import CONST from '@src/CONST'; + +/** + * Returns true if the given error is a webpack ChunkLoadError — the error thrown when a + * dynamically-imported script cannot be fetched (e.g. after a deploy removes old chunk hashes). + */ +function isChunkLoadError(error: unknown): boolean { + if (!(error instanceof Error)) { + return false; + } + return error.name === CONST.CHUNK_LOAD_ERROR || /Loading chunk \S+ failed/i.test(error.message); +} + +export default isChunkLoadError; diff --git a/src/pages/ErrorPage/GenericErrorPage.tsx b/src/pages/ErrorPage/GenericErrorPage.tsx index d11397d13b52..5a15d766a833 100644 --- a/src/pages/ErrorPage/GenericErrorPage.tsx +++ b/src/pages/ErrorPage/GenericErrorPage.tsx @@ -13,6 +13,7 @@ import usePageRefresh from '@hooks/usePageRefresh'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; +import isChunkLoadError from '@libs/isChunkLoadError'; import variables from '@styles/variables'; import {signOutAndRedirectToSignIn} from '@userActions/Session'; import CONST from '@src/CONST'; @@ -24,7 +25,7 @@ function GenericErrorPage({error}: {error?: Error}) { const StyleUtils = useStyleUtils(); const isAuthenticated = useIsAuthenticated(); const {translate} = useLocalize(); - const isChunkLoadError = error?.name === CONST.CHUNK_LOAD_ERROR || /Loading chunk [\d]+ failed/.test(error?.message ?? ''); + const chunkLoadError = isChunkLoadError(error); const refreshPage = usePageRefresh(); const icons = useMemoizedLazyExpensifyIcons(['ExpensifyWordmark', 'Bug']); @@ -63,7 +64,7 @@ function GenericErrorPage({error}: {error?: Error}) { success text={translate('genericErrorPage.refresh')} style={styles.mr3} - onPress={() => refreshPage(isChunkLoadError)} + onPress={() => refreshPage(chunkLoadError)} /> {isAuthenticated && (