Skip to content
Open
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
65 changes: 36 additions & 29 deletions apps/standalone/src/app/routes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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,
})),
Expand Down
1 change: 1 addition & 0 deletions libs/i18n/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
9 changes: 7 additions & 2 deletions libs/ui-components/src/components/common/ErrorBoundary.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -38,7 +38,12 @@ class ErrorBoundary extends React.Component<Props, State> {
}

return hasError ? (
<Alert variant="danger" title={t('Unexpected error occurred')} isInline>
<Alert
variant="danger"
title={t('Unexpected error occurred')}
isInline
actionLinks={<AlertActionLink onClick={() => window.location.reload()}>{t('Reload page')}</AlertActionLink>}
>
{t('Please reload the page and try again.')}
<details>{getErrorMessage(error)}</details>
</Alert>
Expand Down
61 changes: 61 additions & 0 deletions libs/ui-components/src/utils/lazyWithReload.tsx
Original file line number Diff line number Diff line change
@@ -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;
}
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

/**
* 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<React.ComponentType> {
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 <Suspense> in its loading state until the reload takes effect.
return new Promise<{ default: React.ComponentType }>(() => {
/* never resolves */
});
}
throw error;
}),
);
}
Loading