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:
@@ -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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+7
-2
@@ -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;
|
||||
}
|
||||
}
|
||||
+8
-10
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user