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;