diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 000e5faf7c25..ce5fa883baa9 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -2256,6 +2256,7 @@ const CONST = { ATTRIBUTE_ROUTE_FROM: 'route_from', ATTRIBUTE_ROUTE_TO: 'route_to', ATTRIBUTE_FINISHED_MANUALLY: 'finished_manually', + ATTRIBUTE_FAILED: 'failed', ATTRIBUTE_IS_WARM: 'is_warm', ATTRIBUTE_LAZY_TAB_FALLBACK_SHOWN: 'lazy_tab_fallback_shown', // Stamped on the navigate-to-inbox-tab span: wide-layout navigations mount the central report diff --git a/src/languages/IntlStore.ts b/src/languages/IntlStore.ts index f470ffaebc7c..6d327916f75e 100644 --- a/src/languages/IntlStore.ts +++ b/src/languages/IntlStore.ts @@ -1,14 +1,16 @@ import extractModuleDefaultExport from '@libs/extractModuleDefaultExport'; -import {endSpan, getSpan, startSpan} from '@libs/telemetry/activeSpans'; +import {endSpan, endSpanWithAttributes, getSpan, startSpan} from '@libs/telemetry/activeSpans'; import CONST from '@src/CONST'; import {LOCALES} from '@src/CONST/LOCALES'; import type {Locale} from '@src/CONST/LOCALES'; import ONYXKEYS from '@src/ONYXKEYS'; import type DynamicModule from '@src/types/utils/DynamicModule'; +import retryDynamicImport from '@src/utils/retryDynamicImport'; import type {Locale as DateUtilsLocale} from 'date-fns'; +import * as Sentry from '@sentry/react-native'; import {setDefaultOptions} from 'date-fns'; import Onyx from 'react-native-onyx'; @@ -34,6 +36,9 @@ function setAreTranslationsLoading(areTranslationsLoading: boolean) { Onyx.set(ONYXKEYS.RAM_ONLY_ARE_TRANSLATIONS_LOADING, areTranslationsLoading); } +// Scopes the dynamic-import retry state per locale +const LOCALE_RETRY_KEY_PREFIX = 'locale:'; + class IntlStore { private static currentLocale: Locale | undefined = undefined; @@ -231,7 +236,9 @@ class IntlStore { }); } - return loaderPromise() + // Retry through the shared recovery ladder: a locale chunk that 404s (stale app shell after a + // deploy) would otherwise reject unhandled and permanently block the boot splash gate in Expensify.tsx. + return retryDynamicImport(loaderPromise, `${LOCALE_RETRY_KEY_PREFIX}${locale}`) .then(() => { this.currentLocale = locale; // Set the default date-fns locale @@ -239,15 +246,24 @@ class IntlStore { if (dateUtilsLocale) { setDefaultOptions({locale: dateUtilsLocale}); } - }) - .then(() => { setAreTranslationsLoading(false); + + if (localeSpan) { + endSpan(CONST.TELEMETRY.SPAN_LOCALE.TRANSLATIONS_LOAD); + } }) - .finally(() => { - if (!localeSpan) { - return; + .catch((error: unknown) => { + if (localeSpan) { + endSpanWithAttributes(CONST.TELEMETRY.SPAN_LOCALE.TRANSLATIONS_LOAD, {[CONST.TELEMETRY.ATTRIBUTE_FAILED]: true}); } - endSpan(CONST.TELEMETRY.SPAN_LOCALE.TRANSLATIONS_LOAD); + + // Recovery is exhausted: the locale never resolves and the boot splash intentionally stays up — + // with no translations in memory any screen would render raw translation keys. Report the cause + // so the stuck splash is diagnosable in Sentry. + Sentry.captureException(error, { + fingerprint: ['locale-load-failed'], + extra: {locale}, + }); }); } diff --git a/src/utils/lazyRetry.ts b/src/utils/lazyRetry.ts index 24acdba3bbfd..66d4a489103e 100644 --- a/src/utils/lazyRetry.ts +++ b/src/utils/lazyRetry.ts @@ -1,88 +1,18 @@ -import clearWorkboxRecoveryCaches from '@libs/clearWorkboxRecoveryCaches'; -import isChunkLoadError from '@libs/isChunkLoadError'; - -import CONST from '@src/CONST'; - import type {ComponentType} from 'react'; +import retryDynamicImport from './retryDynamicImport'; + 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; - /** - * The retry state must be scoped per import. Multiple chunks are lazy-loaded in sequence - * (e.g. AppNavigator, then AuthScreens), so a global flag could be reset to INITIAL by one - * chunk's success while another chunk is still failing, restarting that chunk's retry cycle - * forever instead of advancing to the cache-clearing branch. - */ -function getRetryStateKey(retryKey: string): string { - return `${CONST.SESSION_STORAGE_KEYS.RETRY_LAZY_REFRESHED}:${retryKey}`; -} - -/** - * 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 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 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. - * - * @param componentImport - A function that returns a promise resolving to a lazily imported React component. - * @param retryKey - A stable identifier unique to this import, used to scope the retry state so - * sibling imports do not interfere with each other's recovery cycle. - * @returns A promise that resolves to the imported component or rejects after all recovery attempts. + * Attempts to lazily import a React component with a graduated retry strategy — see + * `retryDynamicImport`, which owns the recovery ladder. A rejection here propagates out of the + * `React.lazy` factory, which React converts into a render-phase throw caught by `BaseErrorBoundary`. */ // 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, retryKey: string): Import { - return new Promise((resolve, reject) => { - const stateKey = getRetryStateKey(retryKey); - const retryState = sessionStorage.getItem(stateKey) ?? RETRY_STATE.INITIAL; - - componentImport() - .then((component) => { - sessionStorage.setItem(stateKey, RETRY_STATE.INITIAL); - resolve(component); - }) - .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(stateKey, RETRY_STATE.RELOADED); - 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 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(stateKey, RETRY_STATE.CACHE_CLEARED); - clearWorkboxRecoveryCaches().then(() => window.location.reload()); - } else { - // All recovery options exhausted, the device is offline, or the second failure is - // not a ChunkLoadError: propagate to the error boundary. The flag is left at its - // current advanced state (not reset), so a later failure of this same import does - // not restart the full reload cycle — it either fails fast (already cache-cleared) - // or retries the cache clear once the device is back online. A successful import - // resets the flag to INITIAL. - console.error('Failed to lazily import a React component after all recovery attempts.', error); - reject(error instanceof Error ? error : new Error(String(error))); - } - }); - }); + return retryDynamicImport(componentImport, retryKey); }; export default lazyRetry; diff --git a/src/utils/retryDynamicImport.ts b/src/utils/retryDynamicImport.ts new file mode 100644 index 000000000000..07c8d3bc9377 --- /dev/null +++ b/src/utils/retryDynamicImport.ts @@ -0,0 +1,121 @@ +import clearWorkboxRecoveryCaches from '@libs/clearWorkboxRecoveryCaches'; +import isChunkLoadError from '@libs/isChunkLoadError'; + +import CONST from '@src/CONST'; + +// 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; + +type RetryState = (typeof RETRY_STATE)[keyof typeof RETRY_STATE]; + +/** + * The retry state is scoped per import: a global flag could be reset by one chunk's success + * while another chunk is still failing, restarting that chunk's retry cycle forever. + */ +function getRetryStateKey(retryKey: string): string { + return `${CONST.SESSION_STORAGE_KEYS.RETRY_LAZY_REFRESHED}:${retryKey}`; +} + +/** + * Returns null when sessionStorage is unusable. Attempts then cannot be counted, so callers + * must reject instead of reloading — otherwise every attempt reads the same state and the + * page reloads forever. + */ +function readRetryState(stateKey: string): RetryState | null { + if (typeof sessionStorage === 'undefined') { + return null; + } + + try { + const storedState = sessionStorage.getItem(stateKey); + if (storedState === RETRY_STATE.RELOADED || storedState === RETRY_STATE.CACHE_CLEARED) { + return storedState; + } + return RETRY_STATE.INITIAL; + } catch { + return null; + } +} + +/** Returns false if the write did not stick, in which case the caller must not reload (see readRetryState). */ +function writeRetryState(stateKey: string, state: RetryState): boolean { + if (typeof sessionStorage === 'undefined') { + return false; + } + + try { + sessionStorage.setItem(stateKey, state); + return true; + } catch { + return false; + } +} + +/** + * Attempts a dynamic import with a graduated recovery 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 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 prevents + * destroying the cached app shell that keeps the PWA usable while offline. + * - Anything else (recovery exhausted, offline, non-chunk error, unusable sessionStorage): + * reject so the caller can surface the failure. + * + * On the reload branches the returned promise never settles, because the page is being replaced. + * + * @param moduleImport - A function that returns the promise of a dynamically imported module. + * @param retryKey - A stable identifier unique to this import, used to scope the retry state. + */ +function retryDynamicImport(moduleImport: () => Promise, retryKey: string): Promise { + return new Promise((resolve, reject) => { + const stateKey = getRetryStateKey(retryKey); + const retryState = readRetryState(stateKey); + + const rejectWith = (error: unknown) => reject(error instanceof Error ? error : new Error(String(error))); + + moduleImport() + .then((module) => { + writeRetryState(stateKey, RETRY_STATE.INITIAL); + resolve(module); + }) + .catch((error: unknown) => { + if (retryState === null) { + console.error('Failed to import a module and sessionStorage is unavailable, so it cannot be retried safely.', error); + rejectWith(error); + return; + } + + if (retryState === RETRY_STATE.INITIAL) { + console.error('Failed to import a module, refreshing the page in order to retry the operation.', error); + if (!writeRetryState(stateKey, RETRY_STATE.RELOADED)) { + rejectWith(error); + return; + } + window.location.reload(); + } else if (retryState === RETRY_STATE.RELOADED && isChunkLoadError(error) && navigator.onLine) { + console.error('Failed to import a module after reload, clearing SW caches and reloading.', error); + if (!writeRetryState(stateKey, RETRY_STATE.CACHE_CLEARED)) { + rejectWith(error); + return; + } + clearWorkboxRecoveryCaches().then(() => window.location.reload()); + } else { + // The flag is intentionally left at its advanced state so a later failure of this + // same import fails fast instead of restarting the reload cycle. A successful + // import resets it to INITIAL. + console.error('Failed to import a module after all recovery attempts.', error); + rejectWith(error); + } + }); + }); +} + +export default retryDynamicImport; diff --git a/tests/unit/chunkLoadErrorRecoveryTest.ts b/tests/unit/chunkLoadErrorRecoveryTest.ts index 28e136e92292..326f9a9513aa 100644 --- a/tests/unit/chunkLoadErrorRecoveryTest.ts +++ b/tests/unit/chunkLoadErrorRecoveryTest.ts @@ -18,6 +18,7 @@ import usePageRefresh from '@hooks/usePageRefresh'; import CONST from '@src/CONST'; import lazyRetry from '@src/utils/lazyRetry'; +import retryDynamicImport from '@src/utils/retryDynamicImport'; import type {ComponentType} from 'react'; @@ -213,4 +214,103 @@ describe('ChunkLoadError recovery', () => { expect(reloadMock).not.toHaveBeenCalled(); }); }); + + // Covers non-component dynamic imports (e.g. the locale bundles in IntlStore), which before the + // shared ladder rejected with nothing catching them — the "green screen of death" stuck boot splash. + describe('retryDynamicImport (non-component imports)', () => { + const chunkError = Object.assign(new Error('Loading chunk 9345 failed.'), {name: 'ChunkLoadError'}); + const RETRY_KEY = 'locale:en'; + const stateKey = `${CONST.SESSION_STORAGE_KEYS.RETRY_LAZY_REFRESHED}:${RETRY_KEY}`; + + it('plain-reloads on the first failure without clearing caches', async () => { + sessionStorage.removeItem(stateKey); + const failingImport = jest.fn().mockRejectedValue(chunkError); + + retryDynamicImport(failingImport, RETRY_KEY).catch(() => {}); + await flushMicrotasks(); + + expect(reloadMock).toHaveBeenCalledTimes(1); + expect(mockClearWorkboxRecoveryCaches).not.toHaveBeenCalled(); + expect(callOrder).toEqual(['reload']); + }); + + it('clears SW caches before reloading on the second ChunkLoadError failure when online', async () => { + sessionStorage.setItem(stateKey, 'true'); + jest.spyOn(navigator, 'onLine', 'get').mockReturnValue(true); + const failingImport = jest.fn().mockRejectedValue(chunkError); + + retryDynamicImport(failingImport, RETRY_KEY).catch(() => {}); + await flushMicrotasks(); + + expect(mockClearWorkboxRecoveryCaches).toHaveBeenCalledTimes(1); + expect(reloadMock).toHaveBeenCalledTimes(1); + expect(callOrder).toEqual(['clear', 'reload']); + }); + + it('rejects on the third failure so the caller can surface it instead of reloading forever', async () => { + sessionStorage.setItem(stateKey, 'cache-cleared'); + const failingImport = jest.fn().mockRejectedValue(chunkError); + + await expect(retryDynamicImport(failingImport, RETRY_KEY)).rejects.toThrow('Loading chunk 9345 failed.'); + await flushMicrotasks(); + + expect(mockClearWorkboxRecoveryCaches).not.toHaveBeenCalled(); + expect(reloadMock).not.toHaveBeenCalled(); + }); + + it('rejects without clearing caches on the second failure when offline to preserve the offline shell', async () => { + sessionStorage.setItem(stateKey, 'true'); + jest.spyOn(navigator, 'onLine', 'get').mockReturnValue(false); + const failingImport = jest.fn().mockRejectedValue(chunkError); + + await expect(retryDynamicImport(failingImport, RETRY_KEY)).rejects.toBeDefined(); + await flushMicrotasks(); + + expect(mockClearWorkboxRecoveryCaches).not.toHaveBeenCalled(); + expect(reloadMock).not.toHaveBeenCalled(); + }); + + it('resets the retry state on a successful import', async () => { + sessionStorage.setItem(stateKey, 'true'); + const successfulImport = jest.fn().mockResolvedValue({default: 'translations'}); + + await retryDynamicImport(successfulImport, RETRY_KEY); + await flushMicrotasks(); + + expect(sessionStorage.getItem(stateKey)).toBe('false'); + expect(reloadMock).not.toHaveBeenCalled(); + }); + + // Without the storage guard the retry state can never advance, so every attempt would reload again — an infinite reload loop. + it('rejects instead of reloading when the retry state cannot be read', async () => { + const getItemSpy = jest.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { + throw new Error('SecurityError: sessionStorage is not available'); + }); + const failingImport = jest.fn().mockRejectedValue(chunkError); + + await expect(retryDynamicImport(failingImport, RETRY_KEY)).rejects.toBeDefined(); + await flushMicrotasks(); + + expect(reloadMock).not.toHaveBeenCalled(); + expect(mockClearWorkboxRecoveryCaches).not.toHaveBeenCalled(); + + getItemSpy.mockRestore(); + }); + + it('rejects instead of reloading when the retry state cannot be written', async () => { + sessionStorage.removeItem(stateKey); + const setItemSpy = jest.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new Error('QuotaExceededError'); + }); + const failingImport = jest.fn().mockRejectedValue(chunkError); + + await expect(retryDynamicImport(failingImport, RETRY_KEY)).rejects.toBeDefined(); + await flushMicrotasks(); + + expect(reloadMock).not.toHaveBeenCalled(); + expect(mockClearWorkboxRecoveryCaches).not.toHaveBeenCalled(); + + setItemSpy.mockRestore(); + }); + }); });