Merge pull request #5248 from backstage/mob/error-boundary-xp

Error boundary in extensions
This commit is contained in:
Fredrik Adelöw
2021-06-09 21:47:11 +02:00
committed by GitHub
17 changed files with 442 additions and 175 deletions
+34
View File
@@ -0,0 +1,34 @@
---
'@backstage/core-app-api': patch
'@backstage/core-components': patch
'@backstage/core-plugin-api': patch
---
This change adds automatic error boundaries around extensions.
This means that all exposed parts of a plugin are wrapped in a general error boundary component, that is plugin aware. The default design for the error box is borrowed from `@backstage/errors`. To override the default "fallback", one must provide a component named `ErrorBoundaryFallback` to `createApp`, like so:
```ts
const app = createApp({
components: {
ErrorBoundaryFallback: props => {
// a custom fallback component
return (
<>
<h1>Oops.</h1>
<h2>
The plugin {props.plugin.getId()} failed with {props.error.message}
</h2>
<button onClick={props.resetError}>Try again</button>
</>
);
},
},
});
```
The props here include:
- `error`. An `Error` object or something that inherits it that represents the error that was thrown from any inner component.
- `resetError`. A callback that will simply attempt to mount the children of the error boundary again.
- `plugin`. A `BackstagePlugin` that can be used to look up info to be presented in the error message. For instance, you may want to keep a map of your internal plugins and team names or slack channels and present these when an error occurs. Typically, you'll do that by getting the plugin ID with `plugin.getId()`.
+8
View File
@@ -120,6 +120,7 @@ export type AppComponents = {
BootErrorPage: ComponentType<BootErrorPageProps>;
Progress: ComponentType<{}>;
Router: ComponentType<{}>;
ErrorBoundaryFallback: ComponentType<ErrorBoundaryFallbackProps>;
SignInPage?: ComponentType<SignInPageProps>;
};
@@ -220,6 +221,13 @@ export class ErrorApiForwarder implements ErrorApi {
post(error: Error, context?: ErrorContext): void;
}
// @public (undocumented)
export type ErrorBoundaryFallbackProps = {
plugin?: BackstagePlugin;
error: Error;
resetError: () => void;
};
// @public (undocumented)
export const FlatRoutes: (props: FlatRoutesProps) => JSX.Element | null;
@@ -159,6 +159,7 @@ describe('Integration Test', () => {
BootErrorPage: () => null,
Progress: () => null,
Router: BrowserRouter,
ErrorBoundaryFallback: () => null,
};
it('runs happy paths', async () => {
+32 -8
View File
@@ -14,21 +14,27 @@
* limitations under the License.
*/
import { AppConfig, JsonObject } from '@backstage/config';
import { Button } from '@material-ui/core';
import { ErrorPage, ErrorPanel, Progress } from '@backstage/core-components';
import { darkTheme, lightTheme } from '@backstage/theme';
import DarkIcon from '@material-ui/icons/Brightness2';
import LightIcon from '@material-ui/icons/WbSunny';
import React, { PropsWithChildren } from 'react';
import { AppOptions, BootErrorPageProps, AppConfigLoader } from './types';
import { defaultAppIcons } from './icons';
import {
BrowserRouter,
MemoryRouter,
useInRouterContext,
} from 'react-router-dom';
import LightIcon from '@material-ui/icons/WbSunny';
import DarkIcon from '@material-ui/icons/Brightness2';
import { ErrorPage, Progress } from '@backstage/core-components';
import { defaultApis } from './defaultApis';
import { lightTheme, darkTheme } from '@backstage/theme';
import { AppConfig, JsonObject } from '@backstage/config';
import { PrivateAppImpl } from './App';
import { defaultApis } from './defaultApis';
import { defaultAppIcons } from './icons';
import {
AppConfigLoader,
AppOptions,
BootErrorPageProps,
ErrorBoundaryFallbackProps,
} from './types';
/**
* The default config loader, which expects that config is available at compile-time
@@ -115,6 +121,23 @@ export function createApp(options?: AppOptions) {
</OptionallyWrapInRouter>
);
};
const DefaultErrorBoundaryFallback = ({
error,
resetError,
plugin,
}: ErrorBoundaryFallbackProps) => {
return (
<ErrorPanel
title={`Error in ${plugin?.getId()}`}
defaultExpanded
error={error}
>
<Button variant="outlined" onClick={resetError}>
Retry
</Button>
</ErrorPanel>
);
};
const apis = options?.apis ?? [];
const icons = { ...defaultAppIcons, ...options?.icons };
@@ -124,6 +147,7 @@ export function createApp(options?: AppOptions) {
BootErrorPage: DefaultBootErrorPage,
Progress: Progress,
Router: BrowserRouter,
ErrorBoundaryFallback: DefaultErrorBoundaryFallback,
...options?.components,
};
const themes = options?.themes ?? [
+7
View File
@@ -59,11 +59,18 @@ export type SignInPageProps = {
onResult(result: SignInResult): void;
};
export type ErrorBoundaryFallbackProps = {
plugin?: BackstagePlugin;
error: Error;
resetError: () => void;
};
export type AppComponents = {
NotFoundErrorPage: ComponentType<{}>;
BootErrorPage: ComponentType<BootErrorPageProps>;
Progress: ComponentType<{}>;
Router: ComponentType<{}>;
ErrorBoundaryFallback: ComponentType<ErrorBoundaryFallbackProps>;
/**
* An optional sign-in page that will be rendered instead of the AppRouter at startup.
@@ -48,6 +48,7 @@ import {
import { validateRoutes } from './validation';
import { RouteResolver } from './RouteResolver';
import { AnyRouteRef, RouteFunc } from './types';
import { AppContextProvider } from '../app/AppContext';
const MockComponent = ({ children }: PropsWithChildren<{ path?: string }>) => (
<>{children}</>
@@ -125,6 +126,12 @@ const Extension5 = plugin.provide(
}),
);
const mockContext = {
getComponents: () => ({ Progress: () => null } as any),
getSystemIcon: jest.fn(),
getPlugins: jest.fn(),
};
function withRoutingProvider(
root: ReactElement,
routeBindings: [ExternalRouteRef, RouteRef][] = [],
@@ -154,20 +161,22 @@ function withRoutingProvider(
describe('discovery', () => {
it('should handle simple routeRef path creation for routeRefs used in other parts of the app', async () => {
const root = (
<MemoryRouter initialEntries={['/foo/bar']}>
<Routes>
<Extension1 path="/foo">
<Extension2 path="/bar" name="inside" routeRef={ref2} />
<MockRouteSource name="insideExternal" routeRef={eRefA} />
</Extension1>
<Extension3 path="/baz" />
</Routes>
<MockRouteSource name="outside" routeRef={ref2} />
<MockRouteSource name="outsideExternal1" routeRef={eRefB} />
<MockRouteSource name="outsideExternal2" routeRef={eRefC} />
<MockRouteSource name="outsideExternal3" routeRef={eRefD} />
<MockRouteSource name="outsideExternal4" routeRef={eRefE} />
</MemoryRouter>
<AppContextProvider appContext={mockContext}>
<MemoryRouter initialEntries={['/foo/bar']}>
<Routes>
<Extension1 path="/foo">
<Extension2 path="/bar" name="inside" routeRef={ref2} />
<MockRouteSource name="insideExternal" routeRef={eRefA} />
</Extension1>
<Extension3 path="/baz" />
</Routes>
<MockRouteSource name="outside" routeRef={ref2} />
<MockRouteSource name="outsideExternal1" routeRef={eRefB} />
<MockRouteSource name="outsideExternal2" routeRef={eRefC} />
<MockRouteSource name="outsideExternal3" routeRef={eRefD} />
<MockRouteSource name="outsideExternal4" routeRef={eRefE} />
</MemoryRouter>
</AppContextProvider>
);
const rendered = render(
@@ -202,23 +211,25 @@ describe('discovery', () => {
it('should handle routeRefs with parameters', async () => {
const root = (
<MemoryRouter initialEntries={['/foo/bar/wat']}>
<Routes>
<Extension1 path="/foo">
<Extension4
path="/bar/:id"
name="inside"
routeRef={ref4}
params={{ id: 'bleb' }}
/>
</Extension1>
</Routes>
<MockRouteSource
name="outside"
routeRef={ref4}
params={{ id: 'blob' }}
/>
</MemoryRouter>
<AppContextProvider appContext={mockContext}>
<MemoryRouter initialEntries={['/foo/bar/wat']}>
<Routes>
<Extension1 path="/foo">
<Extension4
path="/bar/:id"
name="inside"
routeRef={ref4}
params={{ id: 'bleb' }}
/>
</Extension1>
</Routes>
<MockRouteSource
name="outside"
routeRef={ref4}
params={{ id: 'blob' }}
/>
</MemoryRouter>
</AppContextProvider>
);
const rendered = render(withRoutingProvider(root));
@@ -233,22 +244,24 @@ describe('discovery', () => {
it('should handle relative routing within parameterized routePaths', async () => {
const root = (
<MemoryRouter initialEntries={['/foo/blob/baz']}>
<React.Suspense fallback="loller">
<Routes>
<Extension5 path="/foo/:id">
<Extension2 path="/bar" name="inside" routeRef={ref3} />
<Extension3 path="/baz" />
</Extension5>
</Routes>
<MockRouteSource name="outsideNoParams" routeRef={ref3} />
<MockRouteSource
name="outsideWithParams"
routeRef={ref3}
params={{ id: 'blob' }}
/>
</React.Suspense>
</MemoryRouter>
<AppContextProvider appContext={mockContext}>
<MemoryRouter initialEntries={['/foo/blob/baz']}>
<React.Suspense fallback="loller">
<Routes>
<Extension5 path="/foo/:id">
<Extension2 path="/bar" name="inside" routeRef={ref3} />
<Extension3 path="/baz" />
</Extension5>
</Routes>
<MockRouteSource name="outsideNoParams" routeRef={ref3} />
<MockRouteSource
name="outsideWithParams"
routeRef={ref3}
params={{ id: 'blob' }}
/>
</React.Suspense>
</MemoryRouter>
</AppContextProvider>
);
const rendered = render(withRoutingProvider(root));
@@ -0,0 +1,114 @@
/*
* Copyright 2021 Spotify AB
*
* 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 { List, ListItem, ListItemText, makeStyles } from '@material-ui/core';
import React, { PropsWithChildren } from 'react';
import { CopyTextButton } from '../CopyTextButton';
import { WarningPanel } from '../WarningPanel';
const useStyles = makeStyles(theme => ({
text: {
fontFamily: 'monospace',
whiteSpace: 'pre',
overflowX: 'auto',
marginRight: theme.spacing(2),
},
divider: {
margin: theme.spacing(2),
},
}));
type ErrorListProps = {
error: string;
message: string;
request?: string;
stack?: string;
json?: string;
};
const ErrorList = ({
error,
message,
stack,
children,
}: PropsWithChildren<ErrorListProps>) => {
const classes = useStyles();
return (
<List dense>
<ListItem alignItems="flex-start">
<ListItemText
classes={{ secondary: classes.text }}
primary="Error"
secondary={error}
/>
<CopyTextButton text={error} />
</ListItem>
<ListItem alignItems="flex-start">
<ListItemText
classes={{ secondary: classes.text }}
primary="Message"
secondary={message}
/>
<CopyTextButton text={message} />
</ListItem>
{stack && (
<ListItem alignItems="flex-start">
<ListItemText
classes={{ secondary: classes.text }}
primary="Stack Trace"
secondary={stack}
/>
<CopyTextButton text={stack} />
</ListItem>
)}
{children}
</List>
);
};
export type ErrorPanelProps = {
error: Error;
defaultExpanded?: boolean;
title?: string;
};
/**
* Renders a warning panel as the effect of an error.
*/
export const ErrorPanel = ({
title,
error,
defaultExpanded,
children,
}: PropsWithChildren<ErrorPanelProps>) => {
return (
<WarningPanel
title={title ?? error.message}
defaultExpanded={defaultExpanded}
>
<ErrorList
error={error.name}
message={error.message}
stack={error.stack}
children={children}
/>
</WarningPanel>
);
};
@@ -0,0 +1,18 @@
/*
* Copyright 2021 Spotify AB
*
* 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.
*/
export { ErrorPanel } from './ErrorPanel';
export type { ErrorPanelProps } from './ErrorPanel';
@@ -15,17 +15,11 @@
*/
import { ResponseError } from '@backstage/errors';
import {
Divider,
List,
ListItem,
ListItemText,
makeStyles,
} from '@material-ui/core';
import { Divider, ListItem, ListItemText, makeStyles } from '@material-ui/core';
import React from 'react';
import { CodeSnippet } from '../CodeSnippet';
import { CopyTextButton } from '../CopyTextButton';
import { WarningPanel } from '../WarningPanel';
import { ErrorPanel, ErrorPanelProps } from '../ErrorPanel';
const useStyles = makeStyles(theme => ({
text: {
@@ -39,95 +33,25 @@ const useStyles = makeStyles(theme => ({
},
}));
type ResponseErrorListProps = {
error: string;
message: string;
request?: string;
stack?: string;
json?: string;
};
const ResponseErrorList = ({
error,
request,
message,
stack,
json,
}: ResponseErrorListProps) => {
const classes = useStyles();
return (
<List dense>
<ListItem alignItems="flex-start">
<ListItemText
classes={{ secondary: classes.text }}
primary="Error"
secondary={error}
/>
<CopyTextButton text={error} />
</ListItem>
<ListItem alignItems="flex-start">
<ListItemText
classes={{ secondary: classes.text }}
primary="Message"
secondary={message}
/>
<CopyTextButton text={message} />
</ListItem>
{request && (
<ListItem alignItems="flex-start">
<ListItemText
classes={{ secondary: classes.text }}
primary="Request"
secondary={request}
/>
<CopyTextButton text={request} />
</ListItem>
)}
{stack && (
<ListItem alignItems="flex-start">
<ListItemText
classes={{ secondary: classes.text }}
primary="Stack Trace"
secondary={stack}
/>
<CopyTextButton text={stack} />
</ListItem>
)}
{json && (
<>
<Divider component="li" className={classes.divider} />
<ListItem alignItems="flex-start">
<ListItemText
classes={{ secondary: classes.text }}
primary="Full Error as JSON"
secondary={<CodeSnippet language="json" text={json} />}
/>
<CopyTextButton text={json} />
</ListItem>
</>
)}
</List>
);
};
type Props = {
error: Error;
};
/**
* Renders details about a failed server request.
* Renders a warning panel as the effect of a failed server request.
*
* Has special treatment for ResponseError errors, to display rich
* server-provided information about what happened.
*/
export const ResponseErrorDetails = ({ error }: Props) => {
export const ResponseErrorPanel = ({
title,
error,
defaultExpanded,
}: ErrorPanelProps) => {
const classes = useStyles();
if (error.name !== 'ResponseError') {
return (
<ResponseErrorList
error={error.name}
message={error.message}
stack={error.stack}
<ErrorPanel
title={title ?? error.message}
defaultExpanded={defaultExpanded}
error={error}
/>
);
}
@@ -142,26 +66,32 @@ export const ResponseErrorDetails = ({ error }: Props) => {
const jsonString = JSON.stringify(data, undefined, 2);
return (
<ResponseErrorList
error={errorString}
message={messageString}
request={requestString}
stack={stackString}
json={jsonString}
/>
);
};
/**
* Renders a warning panel as the effect of a failed server request.
*
* Has special treatment for ResponseError errors, to display rich
* server-provided information about what happened.
*/
export const ResponseErrorPanel = ({ error }: Props) => {
return (
<WarningPanel title={error.message}>
<ResponseErrorDetails error={error} />
</WarningPanel>
<ErrorPanel
title={title ?? error.message}
defaultExpanded={defaultExpanded}
error={{ name: errorString, message: messageString, stack: stackString }}
>
{requestString && (
<ListItem alignItems="flex-start">
<ListItemText
classes={{ secondary: classes.text }}
primary="Request"
secondary={request}
/>
<CopyTextButton text={requestString} />
</ListItem>
)}
<>
<Divider component="li" className={classes.divider} />
<ListItem alignItems="flex-start">
<ListItemText
classes={{ secondary: classes.text }}
primary="Full Error as JSON"
secondary={<CodeSnippet language="json" text={jsonString} />}
/>
<CopyTextButton text={jsonString} />
</ListItem>
</>
</ErrorPanel>
);
};
@@ -14,4 +14,4 @@
* limitations under the License.
*/
export { ResponseErrorDetails, ResponseErrorPanel } from './ResponseErrorPanel';
export { ResponseErrorPanel } from './ResponseErrorPanel';
@@ -77,6 +77,7 @@ type Props = {
title?: string;
severity?: 'warning' | 'error' | 'info';
message?: React.ReactNode;
defaultExpanded?: boolean;
children?: React.ReactNode;
};
@@ -92,18 +93,21 @@ const capitalize = (s: string) => {
* @param {string} [title] A title for the warning. If not supplied, "Warning" will be used.
* @param {Object} [message] Optional more detailed user-friendly message elaborating on the cause of the error.
* @param {Object} [children] Objects to provide context, such as a stack trace or detailed error reporting.
* Will be available inside an unfolded accordion.
* Will be available inside an unfolded accordion.
*/
export const WarningPanel = (props: Props) => {
const classes = useStyles(props);
const { severity, title, message, children } = props;
const { severity, title, message, children, defaultExpanded } = props;
// If no severity or title provided, the heading will read simply "Warning"
const subTitle =
(severity ? capitalize(severity) : 'Warning') + (title ? `: ${title}` : '');
return (
<Accordion className={classes.panel}>
<Accordion
defaultExpanded={defaultExpanded ?? false}
className={classes.panel}
>
<AccordionSummary
expandIcon={<ExpandMoreIconStyled />}
className={classes.summary}
@@ -22,6 +22,7 @@ export * from './CopyTextButton';
export * from './DependencyGraph';
export * from './DismissableBanner';
export * from './EmptyState';
export * from './ErrorPanel';
export * from './ResponseErrorPanel';
export * from './FeatureDiscovery';
export * from './HeaderIconLinkRow';
+8
View File
@@ -70,6 +70,7 @@ export type AppComponents = {
BootErrorPage: ComponentType<BootErrorPageProps>;
Progress: ComponentType<{}>;
Router: ComponentType<{}>;
ErrorBoundaryFallback: ComponentType<ErrorBoundaryFallbackProps>;
SignInPage?: ComponentType<SignInPageProps>;
};
@@ -238,6 +239,13 @@ export type ErrorApi = {
// @public (undocumented)
export const errorApiRef: ApiRef<ErrorApi>;
// @public (undocumented)
export type ErrorBoundaryFallbackProps = {
plugin?: BackstagePlugin;
error: Error;
resetError: () => void;
};
// @public
export type ErrorContext = {
hidden?: boolean;
@@ -50,11 +50,18 @@ export type SignInPageProps = {
onResult(result: SignInResult): void;
};
export type ErrorBoundaryFallbackProps = {
plugin?: BackstagePlugin;
error: Error;
resetError: () => void;
};
export type AppComponents = {
NotFoundErrorPage: ComponentType<{}>;
BootErrorPage: ComponentType<BootErrorPageProps>;
Progress: ComponentType<{}>;
Router: ComponentType<{}>;
ErrorBoundaryFallback: ComponentType<ErrorBoundaryFallbackProps>;
/**
* An optional sign-in page that will be rendered instead of the AppRouter at startup.
@@ -0,0 +1,56 @@
/*
* Copyright 2021 Spotify AB
*
* 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 React from 'react';
import { AppContext } from '../app/types';
import { BackstagePlugin } from '../plugin';
type Props = {
app: AppContext;
plugin: BackstagePlugin;
};
type State = { error: Error | undefined };
export class PluginErrorBoundary extends React.Component<Props, State> {
static getDerivedStateFromError(error: Error) {
return { error };
}
state: State = { error: undefined };
handleErrorReset = () => {
this.setState({ error: undefined });
};
render() {
const { error } = this.state;
const { app, plugin } = this.props;
const { ErrorBoundaryFallback } = app.getComponents();
if (error) {
return (
<ErrorBoundaryFallback
error={error}
resetError={this.handleErrorReset}
plugin={plugin}
/>
);
}
return this.props.children;
}
}
@@ -14,7 +14,10 @@
* limitations under the License.
*/
import { withLogCollector } from '@backstage/test-utils-core';
import { render, screen } from '@testing-library/react';
import React from 'react';
import { useApp, ErrorBoundaryFallbackProps } from '../app';
import { createPlugin } from '../plugin';
import { createRouteRef } from '../routing';
import { getComponentData } from './componentData';
@@ -24,6 +27,10 @@ import {
createRoutableExtension,
} from './extensions';
jest.mock('../app');
const mocked = (f: Function) => f as jest.Mock;
const plugin = createPlugin({
id: 'my-plugin',
});
@@ -73,4 +80,31 @@ describe('extensions', () => {
expect(getComponentData(element2, 'core.plugin')).toBe(plugin);
expect(getComponentData(element2, 'core.mountPoint')).toBe(routeRef);
});
it('should wrap extended component with error boundary', async () => {
const BrokenComponent = plugin.provide(
createComponentExtension({
component: {
sync: () => {
throw new Error('Test error');
},
},
}),
);
mocked(useApp).mockReturnValue({
getComponents: () => ({
Progress: () => null,
ErrorBoundaryFallback: (props: ErrorBoundaryFallbackProps) => (
<>Error in {props.plugin?.getId()}</>
),
}),
});
const { error: errors } = await withLogCollector(['error'], async () => {
render(<BrokenComponent />);
});
screen.getByText('Error in my-plugin');
expect(errors[0]).toMatch('Test error');
});
});
@@ -19,6 +19,7 @@ import { useApp } from '../app';
import { RouteRef, useRouteRef } from '../routing';
import { attachComponentData } from './componentData';
import { Extension, BackstagePlugin } from '../plugin/types';
import { PluginErrorBoundary } from './PluginErrorBoundary';
type ComponentLoader<T> =
| {
@@ -123,11 +124,18 @@ export function createReactExtension<
return {
expose(plugin: BackstagePlugin<any, any>) {
const Result: any = (props: any) => (
<Suspense fallback="...">
<Component {...props} />
</Suspense>
);
const Result: any = (props: any) => {
const app = useApp();
const { Progress } = app.getComponents();
return (
<Suspense fallback={<Progress />}>
<PluginErrorBoundary app={app} plugin={plugin}>
<Component {...props} />
</PluginErrorBoundary>
</Suspense>
);
};
attachComponentData(Result, 'core.plugin', plugin);
for (const [key, value] of Object.entries(data)) {