diff --git a/.changeset/brown-dots-shake.md b/.changeset/brown-dots-shake.md
new file mode 100644
index 0000000000..821df15c82
--- /dev/null
+++ b/.changeset/brown-dots-shake.md
@@ -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.
diff --git a/packages/core-plugin-api/src/extensions/extensions.test.tsx b/packages/core-plugin-api/src/extensions/extensions.test.tsx
index 3eca133608..25d29fc392 100644
--- a/packages/core-plugin-api/src/extensions/extensions.test.tsx
+++ b/packages/core-plugin-api/src/extensions/extensions.test.tsx
@@ -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 () =>
;
+ },
+ },
+ }),
+ );
+
+ 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();
+ });
+ });
+ 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({
diff --git a/packages/core-plugin-api/src/extensions/extensions.tsx b/packages/core-plugin-api/src/extensions/extensions.tsx
index b94354fa2c..51af857d4b 100644
--- a/packages/core-plugin-api/src/extensions/extensions.tsx
+++ b/packages/core-plugin-api/src/extensions/extensions.tsx
@@ -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 ;
- };
+ return ;
+ };
- 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 ;
- };
- 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;