From 75683ed6c0635f8fa92a09dfa688c376d8d29b0c Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 6 Dec 2025 12:53:18 +0100 Subject: [PATCH] frontend-plugin-api: new error boundary API option + boundary for app root elements Signed-off-by: Patrik Oldsberg --- .changeset/alert-api-replay.md | 5 ++ .changeset/error-boundary-api.md | 7 ++ .../AlertApi/AlertApiForwarder.ts | 17 ++++- packages/frontend-plugin-api/report.api.md | 2 + .../AppRootElementBlueprint.test.tsx | 65 +++++++++++++++++++ ...ueprint.ts => AppRootElementBlueprint.tsx} | 9 ++- .../src/components/ErrorApiBoundary.tsx | 50 ++++++++++++++ ...rBoundary.tsx => ErrorDisplayBoundary.tsx} | 18 +++-- .../src/components/ExtensionBoundary.tsx | 44 ++++++++++--- 9 files changed, 194 insertions(+), 23 deletions(-) create mode 100644 .changeset/alert-api-replay.md create mode 100644 .changeset/error-boundary-api.md rename packages/frontend-plugin-api/src/blueprints/{AppRootElementBlueprint.ts => AppRootElementBlueprint.tsx} (78%) create mode 100644 packages/frontend-plugin-api/src/components/ErrorApiBoundary.tsx rename packages/frontend-plugin-api/src/components/{ErrorBoundary.tsx => ErrorDisplayBoundary.tsx} (80%) diff --git a/.changeset/alert-api-replay.md b/.changeset/alert-api-replay.md new file mode 100644 index 0000000000..b41ec091a3 --- /dev/null +++ b/.changeset/alert-api-replay.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-app-api': patch +--- + +Added replay functionality to `AlertApiForwarder` to buffer and replay recent alerts to new subscribers, preventing missed alerts that were posted before subscription. diff --git a/.changeset/error-boundary-api.md b/.changeset/error-boundary-api.md new file mode 100644 index 0000000000..74803f1dc0 --- /dev/null +++ b/.changeset/error-boundary-api.md @@ -0,0 +1,7 @@ +--- +'@backstage/frontend-plugin-api': patch +--- + +Added a new `errorPresentation` prop to `ExtensionBoundary` to control how errors are presented to the user. The default is `'error-display'`, which is the current behavior of showing the error in the `ErrorDisplay` component. The new option is `'error-api'`, posts errors to the `ErrorApi` and does not allow retries. + +The `AppRootElementBlueprint` now wraps its element in an `ErrorBoundary` using the new `'error-api'` presentation mode. diff --git a/packages/core-app-api/src/apis/implementations/AlertApi/AlertApiForwarder.ts b/packages/core-app-api/src/apis/implementations/AlertApi/AlertApiForwarder.ts index 4e5397f65d..138d00c09c 100644 --- a/packages/core-app-api/src/apis/implementations/AlertApi/AlertApiForwarder.ts +++ b/packages/core-app-api/src/apis/implementations/AlertApi/AlertApiForwarder.ts @@ -17,20 +17,35 @@ import { AlertApi, AlertMessage } from '@backstage/core-plugin-api'; import { Observable } from '@backstage/types'; import { PublishSubject } from '../../../lib/subjects'; +import ObservableImpl from 'zen-observable'; /** * Base implementation for the AlertApi that simply forwards alerts to consumers. * + * Recent alerts are buffered and replayed to new subscribers to prevent + * missing alerts that were posted before subscription. + * * @public */ export class AlertApiForwarder implements AlertApi { private readonly subject = new PublishSubject(); + private readonly recentAlerts: AlertMessage[] = []; + private readonly maxBufferSize = 10; post(alert: AlertMessage) { + this.recentAlerts.push(alert); + if (this.recentAlerts.length > this.maxBufferSize) { + this.recentAlerts.shift(); + } this.subject.next(alert); } alert$(): Observable { - return this.subject; + return new ObservableImpl(subscriber => { + for (const alert of this.recentAlerts) { + subscriber.next(alert); + } + return this.subject.subscribe(subscriber); + }); } } diff --git a/packages/frontend-plugin-api/report.api.md b/packages/frontend-plugin-api/report.api.md index ca4f8a1a0c..e45401304b 100644 --- a/packages/frontend-plugin-api/report.api.md +++ b/packages/frontend-plugin-api/report.api.md @@ -1146,6 +1146,8 @@ export interface ExtensionBoundaryProps { // (undocumented) children: ReactNode; // (undocumented) + errorPresentation?: 'error-api' | 'error-display'; + // (undocumented) node: AppNode; } diff --git a/packages/frontend-plugin-api/src/blueprints/AppRootElementBlueprint.test.tsx b/packages/frontend-plugin-api/src/blueprints/AppRootElementBlueprint.test.tsx index 39f03cfad4..31d7323aec 100644 --- a/packages/frontend-plugin-api/src/blueprints/AppRootElementBlueprint.test.tsx +++ b/packages/frontend-plugin-api/src/blueprints/AppRootElementBlueprint.test.tsx @@ -14,7 +14,19 @@ * limitations under the License. */ +import { screen, waitFor } from '@testing-library/react'; +import { + MockErrorApi, + TestApiProvider, + withLogCollector, +} from '@backstage/test-utils'; +import { errorApiRef } from '../apis'; +import { + createExtensionTester, + renderInTestApp, +} from '@backstage/frontend-test-utils'; import { AppRootElementBlueprint } from './AppRootElementBlueprint'; +import { ForwardedError } from '@backstage/errors'; describe('AppRootElementBlueprint', () => { it('should create an extension with sensible defaults', () => { @@ -46,4 +58,57 @@ describe('AppRootElementBlueprint', () => { } `); }); + + it('should post error to errorApi and not render children when error occurs', async () => { + const errorApi = new MockErrorApi({ collect: true }); + const errorMessage = 'Test error message'; + const ErrorComponent = () => { + throw new Error(errorMessage); + }; + + await withLogCollector(['error'], async () => { + const extension = AppRootElementBlueprint.make({ + params: { + element: , + }, + }); + + const tester = createExtensionTester(extension); + renderInTestApp( + + {tester.reactElement()} + , + ); + + await waitFor(() => { + const errors = errorApi.getErrors(); + expect(errors.length).toBeGreaterThan(0); + const postedError = errors[0].error; + expect(postedError).toBeInstanceOf(ForwardedError); + expect(postedError.message).toBe( + "Error in extension 'app-root-element:test'; caused by Error: Test error message", + ); + }); + + expect(screen.queryByText(errorMessage)).not.toBeInTheDocument(); + }); + }); + + it('should render children when there is no error', async () => { + const successMessage = 'Success!'; + const SuccessComponent = () =>
{successMessage}
; + + const extension = AppRootElementBlueprint.make({ + params: { + element: , + }, + }); + + const tester = createExtensionTester(extension); + renderInTestApp(tester.reactElement()); + + await waitFor(() => { + expect(screen.getByText(successMessage)).toBeInTheDocument(); + }); + }); }); diff --git a/packages/frontend-plugin-api/src/blueprints/AppRootElementBlueprint.ts b/packages/frontend-plugin-api/src/blueprints/AppRootElementBlueprint.tsx similarity index 78% rename from packages/frontend-plugin-api/src/blueprints/AppRootElementBlueprint.ts rename to packages/frontend-plugin-api/src/blueprints/AppRootElementBlueprint.tsx index d1c71155f2..955abce136 100644 --- a/packages/frontend-plugin-api/src/blueprints/AppRootElementBlueprint.ts +++ b/packages/frontend-plugin-api/src/blueprints/AppRootElementBlueprint.tsx @@ -14,6 +14,7 @@ * limitations under the License. */ +import { ExtensionBoundary } from '@backstage/frontend-plugin-api'; import { coreExtensionData, createExtensionBlueprint } from '../wiring'; /** @@ -26,7 +27,11 @@ export const AppRootElementBlueprint = createExtensionBlueprint({ kind: 'app-root-element', attachTo: { id: 'app/root', input: 'elements' }, output: [coreExtensionData.reactElement], - *factory(params: { element: JSX.Element }) { - yield coreExtensionData.reactElement(params.element); + *factory(params: { element: JSX.Element }, { node }) { + yield coreExtensionData.reactElement( + + {params.element} + , + ); }, }); diff --git a/packages/frontend-plugin-api/src/components/ErrorApiBoundary.tsx b/packages/frontend-plugin-api/src/components/ErrorApiBoundary.tsx new file mode 100644 index 0000000000..e195da5d0f --- /dev/null +++ b/packages/frontend-plugin-api/src/components/ErrorApiBoundary.tsx @@ -0,0 +1,50 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Component, ErrorInfo, ReactNode } from 'react'; +import { AppNode, ErrorApi } from '../apis'; +import { ForwardedError } from '@backstage/errors'; + +/** @internal */ +export class ErrorApiBoundary extends Component< + { + children: ReactNode; + node: AppNode; + errorApi?: ErrorApi; + }, + { error?: Error } +> { + static getDerivedStateFromError(error: Error) { + return { error }; + } + + state = { error: undefined }; + + componentDidCatch(error: Error, _errorInfo: ErrorInfo) { + const { node, errorApi } = this.props; + errorApi?.post( + new ForwardedError(`Error in extension '${node.spec.id}'`, error), + ); + } + + render() { + if (this.state.error) { + return null; + } + + return this.props.children; + } +} diff --git a/packages/frontend-plugin-api/src/components/ErrorBoundary.tsx b/packages/frontend-plugin-api/src/components/ErrorDisplayBoundary.tsx similarity index 80% rename from packages/frontend-plugin-api/src/components/ErrorBoundary.tsx rename to packages/frontend-plugin-api/src/components/ErrorDisplayBoundary.tsx index 4cde84ee93..1584f18514 100644 --- a/packages/frontend-plugin-api/src/components/ErrorBoundary.tsx +++ b/packages/frontend-plugin-api/src/components/ErrorDisplayBoundary.tsx @@ -14,25 +14,23 @@ * limitations under the License. */ -import { Component, PropsWithChildren } from 'react'; +import { Component, ReactNode } from 'react'; import { FrontendPlugin } from '../wiring'; import { ErrorDisplay } from './DefaultSwappableComponents'; -type ErrorBoundaryProps = PropsWithChildren<{ - plugin?: FrontendPlugin; -}>; -type ErrorBoundaryState = { error?: Error }; - /** @internal */ -export class ErrorBoundary extends Component< - ErrorBoundaryProps, - ErrorBoundaryState +export class ErrorDisplayBoundary extends Component< + { + children: ReactNode; + plugin: FrontendPlugin; + }, + { error?: Error } > { static getDerivedStateFromError(error: Error) { return { error }; } - state: ErrorBoundaryState = { error: undefined }; + state = { error: undefined }; handleErrorReset = () => { this.setState({ error: undefined }); diff --git a/packages/frontend-plugin-api/src/components/ExtensionBoundary.tsx b/packages/frontend-plugin-api/src/components/ExtensionBoundary.tsx index 421f41be56..6e4487ed9b 100644 --- a/packages/frontend-plugin-api/src/components/ExtensionBoundary.tsx +++ b/packages/frontend-plugin-api/src/components/ExtensionBoundary.tsx @@ -22,14 +22,23 @@ import { lazy as reactLazy, } from 'react'; import { AnalyticsContext, useAnalytics } from '../analytics'; -import { ErrorBoundary } from './ErrorBoundary'; +import { ErrorDisplayBoundary } from './ErrorDisplayBoundary'; +import { ErrorApiBoundary } from './ErrorApiBoundary'; // eslint-disable-next-line @backstage/no-relative-monorepo-imports import { routableExtensionRenderedEvent } from '../../../core-plugin-api/src/analytics/Tracker'; -import { AppNode } from '../apis'; +import { AppNode, ErrorApi, errorApiRef, useApi } from '../apis'; import { coreExtensionData } from '../wiring'; import { AppNodeProvider } from './AppNodeProvider'; import { Progress } from './DefaultSwappableComponents'; +function useOptionalErrorApi(): ErrorApi | undefined { + try { + return useApi(errorApiRef); + } catch { + return undefined; + } +} + type RouteTrackerProps = PropsWithChildren<{ enabled?: boolean; }>; @@ -53,6 +62,7 @@ const RouteTracker = (props: RouteTrackerProps) => { /** @public */ export interface ExtensionBoundaryProps { + errorPresentation?: 'error-api' | 'error-display'; node: AppNode; children: ReactNode; } @@ -61,6 +71,8 @@ export interface ExtensionBoundaryProps { export function ExtensionBoundary(props: ExtensionBoundaryProps) { const { node, children } = props; + const errorApi = useOptionalErrorApi(); + const hasRoutePathOutput = Boolean( node.instance?.getData(coreExtensionData.routePath), ); @@ -70,18 +82,30 @@ export function ExtensionBoundary(props: ExtensionBoundaryProps) { // Skipping "routeRef" attribute in the new system, the extension "id" should provide more insight const attributes = { extensionId: node.spec.id, - pluginId: node.spec.plugin?.id ?? 'app', + pluginId: plugin.id ?? 'app', }; + let content = ( + + {children} + + ); + + if (props.errorPresentation === 'error-api') { + content = ( + + {content} + + ); + } else { + content = ( + {content} + ); + } + return ( - }> - - - {children} - - - + }>{content} ); }