frontend-plugin-api: new error boundary API option + boundary for app root elements

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2025-12-06 12:53:18 +01:00
parent ec476bcb47
commit 75683ed6c0
9 changed files with 194 additions and 23 deletions
+5
View File
@@ -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.
+7
View File
@@ -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.
@@ -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<AlertMessage>();
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<AlertMessage> {
return this.subject;
return new ObservableImpl<AlertMessage>(subscriber => {
for (const alert of this.recentAlerts) {
subscriber.next(alert);
}
return this.subject.subscribe(subscriber);
});
}
}
@@ -1146,6 +1146,8 @@ export interface ExtensionBoundaryProps {
// (undocumented)
children: ReactNode;
// (undocumented)
errorPresentation?: 'error-api' | 'error-display';
// (undocumented)
node: AppNode;
}
@@ -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: <ErrorComponent />,
},
});
const tester = createExtensionTester(extension);
renderInTestApp(
<TestApiProvider apis={[[errorApiRef, errorApi]]}>
{tester.reactElement()}
</TestApiProvider>,
);
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 = () => <div>{successMessage}</div>;
const extension = AppRootElementBlueprint.make({
params: {
element: <SuccessComponent />,
},
});
const tester = createExtensionTester(extension);
renderInTestApp(tester.reactElement());
await waitFor(() => {
expect(screen.getByText(successMessage)).toBeInTheDocument();
});
});
});
@@ -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(
<ExtensionBoundary node={node} errorPresentation="error-api">
{params.element}
</ExtensionBoundary>,
);
},
});
@@ -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;
}
}
@@ -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 });
@@ -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 = (
<AnalyticsContext attributes={attributes}>
<RouteTracker enabled={hasRoutePathOutput}>{children}</RouteTracker>
</AnalyticsContext>
);
if (props.errorPresentation === 'error-api') {
content = (
<ErrorApiBoundary node={node} errorApi={errorApi}>
{content}
</ErrorApiBoundary>
);
} else {
content = (
<ErrorDisplayBoundary plugin={plugin}>{content}</ErrorDisplayBoundary>
);
}
return (
<AppNodeProvider node={node}>
<Suspense fallback={<Progress />}>
<ErrorBoundary plugin={plugin}>
<AnalyticsContext attributes={attributes}>
<RouteTracker enabled={hasRoutePathOutput}>{children}</RouteTracker>
</AnalyticsContext>
</ErrorBoundary>
</Suspense>
<Suspense fallback={<Progress />}>{content}</Suspense>
</AppNodeProvider>
);
}