Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
3 changes: 1 addition & 2 deletions src/components/LazyModalSlot.tsx
Original file line number Diff line number Diff line change
@@ -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;

Expand Down
11 changes: 10 additions & 1 deletion src/hooks/usePageRefresh/index.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -16,8 +17,16 @@ const usePageRefresh: UsePageRefresh = () => {
return;
}

window.location.reload();
sessionStorage.removeItem(CONST.SESSION_STORAGE_KEYS.LAST_REFRESH_TIMESTAMP);
if (isChunkLoadError && navigator.onLine) {
// The error page is shown after lazyRetry has already done a plain reload and it did
// not fix the problem. When online, clear the service worker cache so the next load
// fetches a fresh app shell from the CDN. When offline we must not clear it: the
// cached shell is the only thing keeping the PWA usable until connectivity returns.
clearWorkboxRecoveryCaches().then(() => window.location.reload());
} else {
window.location.reload();
}
};
};

Expand Down
4 changes: 2 additions & 2 deletions src/libs/Navigation/AppNavigator/AppNavigator.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import React, {lazy, memo, Suspense} from 'react';
import lazyRetry from '@src/utils/lazyRetry';

const AuthScreens = lazy(() => lazyRetry(() => import(/* webpackChunkName: "authScreens.prefetch" */ './AuthScreens')));
const PublicScreens = lazy(() => lazyRetry(() => import(/* webpackMode: "eager" */ './PublicScreens')));
const AuthScreens = lazy(() => lazyRetry(() => import(/* webpackChunkName: "authScreens.prefetch" */ './AuthScreens'), 'authScreens'));
const PublicScreens = lazy(() => lazyRetry(() => import(/* webpackMode: "eager" */ './PublicScreens'), 'publicScreens'));

type AppNavigatorProps = {
/** If we have an authToken this is true */
Expand Down
2 changes: 1 addition & 1 deletion src/libs/Navigation/AppNavigator/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import React, {lazy, Suspense} from 'react';
import type {ComponentProps} from 'react';
import lazyRetry from '@src/utils/lazyRetry';

const AppNavigator = lazy(() => lazyRetry(() => import(/* webpackChunkName: "appNavigator.prefetch" */ './AppNavigator')));
const AppNavigator = lazy(() => lazyRetry(() => import(/* webpackChunkName: "appNavigator.prefetch" */ './AppNavigator'), 'appNavigator'));

function AppNavigatorLoader({authenticated}: ComponentProps<typeof AppNavigator>) {
return (
Expand Down
4 changes: 2 additions & 2 deletions src/libs/clearWorkboxRecoveryCaches/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
// Normally platform-specific behaviour is achieved with .native.ts / .ts file pairs.
Expand Down
14 changes: 14 additions & 0 deletions src/libs/isChunkLoadError.ts
Original file line number Diff line number Diff line change
@@ -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;
5 changes: 3 additions & 2 deletions src/pages/ErrorPage/GenericErrorPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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']);

Expand Down Expand Up @@ -63,7 +64,7 @@ function GenericErrorPage({error}: {error?: Error}) {
success
text={translate('genericErrorPage.refresh')}
style={styles.mr3}
onPress={() => refreshPage(isChunkLoadError)}
onPress={() => refreshPage(chunkLoadError)}
/>
{isAuthenticated && (
<Button
Expand Down
82 changes: 63 additions & 19 deletions src/utils/lazyRetry.ts
Original file line number Diff line number Diff line change
@@ -1,39 +1,83 @@
import type {ComponentType} from 'react';
import clearWorkboxRecoveryCaches from '@libs/clearWorkboxRecoveryCaches';
import isChunkLoadError from '@libs/isChunkLoadError';
import CONST from '@src/CONST';

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;

/**
* 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.
* 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.
* @returns A promise that resolves to the imported component or rejects with an error after a retry attempt.
* @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.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const lazyRetry = function <T extends ComponentType<any>>(componentImport: ComponentImport<T>): Import<T> {
// 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) => {
// 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;
const stateKey = getRetryStateKey(retryKey);
const retryState = sessionStorage.getItem(stateKey) ?? 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(stateKey, RETRY_STATE.INITIAL);
resolve(component);
})
.catch((component: ComponentImport<T>) => {
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
.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 {
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);
// 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)));
}
});
});
Expand Down
Loading
Loading