Skip to content

Commit 8b1c4a7

Browse files
authored
feat(error-handling): introduce unified and configurable error handling (#7761)
Refs #7778
1 parent 4f2287f commit 8b1c4a7

File tree

19 files changed

+631
-297
lines changed

19 files changed

+631
-297
lines changed

config/jest/jest.unit.config.js

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ module.exports = {
77
'**/test/unit/*.js?(x)',
88
'**/test/unit/**/*.js?(x)',
99
],
10-
// testMatch: ['**/test/unit/core/plugins/auth/actions.js'],
1110
setupFilesAfterEnv: ['<rootDir>/test/unit/setup.js'],
1211
testPathIgnorePatterns: [
1312
'<rootDir>/node_modules/',

docs/customization/plug-points.md

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,3 +233,168 @@ const ui = SwaggerUIBundle({
233233
...snippetConfig,
234234
})
235235
```
236+
237+
### Error handling
238+
239+
SwaggerUI comes with a `safe-render` plugin that handles error handling allows plugging into error handling system and modify it.
240+
241+
The plugin accepts a list of component names that should be protected by error boundaries.
242+
243+
Its public API looks like this:
244+
245+
```js
246+
{
247+
fn: {
248+
componentDidCatch,
249+
withErrorBoundary: withErrorBoundary(getSystem),
250+
},
251+
components: {
252+
ErrorBoundary,
253+
Fallback,
254+
},
255+
}
256+
```
257+
258+
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
259+
should always be used as the last plugin, after all the components are already known to the SwaggerUI.
260+
The plugin defines a default list of components that should be protected by error boundaries:
261+
262+
```js
263+
[
264+
"App",
265+
"BaseLayout",
266+
"VersionPragmaFilter",
267+
"InfoContainer",
268+
"ServersContainer",
269+
"SchemesContainer",
270+
"AuthorizeBtnContainer",
271+
"FilterContainer",
272+
"Operations",
273+
"OperationContainer",
274+
"parameters",
275+
"responses",
276+
"OperationServers",
277+
"Models",
278+
"ModelWrapper",
279+
"Topbar",
280+
"StandaloneLayout",
281+
"onlineValidatorBadge"
282+
]
283+
```
284+
285+
As demonstrated below, additional components can be protected by utilizing the safe-render plugin
286+
with configuration options. This gets really handy if you are a SwaggerUI integrator and you maintain a number of
287+
plugins with additional custom components.
288+
289+
```js
290+
const swaggerUI = SwaggerUI({
291+
url: "https://petstore.swagger.io/v2/swagger.json",
292+
dom_id: '#swagger-ui',
293+
plugins: [
294+
() => ({
295+
components: {
296+
MyCustomComponent1: () => 'my custom component',
297+
},
298+
}),
299+
SwaggerUI.plugins.SafeRender({
300+
fullOverride: true, // only the component list defined here will apply (not the default list)
301+
componentList: [
302+
"MyCustomComponent1",
303+
],
304+
}),
305+
],
306+
});
307+
```
308+
309+
##### componentDidCatch
310+
311+
This static function is invoked after a component has thrown an error.
312+
It receives two parameters:
313+
314+
1. `error` - The error that was thrown.
315+
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).
316+
317+
It has precisely the same signature as error boundaries [componentDidCatch lifecycle method](https://reactjs.org/docs/react-component.html#componentdidcatch),
318+
except it's a static function and not a class method.
319+
320+
Default implement of componentDidCatch uses `console.error` to display the received error:
321+
322+
```js
323+
export const componentDidCatch = console.error;
324+
```
325+
326+
To utilize your own error handling logic (e.g. [bugsnag](https://www.bugsnag.com/)), create new SwaggerUI plugin that overrides componentDidCatch:
327+
328+
{% highlight js linenos %}
329+
const BugsnagErrorHandlerPlugin = () => {
330+
// init bugsnag
331+
332+
return {
333+
fn: {
334+
componentDidCatch = (error, info) => {
335+
Bugsnag.notify(error);
336+
Bugsnag.notify(info);
337+
},
338+
},
339+
};
340+
};
341+
{% endhighlight %}
342+
343+
##### withErrorBoundary
344+
345+
This function is HOC (Higher Order Component). It wraps a particular component into the `ErrorBoundary` component.
346+
It can be overridden via a plugin system to control how components are wrapped by the ErrorBoundary component.
347+
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.
348+
349+
##### Fallback
350+
351+
The component is displayed when the error boundary catches an error. It can be overridden via a plugin system.
352+
Its default implementation is trivial:
353+
354+
```js
355+
import React from "react"
356+
import PropTypes from "prop-types"
357+
358+
const Fallback = ({ name }) => (
359+
<div className="fallback">
360+
😱 <i>Could not render { name === "t" ? "this component" : name }, see the console.</i>
361+
</div>
362+
)
363+
Fallback.propTypes = {
364+
name: PropTypes.string.isRequired,
365+
}
366+
export default Fallback
367+
```
368+
369+
Feel free to override it to match your look & feel:
370+
371+
```js
372+
const CustomFallbackPlugin = () => ({
373+
components: {
374+
Fallback: ({ name } ) => `This is my custom fallback. ${name} failed to render`,
375+
},
376+
});
377+
378+
const swaggerUI = SwaggerUI({
379+
url: "https://petstore.swagger.io/v2/swagger.json",
380+
dom_id: '#swagger-ui',
381+
plugins: [
382+
CustomFallbackPlugin,
383+
]
384+
});
385+
```
386+
387+
##### ErrorBoundary
388+
389+
This is the component that implements React error boundaries. Uses `componentDidCatch` and `Fallback`
390+
under the hood. In 99.9% of situations, you won't need to override this component, but if you do,
391+
please read the source code of this component first.
392+
393+
394+
##### Change in behavior
395+
396+
In prior releases of SwaggerUI (before v4.3.0), almost all components have been protected, and when thrown error,
397+
`Fallback` component was displayed. This changes with SwaggerUI v4.3.0. Only components defined
398+
by the `safe-render` plugin are now protected and display fallback. If a small component somewhere within
399+
SwaggerUI React component tree fails to render and throws an error. The error bubbles up to the closest
400+
error boundary, and that error boundary displays the `Fallback` component and invokes `componentDidCatch`.

src/core/components/highlight-code.jsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ const HighlightCode = ({value, fileName, className, downloadable, getConfigs, ca
3131
}
3232

3333
const handlePreventYScrollingBeyondElement = (e) => {
34-
const { target, deltaY } = e
34+
const { target, deltaY } = e
3535
const { scrollHeight: contentHeight, offsetHeight: visibleHeight, scrollTop } = target
3636
const scrollOffset = visibleHeight + scrollTop
3737
const isElementScrollable = contentHeight > visibleHeight

src/core/components/layouts/base.jsx

Lines changed: 31 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@ export default class BaseLayout extends React.Component {
2828
const SchemesContainer = getComponent("SchemesContainer", true)
2929
const AuthorizeBtnContainer = getComponent("AuthorizeBtnContainer", true)
3030
const FilterContainer = getComponent("FilterContainer", true)
31-
const ErrorBoundary = getComponent("ErrorBoundary", true)
3231
let isSwagger2 = specSelectors.isSwagger2()
3332
let isOAS3 = specSelectors.isOAS3()
3433

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

8887
return (
8988
<div className='swagger-ui'>
90-
<ErrorBoundary targetName="BaseLayout">
91-
<SvgAssets />
92-
<VersionPragmaFilter isSwagger2={isSwagger2} isOAS3={isOAS3} alsoShow={<Errors/>}>
93-
<Errors/>
94-
<Row className="information-container">
95-
<Col mobile={12}>
96-
<InfoContainer/>
89+
<SvgAssets />
90+
<VersionPragmaFilter isSwagger2={isSwagger2} isOAS3={isOAS3} alsoShow={<Errors/>}>
91+
<Errors/>
92+
<Row className="information-container">
93+
<Col mobile={12}>
94+
<InfoContainer/>
95+
</Col>
96+
</Row>
97+
98+
{hasServers || hasSchemes || hasSecurityDefinitions ? (
99+
<div className="scheme-container">
100+
<Col className="schemes wrapper" mobile={12}>
101+
{hasServers ? (<ServersContainer />) : null}
102+
{hasSchemes ? (<SchemesContainer />) : null}
103+
{hasSecurityDefinitions ? (<AuthorizeBtnContainer />) : null}
97104
</Col>
98-
</Row>
99-
100-
{hasServers || hasSchemes || hasSecurityDefinitions ? (
101-
<div className="scheme-container">
102-
<Col className="schemes wrapper" mobile={12}>
103-
{hasServers ? (<ServersContainer />) : null}
104-
{hasSchemes ? (<SchemesContainer />) : null}
105-
{hasSecurityDefinitions ? (<AuthorizeBtnContainer />) : null}
106-
</Col>
107-
</div>
108-
) : null}
109-
110-
<FilterContainer/>
111-
112-
<Row>
113-
<Col mobile={12} desktop={12} >
114-
<Operations/>
115-
</Col>
116-
</Row>
117-
<Row>
118-
<Col mobile={12} desktop={12} >
119-
<Models/>
120-
</Col>
121-
</Row>
122-
</VersionPragmaFilter>
123-
</ErrorBoundary>
105+
</div>
106+
) : null}
107+
108+
<FilterContainer/>
109+
110+
<Row>
111+
<Col mobile={12} desktop={12} >
112+
<Operations/>
113+
</Col>
114+
</Row>
115+
<Row>
116+
<Col mobile={12} desktop={12} >
117+
<Models/>
118+
</Col>
119+
</Row>
120+
</VersionPragmaFilter>
124121
</div>
125122
)
126123
}

src/core/plugins/all.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { pascalCaseFilename } from "core/utils"
2+
import SafeRender from "core/plugins/safe-render"
23

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

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

22+
allPlugins.SafeRender = SafeRender
23+
2124
export default allPlugins

src/core/plugins/oas3/components/request-body.jsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ const RequestBody = ({
8888
const sampleForMediaType = rawExamplesOfMediaType?.map((container, key) => {
8989
const val = container?.get("value", null)
9090
if(val) {
91-
container = container.set("value", getDefaultRequestBodyValue(
91+
container = container.set("value", getDefaultRequestBodyValue(
9292
requestBody,
9393
contentType,
9494
key,

src/core/plugins/view/error-boundary.jsx renamed to src/core/plugins/safe-render/components/error-boundary.jsx

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,28 @@
11
import PropTypes from "prop-types"
22
import React, { Component } from "react"
33

4+
import { componentDidCatch } from "../fn"
45
import Fallback from "./fallback"
56

67
export class ErrorBoundary extends Component {
7-
constructor(props) {
8-
super(props)
9-
this.state = { hasError: false, error: null }
10-
}
11-
128
static getDerivedStateFromError(error) {
139
return { hasError: true, error }
1410
}
1511

12+
constructor(...args) {
13+
super(...args)
14+
this.state = { hasError: false, error: null }
15+
}
16+
1617
componentDidCatch(error, errorInfo) {
17-
console.error(error, errorInfo) // eslint-disable-line no-console
18+
this.props.fn.componentDidCatch(error, errorInfo)
1819
}
1920

2021
render() {
2122
const { getComponent, targetName, children } = this.props
22-
const FallbackComponent = getComponent("Fallback")
2323

2424
if (this.state.hasError) {
25+
const FallbackComponent = getComponent("Fallback")
2526
return <FallbackComponent name={targetName} />
2627
}
2728

@@ -31,6 +32,7 @@ export class ErrorBoundary extends Component {
3132
ErrorBoundary.propTypes = {
3233
targetName: PropTypes.string,
3334
getComponent: PropTypes.func,
35+
fn: PropTypes.object,
3436
children: PropTypes.oneOfType([
3537
PropTypes.arrayOf(PropTypes.node),
3638
PropTypes.node,
@@ -39,6 +41,9 @@ ErrorBoundary.propTypes = {
3941
ErrorBoundary.defaultProps = {
4042
targetName: "this component",
4143
getComponent: () => Fallback,
44+
fn: {
45+
componentDidCatch,
46+
},
4247
children: null,
4348
}
4449

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import React, { Component } from "react"
2+
3+
export const componentDidCatch = console.error
4+
5+
const isClassComponent = component => component.prototype && component.prototype.isReactComponent
6+
7+
export const withErrorBoundary = (getSystem) => (WrappedComponent) => {
8+
const { getComponent, fn } = getSystem()
9+
const ErrorBoundary = getComponent("ErrorBoundary")
10+
const targetName = fn.getDisplayName(WrappedComponent)
11+
12+
class WithErrorBoundary extends Component {
13+
render() {
14+
return (
15+
<ErrorBoundary targetName={targetName} getComponent={getComponent} fn={fn}>
16+
<WrappedComponent {...this.props} {...this.context} />
17+
</ErrorBoundary>
18+
)
19+
}
20+
}
21+
WithErrorBoundary.displayName = `WithErrorBoundary(${targetName})`
22+
if (isClassComponent(WrappedComponent)) {
23+
/**
24+
* We need to handle case of class components defining a `mapStateToProps` public method.
25+
* Components with `mapStateToProps` public method cannot be wrapped.
26+
*/
27+
WithErrorBoundary.prototype.mapStateToProps = WrappedComponent.prototype.mapStateToProps
28+
}
29+
30+
return WithErrorBoundary
31+
}
32+

0 commit comments

Comments
 (0)