From b70baecd7afe883d23c4c919fa20a935f66336d2 Mon Sep 17 00:00:00 2001 From: Celia Amador Date: Wed, 19 Aug 2026 10:20:00 +0200 Subject: [PATCH 1/3] EDM-5267: Mitigate errors when UI service restarts Made-with: Cursor --- apps/standalone/src/app/routes.tsx | 67 ++++++++++--------- libs/i18n/locales/en/translation.json | 1 + .../src/components/common/ErrorBoundary.tsx | 9 ++- .../src/utils/resettableLazy.tsx | 17 +++++ 4 files changed, 62 insertions(+), 32 deletions(-) create mode 100644 libs/ui-components/src/utils/resettableLazy.tsx diff --git a/apps/standalone/src/app/routes.tsx b/apps/standalone/src/app/routes.tsx index 562a07510..0c460a872 100644 --- a/apps/standalone/src/app/routes.tsx +++ b/apps/standalone/src/app/routes.tsx @@ -15,6 +15,7 @@ import { type RouteObject, RouterProvider, createBrowserRouter, + useLocation, useParams, useRouteError, } from 'react-router-dom'; @@ -24,6 +25,7 @@ import { PathMissingIcon } from '@patternfly/react-icons/dist/js/icons/path-miss import { useDocumentTitle } from '@flightctl/ui-components/src/hooks/useDocumentTitle'; import { useTranslation } from '@flightctl/ui-components/src/hooks/useTranslation'; import ErrorBoundary from '@flightctl/ui-components/src/components/common/ErrorBoundary'; +import { lazyResetter } from '@flightctl/ui-components/src/utils/resettableLazy'; import AppLayout from './components/AppLayout/AppLayout'; import NotFound from './components/AppLayout/NotFound'; @@ -32,90 +34,94 @@ import { AuthContext } from './context/AuthContext'; const APP_TITLE = 'Flight Control'; -const EnrollmentRequestDetails = React.lazy( +const EnrollmentRequestDetails = lazyResetter( () => import( '@flightctl/ui-components/src/components/EnrollmentRequest/EnrollmentRequestDetails/EnrollmentRequestDetails' ), ); -const DevicesPage = React.lazy(() => import('@flightctl/ui-components/src/components/Device/DevicesPage/DevicesPage')); -const DeviceDetails = React.lazy( +const DevicesPage = lazyResetter( + () => import('@flightctl/ui-components/src/components/Device/DevicesPage/DevicesPage'), +); +const DeviceDetails = lazyResetter( () => import('@flightctl/ui-components/src/components/Device/DeviceDetails/DeviceDetailsPage'), ); -const EditDeviceWizard = React.lazy( +const EditDeviceWizard = lazyResetter( () => import('@flightctl/ui-components/src/components/Device/EditDeviceWizard/EditDeviceWizard'), ); -const CreateRepository = React.lazy( +const CreateRepository = lazyResetter( () => import('@flightctl/ui-components/src/components/Repository/CreateRepository/CreateRepository'), ); -const RepositoryList = React.lazy(() => import('@flightctl/ui-components/src/components/Repository/RepositoryList')); -const RepositoryDetails = React.lazy( +const RepositoryList = lazyResetter(() => import('@flightctl/ui-components/src/components/Repository/RepositoryList')); +const RepositoryDetails = lazyResetter( () => import('@flightctl/ui-components/src/components/Repository/RepositoryDetails/RepositoryDetails'), ); -const ResourceSyncToRepository = React.lazy( +const ResourceSyncToRepository = lazyResetter( () => import('@flightctl/ui-components/src/components/ResourceSync/ResourceSyncToRepository'), ); -const ImportFleetWizard = React.lazy( +const ImportFleetWizard = lazyResetter( () => import('@flightctl/ui-components/src/components/Fleet/ImportFleetWizard/ImportFleetWizard'), ); -const CreateFleetWizard = React.lazy( +const CreateFleetWizard = lazyResetter( () => import('@flightctl/ui-components/src/components/Fleet/CreateFleet/CreateFleetWizard'), ); -const FleetsPage = React.lazy(() => import('@flightctl/ui-components/src/components/Fleet/FleetsPage')); -const FleetDetails = React.lazy( +const FleetsPage = lazyResetter(() => import('@flightctl/ui-components/src/components/Fleet/FleetsPage')); +const FleetDetails = lazyResetter( () => import('@flightctl/ui-components/src/components/Fleet/FleetDetails/FleetDetailsPage'), ); -const OverviewPage = React.lazy(() => import('@flightctl/ui-components/src/components/OverviewPage/OverviewPage')); -const SecurityOverviewPage = React.lazy( +const OverviewPage = lazyResetter(() => import('@flightctl/ui-components/src/components/OverviewPage/OverviewPage')); +const SecurityOverviewPage = lazyResetter( () => import('@flightctl/ui-components/src/components/SecurityOverview/SecurityOverviewPage'), ); -const PendingEnrollmentRequestsBadge = React.lazy( +const PendingEnrollmentRequestsBadge = lazyResetter( () => import('@flightctl/ui-components/src/components/EnrollmentRequest/PendingEnrollmentRequestsBadge'), ); -const CommandLineToolsPage = React.lazy( +const CommandLineToolsPage = lazyResetter( () => import('@flightctl/ui-components/src/components/Masthead/CommandLineToolsPage'), ); -const AuthProvidersPage = React.lazy( +const AuthProvidersPage = lazyResetter( () => import('@flightctl/ui-components/src/components/AuthProvider/AuthProvidersPage'), ); -const CreateAuthProvider = React.lazy( +const CreateAuthProvider = lazyResetter( () => import('@flightctl/ui-components/src/components/AuthProvider/CreateAuthProvider/CreateAuthProvider'), ); -const AuthProviderDetails = React.lazy( +const AuthProviderDetails = lazyResetter( () => import('@flightctl/ui-components/src/components/AuthProvider/AuthProviderDetails/AuthProviderDetails'), ); -const ImageBuildsPage = React.lazy(() => import('@flightctl/ui-components/src/components/ImageBuilds/ImageBuildsPage')); -const ImageBuildDetails = React.lazy( +const ImageBuildsPage = lazyResetter( + () => import('@flightctl/ui-components/src/components/ImageBuilds/ImageBuildsPage'), +); +const ImageBuildDetails = lazyResetter( () => import('@flightctl/ui-components/src/components/ImageBuilds/ImageBuildDetails/ImageBuildDetailsPage'), ); -const CreateImageBuildWizard = React.lazy( +const CreateImageBuildWizard = lazyResetter( () => import('@flightctl/ui-components/src/components/ImageBuilds/CreateImageBuildWizard/CreateImageBuildWizard'), ); -const NewVersionImageBuildWizard = React.lazy( +const NewVersionImageBuildWizard = lazyResetter( () => import('@flightctl/ui-components/src/components/ImageBuilds/NewVersionImageBuildWizard/NewVersionImageBuildWizard'), ); -const CatalogPage = React.lazy(() => import('@flightctl/ui-components/src/components/Catalog/CatalogPage')); -const ImportCatalogWizard = React.lazy( +const CatalogPage = lazyResetter(() => import('@flightctl/ui-components/src/components/Catalog/CatalogPage')); +const ImportCatalogWizard = lazyResetter( () => import('@flightctl/ui-components/src/components/Catalog/ImportCatalogWizard/ImportCatalogWizard'), ); -const AddCatalogItemWizard = React.lazy( +const AddCatalogItemWizard = lazyResetter( () => import('@flightctl/ui-components/src/components/Catalog/AddCatalogItemWizard/AddCatalogItemWizard'), ); -const CatalogInstallWizard = React.lazy( +const CatalogInstallWizard = lazyResetter( () => import('@flightctl/ui-components/src/components/Catalog/InstallWizard/InstallWizard'), ); -const CatalogEditFleetWizard = React.lazy(() => +const CatalogEditFleetWizard = lazyResetter(() => import('@flightctl/ui-components/src/components/Catalog/EditWizard/EditWizard').then((module) => ({ default: module.EditFleetWizard, })), ); -const CatalogEditDeviceWizard = React.lazy(() => +const CatalogEditDeviceWizard = lazyResetter(() => import('@flightctl/ui-components/src/components/Catalog/EditWizard/EditWizard').then((module) => ({ default: module.EditDeviceWizard, })), @@ -144,6 +150,7 @@ const ErrorPage = () => { }; const TitledRoute = ({ title, children }: React.PropsWithChildren<{ title: string }>) => { + const location = useLocation(); useDocumentTitle(`${APP_TITLE} | ${title}`); return ( } > - {children} + {children} ); }; diff --git a/libs/i18n/locales/en/translation.json b/libs/i18n/locales/en/translation.json index 5bcd90012..632eaf2ba 100644 --- a/libs/i18n/locales/en/translation.json +++ b/libs/i18n/locales/en/translation.json @@ -652,6 +652,7 @@ "New label": "New label", "Add label": "Add label", "Unexpected error occurred": "Unexpected error occurred", + "Reload page": "Reload page", "Please reload the page and try again.": "Please reload the page and try again.", "Resize panel": "Resize panel", "This managed label matches a property reported by the device, but its value differs. The label could be out of sync if its value derives from that property.": "This managed label matches a property reported by the device, but its value differs. The label could be out of sync if its value derives from that property.", diff --git a/libs/ui-components/src/components/common/ErrorBoundary.tsx b/libs/ui-components/src/components/common/ErrorBoundary.tsx index 2b9d207a4..937015aa0 100644 --- a/libs/ui-components/src/components/common/ErrorBoundary.tsx +++ b/libs/ui-components/src/components/common/ErrorBoundary.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { type TFunction, withTranslation } from 'react-i18next'; -import { Alert } from '@patternfly/react-core'; +import { Alert, AlertActionLink } from '@patternfly/react-core'; import { getErrorMessage } from '../../utils/error'; @@ -38,7 +38,12 @@ class ErrorBoundary extends React.Component { } return hasError ? ( - + window.location.reload()}>{t('Reload page')}} + > {t('Please reload the page and try again.')}
{getErrorMessage(error)}
diff --git a/libs/ui-components/src/utils/resettableLazy.tsx b/libs/ui-components/src/utils/resettableLazy.tsx new file mode 100644 index 000000000..4ed81ea6a --- /dev/null +++ b/libs/ui-components/src/utils/resettableLazy.tsx @@ -0,0 +1,17 @@ +import * as React from 'react'; +import { useLocation } from 'react-router-dom'; + +/** + * Wrapper around React.lazy that creates a fresh lazy loader on each router navigation. + * Prevents the lazy loader from being cached by React.lazy + */ +export function lazyResetter(importer: () => Promise<{ default: React.ComponentType }>): React.FC { + function ResettableLazyPage() { + const { key: locationKey } = useLocation(); + // eslint-disable-next-line react-hooks/exhaustive-deps -- intentional: recreate lazy when location.key changes + const Component = React.useMemo(() => React.lazy(importer), [locationKey]); + return ; + } + + return ResettableLazyPage; +} From 80cf530359d13ba519b954c34c98b03ea51a139c Mon Sep 17 00:00:00 2001 From: Ashraf Masarwa Date: Thu, 29 Oct 2026 14:32:25 +0200 Subject: [PATCH 2/3] Address review: auto-reload on stale chunk instead of remounting on nav Replace lazyResetter (recreated React.lazy on every location.key change, remounting pages on tab switches and filter changes) with lazyWithReload, which creates the lazy once and, on a chunk-load failure, triggers a single guarded full page reload. A full reload is the only reliable recovery for stale chunks after a UI redeploy since the hashed bundle URL no longer exists. Also drop the key={location.key} remount from the TitledRoute ErrorBoundary, which had the same per-navigation remount problem. --- apps/standalone/src/app/routes.tsx | 66 +++++++++---------- .../src/utils/lazyWithReload.tsx | 56 ++++++++++++++++ .../src/utils/resettableLazy.tsx | 17 ----- 3 files changed, 89 insertions(+), 50 deletions(-) create mode 100644 libs/ui-components/src/utils/lazyWithReload.tsx delete mode 100644 libs/ui-components/src/utils/resettableLazy.tsx diff --git a/apps/standalone/src/app/routes.tsx b/apps/standalone/src/app/routes.tsx index 0c460a872..31fb62578 100644 --- a/apps/standalone/src/app/routes.tsx +++ b/apps/standalone/src/app/routes.tsx @@ -15,7 +15,6 @@ import { type RouteObject, RouterProvider, createBrowserRouter, - useLocation, useParams, useRouteError, } from 'react-router-dom'; @@ -25,7 +24,7 @@ import { PathMissingIcon } from '@patternfly/react-icons/dist/js/icons/path-miss import { useDocumentTitle } from '@flightctl/ui-components/src/hooks/useDocumentTitle'; import { useTranslation } from '@flightctl/ui-components/src/hooks/useTranslation'; import ErrorBoundary from '@flightctl/ui-components/src/components/common/ErrorBoundary'; -import { lazyResetter } from '@flightctl/ui-components/src/utils/resettableLazy'; +import { lazyWithReload } from '@flightctl/ui-components/src/utils/lazyWithReload'; import AppLayout from './components/AppLayout/AppLayout'; import NotFound from './components/AppLayout/NotFound'; @@ -34,94 +33,96 @@ import { AuthContext } from './context/AuthContext'; const APP_TITLE = 'Flight Control'; -const EnrollmentRequestDetails = lazyResetter( +const EnrollmentRequestDetails = lazyWithReload( () => import( '@flightctl/ui-components/src/components/EnrollmentRequest/EnrollmentRequestDetails/EnrollmentRequestDetails' ), ); -const DevicesPage = lazyResetter( +const DevicesPage = lazyWithReload( () => import('@flightctl/ui-components/src/components/Device/DevicesPage/DevicesPage'), ); -const DeviceDetails = lazyResetter( +const DeviceDetails = lazyWithReload( () => import('@flightctl/ui-components/src/components/Device/DeviceDetails/DeviceDetailsPage'), ); -const EditDeviceWizard = lazyResetter( +const EditDeviceWizard = lazyWithReload( () => import('@flightctl/ui-components/src/components/Device/EditDeviceWizard/EditDeviceWizard'), ); -const CreateRepository = lazyResetter( +const CreateRepository = lazyWithReload( () => import('@flightctl/ui-components/src/components/Repository/CreateRepository/CreateRepository'), ); -const RepositoryList = lazyResetter(() => import('@flightctl/ui-components/src/components/Repository/RepositoryList')); -const RepositoryDetails = lazyResetter( +const RepositoryList = lazyWithReload( + () => import('@flightctl/ui-components/src/components/Repository/RepositoryList'), +); +const RepositoryDetails = lazyWithReload( () => import('@flightctl/ui-components/src/components/Repository/RepositoryDetails/RepositoryDetails'), ); -const ResourceSyncToRepository = lazyResetter( +const ResourceSyncToRepository = lazyWithReload( () => import('@flightctl/ui-components/src/components/ResourceSync/ResourceSyncToRepository'), ); -const ImportFleetWizard = lazyResetter( +const ImportFleetWizard = lazyWithReload( () => import('@flightctl/ui-components/src/components/Fleet/ImportFleetWizard/ImportFleetWizard'), ); -const CreateFleetWizard = lazyResetter( +const CreateFleetWizard = lazyWithReload( () => import('@flightctl/ui-components/src/components/Fleet/CreateFleet/CreateFleetWizard'), ); -const FleetsPage = lazyResetter(() => import('@flightctl/ui-components/src/components/Fleet/FleetsPage')); -const FleetDetails = lazyResetter( +const FleetsPage = lazyWithReload(() => import('@flightctl/ui-components/src/components/Fleet/FleetsPage')); +const FleetDetails = lazyWithReload( () => import('@flightctl/ui-components/src/components/Fleet/FleetDetails/FleetDetailsPage'), ); -const OverviewPage = lazyResetter(() => import('@flightctl/ui-components/src/components/OverviewPage/OverviewPage')); -const SecurityOverviewPage = lazyResetter( +const OverviewPage = lazyWithReload(() => import('@flightctl/ui-components/src/components/OverviewPage/OverviewPage')); +const SecurityOverviewPage = lazyWithReload( () => import('@flightctl/ui-components/src/components/SecurityOverview/SecurityOverviewPage'), ); -const PendingEnrollmentRequestsBadge = lazyResetter( +const PendingEnrollmentRequestsBadge = lazyWithReload( () => import('@flightctl/ui-components/src/components/EnrollmentRequest/PendingEnrollmentRequestsBadge'), ); -const CommandLineToolsPage = lazyResetter( +const CommandLineToolsPage = lazyWithReload( () => import('@flightctl/ui-components/src/components/Masthead/CommandLineToolsPage'), ); -const AuthProvidersPage = lazyResetter( +const AuthProvidersPage = lazyWithReload( () => import('@flightctl/ui-components/src/components/AuthProvider/AuthProvidersPage'), ); -const CreateAuthProvider = lazyResetter( +const CreateAuthProvider = lazyWithReload( () => import('@flightctl/ui-components/src/components/AuthProvider/CreateAuthProvider/CreateAuthProvider'), ); -const AuthProviderDetails = lazyResetter( +const AuthProviderDetails = lazyWithReload( () => import('@flightctl/ui-components/src/components/AuthProvider/AuthProviderDetails/AuthProviderDetails'), ); -const ImageBuildsPage = lazyResetter( +const ImageBuildsPage = lazyWithReload( () => import('@flightctl/ui-components/src/components/ImageBuilds/ImageBuildsPage'), ); -const ImageBuildDetails = lazyResetter( +const ImageBuildDetails = lazyWithReload( () => import('@flightctl/ui-components/src/components/ImageBuilds/ImageBuildDetails/ImageBuildDetailsPage'), ); -const CreateImageBuildWizard = lazyResetter( +const CreateImageBuildWizard = lazyWithReload( () => import('@flightctl/ui-components/src/components/ImageBuilds/CreateImageBuildWizard/CreateImageBuildWizard'), ); -const NewVersionImageBuildWizard = lazyResetter( +const NewVersionImageBuildWizard = lazyWithReload( () => import('@flightctl/ui-components/src/components/ImageBuilds/NewVersionImageBuildWizard/NewVersionImageBuildWizard'), ); -const CatalogPage = lazyResetter(() => import('@flightctl/ui-components/src/components/Catalog/CatalogPage')); -const ImportCatalogWizard = lazyResetter( +const CatalogPage = lazyWithReload(() => import('@flightctl/ui-components/src/components/Catalog/CatalogPage')); +const ImportCatalogWizard = lazyWithReload( () => import('@flightctl/ui-components/src/components/Catalog/ImportCatalogWizard/ImportCatalogWizard'), ); -const AddCatalogItemWizard = lazyResetter( +const AddCatalogItemWizard = lazyWithReload( () => import('@flightctl/ui-components/src/components/Catalog/AddCatalogItemWizard/AddCatalogItemWizard'), ); -const CatalogInstallWizard = lazyResetter( +const CatalogInstallWizard = lazyWithReload( () => import('@flightctl/ui-components/src/components/Catalog/InstallWizard/InstallWizard'), ); -const CatalogEditFleetWizard = lazyResetter(() => +const CatalogEditFleetWizard = lazyWithReload(() => import('@flightctl/ui-components/src/components/Catalog/EditWizard/EditWizard').then((module) => ({ default: module.EditFleetWizard, })), ); -const CatalogEditDeviceWizard = lazyResetter(() => +const CatalogEditDeviceWizard = lazyWithReload(() => import('@flightctl/ui-components/src/components/Catalog/EditWizard/EditWizard').then((module) => ({ default: module.EditDeviceWizard, })), @@ -150,7 +151,6 @@ const ErrorPage = () => { }; const TitledRoute = ({ title, children }: React.PropsWithChildren<{ title: string }>) => { - const location = useLocation(); useDocumentTitle(`${APP_TITLE} | ${title}`); return ( } > - {children} + {children} ); }; diff --git a/libs/ui-components/src/utils/lazyWithReload.tsx b/libs/ui-components/src/utils/lazyWithReload.tsx new file mode 100644 index 000000000..9fda353c1 --- /dev/null +++ b/libs/ui-components/src/utils/lazyWithReload.tsx @@ -0,0 +1,56 @@ +import * as React from 'react'; + +const RELOAD_TIMESTAMP_KEY = 'flightctl-chunk-reload-ts'; + +/** + * Only auto-reload once within this window. If a chunk import keeps failing right after a + * reload the cause is not a stale chunk (which a reload fixes), so we stop reloading and let + * the ErrorBoundary surface the error instead of looping. + */ +const RELOAD_WINDOW_MS = 10_000; + +/** + * Returns true at most once per RELOAD_WINDOW_MS. The timestamp is kept in sessionStorage so + * the guard survives the full page reload it triggers. If sessionStorage is unavailable we + * return false, erring towards the ErrorBoundary rather than risking a reload loop. + */ +export const shouldReloadOnce = (now: number = Date.now()): boolean => { + try { + const last = window.sessionStorage.getItem(RELOAD_TIMESTAMP_KEY); + if (last && now - Number(last) < RELOAD_WINDOW_MS) { + return false; + } + window.sessionStorage.setItem(RELOAD_TIMESTAMP_KEY, String(now)); + return true; + } catch { + return false; + } +}; + +/** + * Wrapper around React.lazy that recovers from stale chunk-load failures after a UI + * redeployment. When the UI service restarts, the hashed bundles referenced by an already + * open tab no longer exist on the server, so the dynamic import() fails (404/504). React.lazy + * caches that rejection, which makes the error "sticky" across navigation. + * + * Reloading the same hashed URL cannot succeed (the file is gone); only a full page reload + * fetches a fresh index.html with the new chunk names. So on a load failure we trigger a + * single guarded full page reload. If it still fails right after reloading, the error is + * rethrown and the ErrorBoundary shows its "Reload page" action instead of looping. + */ +export function lazyWithReload( + importer: () => Promise<{ default: React.ComponentType }>, +): React.LazyExoticComponent { + return React.lazy(() => + importer().catch((error: unknown) => { + if (shouldReloadOnce()) { + window.location.reload(); + // Keep in its loading state until the reload takes effect. + return new Promise<{ default: React.ComponentType }>(() => { + /* never resolves */ + }); + } + throw error; + }), + ); +} diff --git a/libs/ui-components/src/utils/resettableLazy.tsx b/libs/ui-components/src/utils/resettableLazy.tsx deleted file mode 100644 index 4ed81ea6a..000000000 --- a/libs/ui-components/src/utils/resettableLazy.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import * as React from 'react'; -import { useLocation } from 'react-router-dom'; - -/** - * Wrapper around React.lazy that creates a fresh lazy loader on each router navigation. - * Prevents the lazy loader from being cached by React.lazy - */ -export function lazyResetter(importer: () => Promise<{ default: React.ComponentType }>): React.FC { - function ResettableLazyPage() { - const { key: locationKey } = useLocation(); - // eslint-disable-next-line react-hooks/exhaustive-deps -- intentional: recreate lazy when location.key changes - const Component = React.useMemo(() => React.lazy(importer), [locationKey]); - return ; - } - - return ResettableLazyPage; -} From 6d5956a0c63936e1a084647f62235dbaf9ed8b40 Mon Sep 17 00:00:00 2001 From: Ashraf Masarwa Date: Thu, 29 Oct 2026 15:02:19 +0200 Subject: [PATCH 3/3] Address CodeRabbit: scope auto-reload to chunk-load errors, one reload per tab - Only reload when the import rejects with a chunk-load failure (ChunkLoadError or a "Loading chunk ... failed" message); other rejections rethrow so a genuine module-eval bug is surfaced instead of hidden behind a reload. - Replace the 10s timestamp window in shouldReloadOnce with a write-once per-tab sessionStorage marker, capping auto-recovery at a single reload per tab. This is loop-safe even when several lazy components (e.g. the masthead badge plus the route) fail on the same screen; a clear-on-success would reintroduce a loop there. --- .../src/utils/lazyWithReload.tsx | 43 +++++++++++-------- 1 file changed, 24 insertions(+), 19 deletions(-) diff --git a/libs/ui-components/src/utils/lazyWithReload.tsx b/libs/ui-components/src/utils/lazyWithReload.tsx index 9fda353c1..3ecb68cc4 100644 --- a/libs/ui-components/src/utils/lazyWithReload.tsx +++ b/libs/ui-components/src/utils/lazyWithReload.tsx @@ -1,32 +1,35 @@ import * as React from 'react'; -const RELOAD_TIMESTAMP_KEY = 'flightctl-chunk-reload-ts'; +const RELOAD_ATTEMPTED_KEY = 'flightctl-chunk-reload-attempted'; /** - * Only auto-reload once within this window. If a chunk import keeps failing right after a - * reload the cause is not a stale chunk (which a reload fixes), so we stop reloading and let - * the ErrorBoundary surface the error instead of looping. + * Returns true only the first time it is called within a tab, recording a marker in + * sessionStorage so the decision survives the full page reload it triggers. Every later call + * returns false, capping auto-recovery at a single reload per tab and preventing reload loops + * (several lazy components can fail on the same screen). If sessionStorage is unavailable we + * return false, erring towards the ErrorBoundary rather than risking a loop. */ -const RELOAD_WINDOW_MS = 10_000; - -/** - * Returns true at most once per RELOAD_WINDOW_MS. The timestamp is kept in sessionStorage so - * the guard survives the full page reload it triggers. If sessionStorage is unavailable we - * return false, erring towards the ErrorBoundary rather than risking a reload loop. - */ -export const shouldReloadOnce = (now: number = Date.now()): boolean => { +export const shouldReloadOnce = (): boolean => { try { - const last = window.sessionStorage.getItem(RELOAD_TIMESTAMP_KEY); - if (last && now - Number(last) < RELOAD_WINDOW_MS) { + if (window.sessionStorage.getItem(RELOAD_ATTEMPTED_KEY)) { return false; } - window.sessionStorage.setItem(RELOAD_TIMESTAMP_KEY, String(now)); + window.sessionStorage.setItem(RELOAD_ATTEMPTED_KEY, 'true'); return true; } catch { return false; } }; +/** + * Detects a chunk-load failure, i.e. the hashed bundle could not be fetched (404/504 after a + * redeploy). webpack throws an Error named "ChunkLoadError"; we also match the message to cover + * CSS chunks and webpack version differences. Any other rejection (e.g. an error thrown while + * the module evaluates) is a real bug that a reload would only hide, so it is left alone. + */ +const isChunkLoadError = (error: unknown): boolean => + error instanceof Error && (error.name === 'ChunkLoadError' || /Loading (CSS )?chunk .* failed/i.test(error.message)); + /** * Wrapper around React.lazy that recovers from stale chunk-load failures after a UI * redeployment. When the UI service restarts, the hashed bundles referenced by an already @@ -34,16 +37,18 @@ export const shouldReloadOnce = (now: number = Date.now()): boolean => { * caches that rejection, which makes the error "sticky" across navigation. * * Reloading the same hashed URL cannot succeed (the file is gone); only a full page reload - * fetches a fresh index.html with the new chunk names. So on a load failure we trigger a - * single guarded full page reload. If it still fails right after reloading, the error is - * rethrown and the ErrorBoundary shows its "Reload page" action instead of looping. + * fetches a fresh index.html with the new chunk names. So on a chunk-load failure we trigger a + * single guarded full page reload. If the import still fails right after reloading, the error + * is rethrown and the ErrorBoundary shows its "Reload page" action instead of looping. */ export function lazyWithReload( importer: () => Promise<{ default: React.ComponentType }>, ): React.LazyExoticComponent { return React.lazy(() => importer().catch((error: unknown) => { - if (shouldReloadOnce()) { + // Check the error type before shouldReloadOnce() so non-chunk errors don't consume the + // one-shot reload marker. + if (isChunkLoadError(error) && shouldReloadOnce()) { window.location.reload(); // Keep in its loading state until the reload takes effect. return new Promise<{ default: React.ComponentType }>(() => {