Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(error-boundary): add support to override error handling #7761

Merged
merged 14 commits into from
Jan 24, 2022
1 change: 0 additions & 1 deletion config/jest/jest.unit.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ module.exports = {
'**/test/unit/*.js?(x)',
'**/test/unit/**/*.js?(x)',
],
// testMatch: ['**/test/unit/core/plugins/auth/actions.js'],
setupFilesAfterEnv: ['<rootDir>/test/unit/setup.js'],
testPathIgnorePatterns: [
'<rootDir>/node_modules/',
Expand Down
165 changes: 165 additions & 0 deletions docs/customization/plug-points.md
Original file line number Diff line number Diff line change
Expand Up @@ -233,3 +233,168 @@ const ui = SwaggerUIBundle({
...snippetConfig,
})
```

### Error handling

SwaggerUI comes with a `safe-render` plugin that handles error handling allows plugging into error handling system and modify it.

The plugin accepts a list of component names that should be protected by error boundaries.

Its public API looks like this:

```js
{
fn: {
componentDidCatch,
withErrorBoundary: withErrorBoundary(getSystem),
},
components: {
ErrorBoundary,
Fallback,
},
}
```

safe-render plugin is automatically utilized by [base](https://github.com/swagger-api/swagger-ui/blob/78f62c300a6d137e65fd027d850981b010009970/src/core/presets/base.js) and [standalone](https://github.com/swagger-api/swagger-ui/tree/78f62c300a6d137e65fd027d850981b010009970/src/standalone) SwaggerUI presets and
should always be used as the last plugin, after all the components are already known to the SwaggerUI.
The plugin defines a default list of components that should be protected by error boundaries:

```js
[
"App",
"BaseLayout",
"VersionPragmaFilter",
"InfoContainer",
"ServersContainer",
"SchemesContainer",
"AuthorizeBtnContainer",
"FilterContainer",
"Operations",
"OperationContainer",
"parameters",
"responses",
"OperationServers",
"Models",
"ModelWrapper",
"Topbar",
"StandaloneLayout",
"onlineValidatorBadge"
]
```

As demonstrated below, additional components can be protected by utilizing the safe-render plugin
with configuration options. This gets really handy if you are a SwaggerUI integrator and you maintain a number of
plugins with additional custom components.

```js
const swaggerUI = SwaggerUI({
url: "https://petstore.swagger.io/v2/swagger.json",
dom_id: '#swagger-ui',
plugins: [
() => ({
components: {
MyCustomComponent1: () => 'my custom component',
},
}),
SwaggerUI.plugins.SafeRender({
fullOverride: true, // only the component list defined here will apply (not the default list)
componentList: [
"MyCustomComponent1",
],
}),
],
});
```

##### componentDidCatch

This static function is invoked after a component has thrown an error.
It receives two parameters:

1. `error` - The error that was thrown.
2. `info` - An object with a componentStack key containing [information about which component threw the error](https://reactjs.org/docs/error-boundaries.html#component-stack-traces).

It has precisely the same signature as error boundaries [componentDidCatch lifecycle method](https://reactjs.org/docs/react-component.html#componentdidcatch),
except it's a static function and not a class method.

Default implement of componentDidCatch uses `console.error` to display the received error:

```js
export const componentDidCatch = console.error;
```

To utilize your own error handling logic (e.g. [bugsnag](https://www.bugsnag.com/)), create new SwaggerUI plugin that overrides componentDidCatch:

{% highlight js linenos %}
const BugsnagErrorHandlerPlugin = () => {
// init bugsnag

return {
fn: {
componentDidCatch = (error, info) => {
Bugsnag.notify(error);
Bugsnag.notify(info);
},
},
};
};
{% endhighlight %}

##### withErrorBoundary

This function is HOC (Higher Order Component). It wraps a particular component into the `ErrorBoundary` component.
It can be overridden via a plugin system to control how components are wrapped by the ErrorBoundary component.
In 99.9% of situations, you won't need to override this function, but if you do, please read the source code of this function first.

##### Fallback

The component is displayed when the error boundary catches an error. It can be overridden via a plugin system.
Its default implementation is trivial:

```js
import React from "react"
import PropTypes from "prop-types"

const Fallback = ({ name }) => (
<div className="fallback">
😱 <i>Could not render { name === "t" ? "this component" : name }, see the console.</i>
</div>
)
Fallback.propTypes = {
name: PropTypes.string.isRequired,
}
export default Fallback
```

Feel free to override it to match your look & feel:

```js
const CustomFallbackPlugin = () => ({
components: {
Fallback: ({ name } ) => `This is my custom fallback. ${name} failed to render`,
},
});

const swaggerUI = SwaggerUI({
url: "https://petstore.swagger.io/v2/swagger.json",
dom_id: '#swagger-ui',
plugins: [
CustomFallbackPlugin,
]
});
```

##### ErrorBoundary

This is the component that implements React error boundaries. Uses `componentDidCatch` and `Fallback`
under the hood. In 99.9% of situations, you won't need to override this component, but if you do,
please read the source code of this component first.


##### Change in behavior

In prior releases of SwaggerUI (before v4.3.0), almost all components have been protected, and when thrown error,
`Fallback` component was displayed. This changes with SwaggerUI v4.3.0. Only components defined
by the `safe-render` plugin are now protected and display fallback. If a small component somewhere within
SwaggerUI React component tree fails to render and throws an error. The error bubbles up to the closest
error boundary, and that error boundary displays the `Fallback` component and invokes `componentDidCatch`.
2 changes: 1 addition & 1 deletion src/core/components/highlight-code.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ const HighlightCode = ({value, fileName, className, downloadable, getConfigs, ca
}

const handlePreventYScrollingBeyondElement = (e) => {
const { target, deltaY } = e
const { target, deltaY } = e
const { scrollHeight: contentHeight, offsetHeight: visibleHeight, scrollTop } = target
const scrollOffset = visibleHeight + scrollTop
const isElementScrollable = contentHeight > visibleHeight
Expand Down
65 changes: 31 additions & 34 deletions src/core/components/layouts/base.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ export default class BaseLayout extends React.Component {
const SchemesContainer = getComponent("SchemesContainer", true)
const AuthorizeBtnContainer = getComponent("AuthorizeBtnContainer", true)
const FilterContainer = getComponent("FilterContainer", true)
const ErrorBoundary = getComponent("ErrorBoundary", true)
let isSwagger2 = specSelectors.isSwagger2()
let isOAS3 = specSelectors.isOAS3()

Expand Down Expand Up @@ -87,40 +86,38 @@ export default class BaseLayout extends React.Component {

return (
<div className='swagger-ui'>
<ErrorBoundary targetName="BaseLayout">
<SvgAssets />
<VersionPragmaFilter isSwagger2={isSwagger2} isOAS3={isOAS3} alsoShow={<Errors/>}>
<Errors/>
<Row className="information-container">
<Col mobile={12}>
<InfoContainer/>
<SvgAssets />
<VersionPragmaFilter isSwagger2={isSwagger2} isOAS3={isOAS3} alsoShow={<Errors/>}>
<Errors/>
<Row className="information-container">
<Col mobile={12}>
<InfoContainer/>
</Col>
</Row>

{hasServers || hasSchemes || hasSecurityDefinitions ? (
<div className="scheme-container">
<Col className="schemes wrapper" mobile={12}>
{hasServers ? (<ServersContainer />) : null}
{hasSchemes ? (<SchemesContainer />) : null}
{hasSecurityDefinitions ? (<AuthorizeBtnContainer />) : null}
</Col>
</Row>

{hasServers || hasSchemes || hasSecurityDefinitions ? (
<div className="scheme-container">
<Col className="schemes wrapper" mobile={12}>
{hasServers ? (<ServersContainer />) : null}
{hasSchemes ? (<SchemesContainer />) : null}
{hasSecurityDefinitions ? (<AuthorizeBtnContainer />) : null}
</Col>
</div>
) : null}

<FilterContainer/>

<Row>
<Col mobile={12} desktop={12} >
<Operations/>
</Col>
</Row>
<Row>
<Col mobile={12} desktop={12} >
<Models/>
</Col>
</Row>
</VersionPragmaFilter>
</ErrorBoundary>
</div>
) : null}

<FilterContainer/>

<Row>
<Col mobile={12} desktop={12} >
<Operations/>
</Col>
</Row>
<Row>
<Col mobile={12} desktop={12} >
<Models/>
</Col>
</Row>
</VersionPragmaFilter>
</div>
)
}
Expand Down
3 changes: 3 additions & 0 deletions src/core/plugins/all.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { pascalCaseFilename } from "core/utils"
import SafeRender from "core/plugins/safe-render"

const request = require.context(".", true, /\.jsx?$/)

Expand All @@ -18,4 +19,6 @@ request.keys().forEach( function( key ){
allPlugins[pascalCaseFilename(key)] = mod.default ? mod.default : mod
})

allPlugins.SafeRender = SafeRender

export default allPlugins
2 changes: 1 addition & 1 deletion src/core/plugins/oas3/components/request-body.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ const RequestBody = ({
const sampleForMediaType = rawExamplesOfMediaType?.map((container, key) => {
const val = container?.get("value", null)
if(val) {
container = container.set("value", getDefaultRequestBodyValue(
container = container.set("value", getDefaultRequestBodyValue(
requestBody,
contentType,
key,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,27 +1,28 @@
import PropTypes from "prop-types"
import React, { Component } from "react"

import { componentDidCatch } from "../fn"
import Fallback from "./fallback"

export class ErrorBoundary extends Component {
constructor(props) {
super(props)
this.state = { hasError: false, error: null }
}

static getDerivedStateFromError(error) {
return { hasError: true, error }
}

constructor(...args) {
super(...args)
this.state = { hasError: false, error: null }
}

componentDidCatch(error, errorInfo) {
console.error(error, errorInfo) // eslint-disable-line no-console
this.props.fn.componentDidCatch(error, errorInfo)
}

render() {
const { getComponent, targetName, children } = this.props
const FallbackComponent = getComponent("Fallback")

if (this.state.hasError) {
const FallbackComponent = getComponent("Fallback")
return <FallbackComponent name={targetName} />
}

Expand All @@ -31,6 +32,7 @@ export class ErrorBoundary extends Component {
ErrorBoundary.propTypes = {
targetName: PropTypes.string,
getComponent: PropTypes.func,
fn: PropTypes.object,
children: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.node),
PropTypes.node,
Expand All @@ -39,6 +41,9 @@ ErrorBoundary.propTypes = {
ErrorBoundary.defaultProps = {
targetName: "this component",
getComponent: () => Fallback,
fn: {
componentDidCatch,
},
children: null,
}

Expand Down
32 changes: 32 additions & 0 deletions src/core/plugins/safe-render/fn.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import React, { Component } from "react"

export const componentDidCatch = console.error

const isClassComponent = component => component.prototype && component.prototype.isReactComponent

export const withErrorBoundary = (getSystem) => (WrappedComponent) => {
const { getComponent, fn } = getSystem()
const ErrorBoundary = getComponent("ErrorBoundary")
const targetName = fn.getDisplayName(WrappedComponent)

class WithErrorBoundary extends Component {
render() {
return (
<ErrorBoundary targetName={targetName} getComponent={getComponent} fn={fn}>
<WrappedComponent {...this.props} {...this.context} />
</ErrorBoundary>
)
}
}
WithErrorBoundary.displayName = `WithErrorBoundary(${targetName})`
if (isClassComponent(WrappedComponent)) {
/**
* We need to handle case of class components defining a `mapStateToProps` public method.
* Components with `mapStateToProps` public method cannot be wrapped.
*/
WithErrorBoundary.prototype.mapStateToProps = WrappedComponent.prototype.mapStateToProps
}

return WithErrorBoundary
}