diff --git a/apps/standalone/src/app/routes.tsx b/apps/standalone/src/app/routes.tsx index 562a07510..31fb62578 100644 --- a/apps/standalone/src/app/routes.tsx +++ b/apps/standalone/src/app/routes.tsx @@ -24,6 +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 { lazyWithReload } from '@flightctl/ui-components/src/utils/lazyWithReload'; import AppLayout from './components/AppLayout/AppLayout'; import NotFound from './components/AppLayout/NotFound'; @@ -32,90 +33,96 @@ import { AuthContext } from './context/AuthContext'; const APP_TITLE = 'Flight Control'; -const EnrollmentRequestDetails = React.lazy( +const EnrollmentRequestDetails = lazyWithReload( () => 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 = lazyWithReload( + () => import('@flightctl/ui-components/src/components/Device/DevicesPage/DevicesPage'), +); +const DeviceDetails = lazyWithReload( () => import('@flightctl/ui-components/src/components/Device/DeviceDetails/DeviceDetailsPage'), ); -const EditDeviceWizard = React.lazy( +const EditDeviceWizard = lazyWithReload( () => import('@flightctl/ui-components/src/components/Device/EditDeviceWizard/EditDeviceWizard'), ); -const CreateRepository = React.lazy( +const CreateRepository = lazyWithReload( () => 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 = lazyWithReload( + () => import('@flightctl/ui-components/src/components/Repository/RepositoryList'), +); +const RepositoryDetails = lazyWithReload( () => import('@flightctl/ui-components/src/components/Repository/RepositoryDetails/RepositoryDetails'), ); -const ResourceSyncToRepository = React.lazy( +const ResourceSyncToRepository = lazyWithReload( () => import('@flightctl/ui-components/src/components/ResourceSync/ResourceSyncToRepository'), ); -const ImportFleetWizard = React.lazy( +const ImportFleetWizard = lazyWithReload( () => import('@flightctl/ui-components/src/components/Fleet/ImportFleetWizard/ImportFleetWizard'), ); -const CreateFleetWizard = React.lazy( +const CreateFleetWizard = lazyWithReload( () => 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 = lazyWithReload(() => import('@flightctl/ui-components/src/components/Fleet/FleetsPage')); +const FleetDetails = lazyWithReload( () => 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 = lazyWithReload(() => import('@flightctl/ui-components/src/components/OverviewPage/OverviewPage')); +const SecurityOverviewPage = lazyWithReload( () => import('@flightctl/ui-components/src/components/SecurityOverview/SecurityOverviewPage'), ); -const PendingEnrollmentRequestsBadge = React.lazy( +const PendingEnrollmentRequestsBadge = lazyWithReload( () => import('@flightctl/ui-components/src/components/EnrollmentRequest/PendingEnrollmentRequestsBadge'), ); -const CommandLineToolsPage = React.lazy( +const CommandLineToolsPage = lazyWithReload( () => import('@flightctl/ui-components/src/components/Masthead/CommandLineToolsPage'), ); -const AuthProvidersPage = React.lazy( +const AuthProvidersPage = lazyWithReload( () => import('@flightctl/ui-components/src/components/AuthProvider/AuthProvidersPage'), ); -const CreateAuthProvider = React.lazy( +const CreateAuthProvider = lazyWithReload( () => import('@flightctl/ui-components/src/components/AuthProvider/CreateAuthProvider/CreateAuthProvider'), ); -const AuthProviderDetails = React.lazy( +const AuthProviderDetails = lazyWithReload( () => 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 = lazyWithReload( + () => import('@flightctl/ui-components/src/components/ImageBuilds/ImageBuildsPage'), +); +const ImageBuildDetails = lazyWithReload( () => import('@flightctl/ui-components/src/components/ImageBuilds/ImageBuildDetails/ImageBuildDetailsPage'), ); -const CreateImageBuildWizard = React.lazy( +const CreateImageBuildWizard = lazyWithReload( () => import('@flightctl/ui-components/src/components/ImageBuilds/CreateImageBuildWizard/CreateImageBuildWizard'), ); -const NewVersionImageBuildWizard = React.lazy( +const NewVersionImageBuildWizard = lazyWithReload( () => 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 = lazyWithReload(() => import('@flightctl/ui-components/src/components/Catalog/CatalogPage')); +const ImportCatalogWizard = lazyWithReload( () => import('@flightctl/ui-components/src/components/Catalog/ImportCatalogWizard/ImportCatalogWizard'), ); -const AddCatalogItemWizard = React.lazy( +const AddCatalogItemWizard = lazyWithReload( () => import('@flightctl/ui-components/src/components/Catalog/AddCatalogItemWizard/AddCatalogItemWizard'), ); -const CatalogInstallWizard = React.lazy( +const CatalogInstallWizard = lazyWithReload( () => import('@flightctl/ui-components/src/components/Catalog/InstallWizard/InstallWizard'), ); -const CatalogEditFleetWizard = React.lazy(() => +const CatalogEditFleetWizard = lazyWithReload(() => import('@flightctl/ui-components/src/components/Catalog/EditWizard/EditWizard').then((module) => ({ default: module.EditFleetWizard, })), ); -const CatalogEditDeviceWizard = React.lazy(() => +const CatalogEditDeviceWizard = lazyWithReload(() => import('@flightctl/ui-components/src/components/Catalog/EditWizard/EditWizard').then((module) => ({ default: module.EditDeviceWizard, })), 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/lazyWithReload.tsx b/libs/ui-components/src/utils/lazyWithReload.tsx new file mode 100644 index 000000000..3ecb68cc4 --- /dev/null +++ b/libs/ui-components/src/utils/lazyWithReload.tsx @@ -0,0 +1,61 @@ +import * as React from 'react'; + +const RELOAD_ATTEMPTED_KEY = 'flightctl-chunk-reload-attempted'; + +/** + * 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. + */ +export const shouldReloadOnce = (): boolean => { + try { + if (window.sessionStorage.getItem(RELOAD_ATTEMPTED_KEY)) { + return false; + } + 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 + * 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 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) => { + // 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 }>(() => { + /* never resolves */ + }); + } + throw error; + }), + ); +}