Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/CONST/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 24 additions & 8 deletions src/languages/IntlStore.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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;

Expand Down Expand Up @@ -231,23 +236,34 @@ 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
const dateUtilsLocale = this.dateUtilsCache.get(locale);
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},
});
});
}

Expand Down
82 changes: 6 additions & 76 deletions src/utils/lazyRetry.ts
Original file line number Diff line number Diff line change
@@ -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<T> = Promise<{default: T}>;
type ComponentImport<T> = () => Import<T>;

// 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 <T extends ComponentType<any>>(componentImport: ComponentImport<T>, retryKey: string): Import<T> {
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;
121 changes: 121 additions & 0 deletions src/utils/retryDynamicImport.ts
Original file line number Diff line number Diff line change
@@ -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<T>(moduleImport: () => Promise<T>, retryKey: string): Promise<T> {
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;
Loading
Loading