From 75b8537ce11757120f878ac7394ef4d1b2bab651 Mon Sep 17 00:00:00 2001 From: Juan Lulkin Date: Tue, 11 May 2021 13:34:43 +0200 Subject: [PATCH] Adds error boundary to extensions Signed-off-by: Juan Lulkin --- .changeset/few-cats-type.md | 33 ++++ packages/app/src/App.tsx | 4 + packages/core-api/src/app/App.test.tsx | 1 + packages/core-api/src/app/types.ts | 12 +- .../src/extensions/PluginErrorBoundary.tsx | 56 +++++++ .../src/extensions/extensions.test.tsx | 49 ++++++ .../core-api/src/extensions/extensions.tsx | 28 +++- packages/core-api/src/routing/hooks.test.tsx | 148 ++++++++++-------- packages/core/src/api-wrappers/createApp.tsx | 2 + .../ErrorBoundaryFallback.tsx | 41 +++++ .../components/ErrorBoundaryFallback/index.ts | 17 ++ .../ResponseErrorPanel/ResponseErrorPanel.tsx | 28 +++- .../components/WarningPanel/WarningPanel.tsx | 5 +- packages/core/src/components/index.ts | 1 + .../test-utils/src/testUtils/appWrappers.tsx | 1 + 15 files changed, 345 insertions(+), 81 deletions(-) create mode 100644 .changeset/few-cats-type.md create mode 100644 packages/core-api/src/extensions/PluginErrorBoundary.tsx create mode 100644 packages/core/src/components/ErrorBoundaryFallback/ErrorBoundaryFallback.tsx create mode 100644 packages/core/src/components/ErrorBoundaryFallback/index.ts diff --git a/.changeset/few-cats-type.md b/.changeset/few-cats-type.md new file mode 100644 index 0000000000..7d4c0f69cb --- /dev/null +++ b/.changeset/few-cats-type.md @@ -0,0 +1,33 @@ +--- +'@backstage/core': minor +'@backstage/core-api': patch +--- + +This change adds error boundary on extensions. + +This means 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 ( + <> +

Oops.

+

+ The plugin {props.plugin.getId()} failed with {props.error.message} +

+ + + ); + }, + }, +}); +``` + +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 mount the children of the error boundary again. +- `plugin`. A BackstagePlugin that can be used to lookup 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()`. diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index 5874465ef6..927e9b4327 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -20,6 +20,7 @@ import { FlatRoutes, OAuthRequestDialog, SignInPage, + ErrorBoundaryFallback, } from '@backstage/core'; import { apiDocsPlugin, ApiExplorerPage } from '@backstage/plugin-api-docs'; import { @@ -66,6 +67,9 @@ const app = createApp({ alert: AlarmIcon, }, components: { + ErrorBoundaryFallback: props => { + return ; + }, SignInPage: props => { return ( { BootErrorPage: () => null, Progress: () => null, Router: BrowserRouter, + ErrorBoundaryFallback: () => null, }; it('runs happy paths', async () => { diff --git a/packages/core-api/src/app/types.ts b/packages/core-api/src/app/types.ts index df0f65f33f..40e9913201 100644 --- a/packages/core-api/src/app/types.ts +++ b/packages/core-api/src/app/types.ts @@ -14,13 +14,14 @@ * limitations under the License. */ +import { AppConfig } from '@backstage/config'; import { ComponentType } from 'react'; +import { AppTheme, ProfileInfo } from '../apis/definitions'; +import { AnyApiFactory } from '../apis/system'; +import type { ErrorBoundaryFallbackProps } from '../extensions/PluginErrorBoundary'; import { IconComponent, IconComponentMap, IconKey } from '../icons/types'; import { AnyExternalRoutes, BackstagePlugin } from '../plugin/types'; import { ExternalRouteRef, RouteRef, SubRouteRef } from '../routing/types'; -import { AnyApiFactory } from '../apis/system'; -import { AppTheme, ProfileInfo } from '../apis/definitions'; -import { AppConfig } from '@backstage/config'; export type BootErrorPageProps = { step: 'load-config' | 'load-chunk'; @@ -58,6 +59,11 @@ export type AppComponents = { BootErrorPage: ComponentType; Progress: ComponentType<{}>; Router: ComponentType<{}>; + ErrorBoundaryFallback: ComponentType< + ErrorBoundaryFallbackProps & { + plugin: BackstagePlugin; + } + >; /** * An optional sign-in page that will be rendered instead of the AppRouter at startup. diff --git a/packages/core-api/src/extensions/PluginErrorBoundary.tsx b/packages/core-api/src/extensions/PluginErrorBoundary.tsx new file mode 100644 index 0000000000..4cf772a638 --- /dev/null +++ b/packages/core-api/src/extensions/PluginErrorBoundary.tsx @@ -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'; + +export type ErrorBoundaryFallbackProps = { + error: Error; + resetError: () => void; +}; + +type FallbackRender = React.FunctionComponent; + +type Props = { + fallbackRender: FallbackRender; +}; + +type State = { error?: Error }; + +export class PluginErrorBoundary extends React.Component { + static getDerivedStateFromError(error: Error) { + return { error }; + } + + state: State = { error: undefined }; + + resetError = () => { + this.setState({ error: undefined }); + }; + + render() { + const { error } = this.state; + const { fallbackRender } = this.props; + + if (error) { + return fallbackRender({ + error, + resetError: this.resetError, + }); + } + + return this.props.children; + } +} diff --git a/packages/core-api/src/extensions/extensions.test.tsx b/packages/core-api/src/extensions/extensions.test.tsx index 26755b3bcf..db67ed18c9 100644 --- a/packages/core-api/src/extensions/extensions.test.tsx +++ b/packages/core-api/src/extensions/extensions.test.tsx @@ -14,7 +14,13 @@ * limitations under the License. */ +import { errorApiRef } from '../apis/definitions/ErrorApi'; +import { ApiRegistry } from '../apis/system/ApiRegistry'; +import { ApiProvider } from '../apis/system/ApiProvider'; +import { withLogCollector } from '@backstage/test-utils-core'; +import { render, screen } from '@testing-library/react'; import React from 'react'; +import { AppContextProvider } from '../app/AppContext'; import { createPlugin } from '../plugin'; import { createRouteRef } from '../routing'; import { getComponentData } from './componentData'; @@ -23,6 +29,9 @@ import { createReactExtension, createRoutableExtension, } from './extensions'; +import { AppComponents } from '../app/types'; +import { AppContext } from '../app'; +import { MockErrorApi } from '@backstage/test-utils'; const plugin = createPlugin({ id: 'my-plugin', @@ -73,4 +82,44 @@ 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 extension = createComponentExtension({ + component: { + sync: () => { + throw new Error('Test error'); + }, + }, + }); + + const BrokenComponent = plugin.provide(extension); + + const errorApi = new MockErrorApi(); + + const apis = ApiRegistry.from([[errorApiRef, errorApi]]); + + const MockFallback: AppComponents['ErrorBoundaryFallback'] = props => ( + <>Error in {props.plugin.getId()} + ); + + const { error: errors } = await withLogCollector(['error'], async () => { + render( + + ({ + ErrorBoundaryFallback: MockFallback, + }), + } as unknown) as AppContext + } + > + + + , + ); + }); + screen.getByText('Error in my-plugin'); + expect(errors[0]).toMatch('Test error'); + }); }); diff --git a/packages/core-api/src/extensions/extensions.tsx b/packages/core-api/src/extensions/extensions.tsx index 9f6412f960..686c467caf 100644 --- a/packages/core-api/src/extensions/extensions.tsx +++ b/packages/core-api/src/extensions/extensions.tsx @@ -19,6 +19,7 @@ import { useApp } from '../app'; import { BackstagePlugin, Extension } from '../plugin/types'; import { RouteRef, useRouteRef } from '../routing'; import { attachComponentData } from './componentData'; +import { PluginErrorBoundary } from './PluginErrorBoundary'; type ComponentLoader = | { @@ -123,11 +124,28 @@ export function createReactExtension< return { expose(plugin: BackstagePlugin) { - const Result: any = (props: any) => ( - - - - ); + const Result: any = (props: any) => { + const app = useApp(); + const { ErrorBoundaryFallback } = app.getComponents(); + + return ( + + { + return ( + + ); + }} + > + + + + ); + }; attachComponentData(Result, 'core.plugin', plugin); for (const [key, value] of Object.entries(data)) { diff --git a/packages/core-api/src/routing/hooks.test.tsx b/packages/core-api/src/routing/hooks.test.tsx index 8a9b70ed7d..a8fdfda5d0 100644 --- a/packages/core-api/src/routing/hooks.test.tsx +++ b/packages/core-api/src/routing/hooks.test.tsx @@ -14,6 +14,7 @@ * limitations under the License. */ +import { AppContext } from '../app'; import { render } from '@testing-library/react'; import { renderHook } from '@testing-library/react-hooks'; import React, { @@ -23,6 +24,7 @@ import React, { useContext, } from 'react'; import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { AppContextProvider } from '../app/AppContext'; import { createRoutableExtension } from '../extensions'; import { childDiscoverer, @@ -154,23 +156,27 @@ function withRoutingProvider( ); } +const appContext = ({ getComponents: () => ({}) } as unknown) as AppContext; + describe('discovery', () => { it('should handle simple routeRef path creation for routeRefs used in other parts of the app', async () => { const root = ( - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + ); const rendered = render( @@ -205,23 +211,25 @@ describe('discovery', () => { it('should handle routeRefs with parameters', async () => { const root = ( - - - - - - - - + + + + + + + + + + ); const rendered = render(withRoutingProvider(root)); @@ -236,8 +244,37 @@ describe('discovery', () => { it('should handle relative routing within parameterized routePaths', async () => { const root = ( - - + + + + + + + + + + + + + + + ); + + const rendered = render(withRoutingProvider(root)); + + await expect( + rendered.findByText('Path at inside: /foo/blob/baz'), + ).resolves.toBeInTheDocument(); + }); + + it('should throw errors for routing to other routeRefs with unsupported parameters', () => { + const root = ( + + @@ -250,33 +287,8 @@ describe('discovery', () => { routeRef={ref3} params={{ id: 'blob' }} /> - - - ); - - const rendered = render(withRoutingProvider(root)); - - await expect( - rendered.findByText('Path at inside: /foo/blob/baz'), - ).resolves.toBeInTheDocument(); - }); - - it('should throw errors for routing to other routeRefs with unsupported parameters', () => { - const root = ( - - - - - - - - - - + + ); const rendered = render(withRoutingProvider(root)); @@ -295,13 +307,15 @@ describe('discovery', () => { it('should handle relative routing of parameterized routePaths with duplicate param names', () => { const root = ( - - - - - - - + + + + + + + + + ); const { routePaths, routeParents } = traverseElementTree({ diff --git a/packages/core/src/api-wrappers/createApp.tsx b/packages/core/src/api-wrappers/createApp.tsx index e79cdf509f..9b8524c89c 100644 --- a/packages/core/src/api-wrappers/createApp.tsx +++ b/packages/core/src/api-wrappers/createApp.tsx @@ -30,6 +30,7 @@ import LightIcon from '@material-ui/icons/WbSunny'; import DarkIcon from '@material-ui/icons/Brightness2'; import { ErrorPage } from '../layout/ErrorPage'; import { Progress } from '../components/Progress'; +import { ErrorBoundaryFallback } from '../components/ErrorBoundaryFallback'; import { defaultApis } from './defaultApis'; import { lightTheme, darkTheme } from '@backstage/theme'; import { AppConfig, JsonObject } from '@backstage/config'; @@ -130,6 +131,7 @@ export function createApp(options?: AppOptions) { BootErrorPage: DefaultBootErrorPage, Progress: Progress, Router: BrowserRouter, + ErrorBoundaryFallback: ErrorBoundaryFallback, ...options?.components, }; const themes = options?.themes ?? [ diff --git a/packages/core/src/components/ErrorBoundaryFallback/ErrorBoundaryFallback.tsx b/packages/core/src/components/ErrorBoundaryFallback/ErrorBoundaryFallback.tsx new file mode 100644 index 0000000000..f1364061df --- /dev/null +++ b/packages/core/src/components/ErrorBoundaryFallback/ErrorBoundaryFallback.tsx @@ -0,0 +1,41 @@ +/* + * Copyright 2020 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. + */ + +// TODO align this design with the backend errors + +import React from 'react'; +import { AppComponents } from '../..'; +import { ResponseErrorPanel } from '../ResponseErrorPanel'; +import { Button } from '@material-ui/core'; + +export const ErrorBoundaryFallback: AppComponents['ErrorBoundaryFallback'] = ({ + error, + resetError, + plugin, +}) => { + return ( + + Retry + + } + /> + ); +}; diff --git a/packages/core/src/components/ErrorBoundaryFallback/index.ts b/packages/core/src/components/ErrorBoundaryFallback/index.ts new file mode 100644 index 0000000000..ff40974ed7 --- /dev/null +++ b/packages/core/src/components/ErrorBoundaryFallback/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 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 { ErrorBoundaryFallback } from './ErrorBoundaryFallback'; diff --git a/packages/core/src/components/ResponseErrorPanel/ResponseErrorPanel.tsx b/packages/core/src/components/ResponseErrorPanel/ResponseErrorPanel.tsx index 52f7ab03f9..42adf1980c 100644 --- a/packages/core/src/components/ResponseErrorPanel/ResponseErrorPanel.tsx +++ b/packages/core/src/components/ResponseErrorPanel/ResponseErrorPanel.tsx @@ -45,6 +45,7 @@ type ResponseErrorListProps = { request?: string; stack?: string; json?: string; + actions?: React.ReactNode; }; const ResponseErrorList = ({ @@ -53,6 +54,7 @@ const ResponseErrorList = ({ message, stack, json, + actions, }: ResponseErrorListProps) => { const classes = useStyles(); @@ -107,12 +109,21 @@ const ResponseErrorList = ({ )} + {actions && ( + <> + + {actions} + + )} ); }; type Props = { error: Error; + defaultExpanded?: boolean; + title?: string; + actions?: React.ReactNode; }; /** @@ -121,13 +132,14 @@ type Props = { * Has special treatment for ResponseError errors, to display rich * server-provided information about what happened. */ -export const ResponseErrorDetails = ({ error }: Props) => { +export const ResponseErrorDetails = ({ error, actions }: Props) => { if (error.name !== 'ResponseError') { return ( ); } @@ -158,10 +170,18 @@ export const ResponseErrorDetails = ({ error }: Props) => { * Has special treatment for ResponseError errors, to display rich * server-provided information about what happened. */ -export const ResponseErrorPanel = ({ error }: Props) => { +export const ResponseErrorPanel = ({ + title, + error, + defaultExpanded, + actions, +}: Props) => { return ( - - + + ); }; diff --git a/packages/core/src/components/WarningPanel/WarningPanel.tsx b/packages/core/src/components/WarningPanel/WarningPanel.tsx index 1faf3bb142..20c71ee106 100644 --- a/packages/core/src/components/WarningPanel/WarningPanel.tsx +++ b/packages/core/src/components/WarningPanel/WarningPanel.tsx @@ -77,6 +77,7 @@ type Props = { title?: string; severity?: 'warning' | 'error' | 'info'; message?: React.ReactNode; + defaultExpanded?: boolean; children?: React.ReactNode; }; @@ -96,14 +97,14 @@ const capitalize = (s: string) => { */ 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 ( - + } className={classes.summary} diff --git a/packages/core/src/components/index.ts b/packages/core/src/components/index.ts index 14f7f3cac0..c8f9803eae 100644 --- a/packages/core/src/components/index.ts +++ b/packages/core/src/components/index.ts @@ -23,6 +23,7 @@ export * from './CopyTextButton'; export * from './DependencyGraph'; export * from './DismissableBanner'; export * from './EmptyState'; +export * from './ErrorBoundaryFallback'; export * from './ResponseErrorPanel'; export * from './FeatureDiscovery'; export * from './HeaderIconLinkRow'; diff --git a/packages/test-utils/src/testUtils/appWrappers.tsx b/packages/test-utils/src/testUtils/appWrappers.tsx index add7b4479c..0086bf3293 100644 --- a/packages/test-utils/src/testUtils/appWrappers.tsx +++ b/packages/test-utils/src/testUtils/appWrappers.tsx @@ -96,6 +96,7 @@ export function wrapInTestApp( Router: ({ children }) => ( ), + ErrorBoundaryFallback: () => null, }, icons: defaultSystemIcons, plugins: [],