core-plugin-api: avoid using BootErrorPage

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2025-03-12 12:32:14 +01:00
parent 947f2622e1
commit 327d21e219
3 changed files with 97 additions and 50 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/core-plugin-api': patch
---
Failure to lazy load an extension will now always result in an error being thrown to be forwarded to error boundaries, rather than being rendered using the `BootErrorPage` app component.
@@ -15,7 +15,7 @@
*/
import { withLogCollector } from '@backstage/test-utils';
import { render, screen } from '@testing-library/react';
import { act, render, screen } from '@testing-library/react';
import React from 'react';
import { useAnalyticsContext } from '../analytics/AnalyticsContext';
import { useApp, ErrorBoundaryFallbackProps } from '../app';
@@ -27,6 +27,7 @@ import {
createReactExtension,
createRoutableExtension,
} from './extensions';
import { ForwardedError } from '@backstage/errors';
jest.mock('../app');
@@ -120,6 +121,48 @@ describe('extensions', () => {
expect(errors[0]).toMatchObject({ detail: new Error('Test error') });
});
it('should handle failed lazy loads', async () => {
const BrokenComponent = plugin.provide(
createComponentExtension({
name: 'BrokenComponent',
component: {
lazy: async () => {
if (true as boolean) {
throw new Error('Test error');
}
return () => <div />;
},
},
}),
);
mocked(useApp).mockReturnValue({
getComponents: () => ({
Progress: () => null,
ErrorBoundaryFallback: (props: ErrorBoundaryFallbackProps) => (
<>
Error in {props.plugin?.getId()}: {String(props.error)}
</>
),
}),
});
const { error: errors } = await withLogCollector(['error'], async () => {
await act(async () => {
render(<BrokenComponent />);
});
});
screen.getByText(
'Error in my-plugin: Error: Failed lazy loading of the BrokenComponent extension, try to reload the page; caused by Error: Test error',
);
expect(errors[0]).toMatchObject({
detail: new ForwardedError(
'Failed lazy loading of the BrokenComponent extension, try to reload the page',
new Error('Test error'),
),
});
});
it('should wrap extended component with analytics context', async () => {
const AnalyticsSpyExtension = plugin.provide(
createReactExtension({
@@ -22,6 +22,7 @@ import { attachComponentData } from './componentData';
import { Extension, BackstagePlugin } from '../plugin';
import { PluginErrorBoundary } from './PluginErrorBoundary';
import { routableExtensionRenderedEvent } from '../analytics/Tracker';
import { ForwardedError } from '@backstage/errors';
/**
* Lazy or synchronous retrieving of extension components.
@@ -79,62 +80,51 @@ export function createRoutableExtension<
return createReactExtension({
component: {
lazy: () =>
component().then(
InnerComponent => {
const RoutableExtensionWrapper: any = (props: any) => {
const analytics = useAnalytics();
component().then(InnerComponent => {
const RoutableExtensionWrapper: any = (props: any) => {
const analytics = useAnalytics();
// Validate that the routing is wired up correctly in the App.tsx
try {
useRouteRef(mountPoint);
} catch (error) {
if (typeof error === 'object' && error !== null) {
const { message } = error as { message?: unknown };
if (
typeof message === 'string' &&
message.startsWith('No path for ')
) {
throw new Error(
`Routable extension component with mount point ${mountPoint} was not discovered in the app element tree. ` +
'Routable extension components may not be rendered by other components and must be ' +
'directly available as an element within the App provider component.',
);
}
// Validate that the routing is wired up correctly in the App.tsx
try {
useRouteRef(mountPoint);
} catch (error) {
if (typeof error === 'object' && error !== null) {
const { message } = error as { message?: unknown };
if (
typeof message === 'string' &&
message.startsWith('No path for ')
) {
throw new Error(
`Routable extension component with mount point ${mountPoint} was not discovered in the app element tree. ` +
'Routable extension components may not be rendered by other components and must be ' +
'directly available as an element within the App provider component.',
);
}
throw error;
}
throw error;
}
// This event, never exposed to end-users of the analytics API,
// helps inform which extension metadata gets associated with a
// navigation event when the route navigated to is a gathered
// mountpoint.
useEffect(() => {
analytics.captureEvent(routableExtensionRenderedEvent, '');
}, [analytics]);
// This event, never exposed to end-users of the analytics API,
// helps inform which extension metadata gets associated with a
// navigation event when the route navigated to is a gathered
// mountpoint.
useEffect(() => {
analytics.captureEvent(routableExtensionRenderedEvent, '');
}, [analytics]);
return <InnerComponent {...props} />;
};
return <InnerComponent {...props} />;
};
const componentName =
name ||
(InnerComponent as { displayName?: string }).displayName ||
InnerComponent.name ||
'LazyComponent';
const componentName =
name ||
(InnerComponent as { displayName?: string }).displayName ||
InnerComponent.name ||
'LazyComponent';
RoutableExtensionWrapper.displayName = `RoutableExtension(${componentName})`;
RoutableExtensionWrapper.displayName = `RoutableExtension(${componentName})`;
return RoutableExtensionWrapper as T;
},
error => {
const RoutableExtensionWrapper: any = (_: any) => {
const app = useApp();
const { BootErrorPage } = app.getComponents();
return <BootErrorPage step="load-chunk" error={error} />;
};
return RoutableExtensionWrapper;
},
),
return RoutableExtensionWrapper as T;
}),
},
data: {
'core.mountPoint': mountPoint,
@@ -225,7 +215,16 @@ export function createReactExtension<
if ('lazy' in options.component) {
const lazyLoader = options.component.lazy;
Component = lazy(() =>
lazyLoader().then(component => ({ default: component })),
lazyLoader().then(
component => ({ default: component }),
error => {
const ofExtension = name ? ` of the ${name} extension` : '';
throw new ForwardedError(
`Failed lazy loading${ofExtension}, try to reload the page`,
error,
);
},
),
) as unknown as T;
} else {
Component = options.component.sync;