fix: introduce Error Boundaries to handle unexpected failures (#7671)

Two new components have been updated via plugin system: ErrorBoundary and Fallback.
These components can be overridden by user plugins.

Refs #7647
This commit is contained in:
Vladimir Gorej
2021-11-25 13:47:22 +01:00
committed by GitHub
parent fd22564598
commit b299be764f
8 changed files with 157 additions and 89 deletions

View File

@@ -18,6 +18,7 @@ module.exports = {
'<rootDir>/test/unit/components/online-validator-badge.jsx', '<rootDir>/test/unit/components/online-validator-badge.jsx',
'<rootDir>/test/unit/components/live-response.jsx', '<rootDir>/test/unit/components/live-response.jsx',
], ],
silent: true, // set to `false` to allow console.* calls to be printed
transformIgnorePatterns: [ transformIgnorePatterns: [
'/node_modules/(?!(react-syntax-highlighter)/)' '/node_modules/(?!(react-syntax-highlighter)/)'
] ]

View File

@@ -28,6 +28,7 @@ export default class BaseLayout extends React.Component {
const SchemesContainer = getComponent("SchemesContainer", true) const SchemesContainer = getComponent("SchemesContainer", true)
const AuthorizeBtnContainer = getComponent("AuthorizeBtnContainer", true) const AuthorizeBtnContainer = getComponent("AuthorizeBtnContainer", true)
const FilterContainer = getComponent("FilterContainer", true) const FilterContainer = getComponent("FilterContainer", true)
const ErrorBoundary = getComponent("ErrorBoundary", true)
let isSwagger2 = specSelectors.isSwagger2() let isSwagger2 = specSelectors.isSwagger2()
let isOAS3 = specSelectors.isOAS3() let isOAS3 = specSelectors.isOAS3()
@@ -36,7 +37,7 @@ export default class BaseLayout extends React.Component {
const loadingStatus = specSelectors.loadingStatus() const loadingStatus = specSelectors.loadingStatus()
let loadingMessage = null let loadingMessage = null
if(loadingStatus === "loading") { if(loadingStatus === "loading") {
loadingMessage = <div className="info"> loadingMessage = <div className="info">
<div className="loading-container"> <div className="loading-container">
@@ -85,8 +86,8 @@ export default class BaseLayout extends React.Component {
const hasSecurityDefinitions = !!specSelectors.securityDefinitions() const hasSecurityDefinitions = !!specSelectors.securityDefinitions()
return ( return (
<div className='swagger-ui'> <div className='swagger-ui'>
<ErrorBoundary targetName="BaseLayout">
<SvgAssets /> <SvgAssets />
<VersionPragmaFilter isSwagger2={isSwagger2} isOAS3={isOAS3} alsoShow={<Errors/>}> <VersionPragmaFilter isSwagger2={isSwagger2} isOAS3={isOAS3} alsoShow={<Errors/>}>
<Errors/> <Errors/>
@@ -119,7 +120,8 @@ export default class BaseLayout extends React.Component {
</Col> </Col>
</Row> </Row>
</VersionPragmaFilter> </VersionPragmaFilter>
</div> </ErrorBoundary>
) </div>
)
} }
} }

View File

@@ -0,0 +1,45 @@
import PropTypes from "prop-types"
import React, { Component } from "react"
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 }
}
componentDidCatch(error, errorInfo) {
console.error(error, errorInfo) // eslint-disable-line no-console
}
render() {
const { getComponent, targetName, children } = this.props
const FallbackComponent = getComponent("Fallback")
if (this.state.hasError) {
return <FallbackComponent name={targetName} />
}
return children
}
}
ErrorBoundary.propTypes = {
targetName: PropTypes.string,
getComponent: PropTypes.func,
children: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.node),
PropTypes.node,
])
}
ErrorBoundary.defaultProps = {
targetName: "this component",
getComponent: () => Fallback,
children: null,
}
export default ErrorBoundary

View File

@@ -0,0 +1,13 @@
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

View File

@@ -1,6 +1,9 @@
import * as rootInjects from "./root-injects" import * as rootInjects from "./root-injects"
import { memoize } from "core/utils" import { memoize } from "core/utils"
import ErrorBoundary from "./error-boundary"
import Fallback from "./fallback"
export default function({getComponents, getStore, getSystem}) { export default function({getComponents, getStore, getSystem}) {
let { getComponent, render, makeMappedContainer } = rootInjects let { getComponent, render, makeMappedContainer } = rootInjects
@@ -14,6 +17,10 @@ export default function({getComponents, getStore, getSystem}) {
getComponent: memGetComponent, getComponent: memGetComponent,
makeMappedContainer: memMakeMappedContainer, makeMappedContainer: memMakeMappedContainer,
render: render.bind(null, getSystem, getStore, getComponent, getComponents), render: render.bind(null, getSystem, getStore, getComponent, getComponents),
} },
components: {
ErrorBoundary,
Fallback,
},
} }
} }

View File

@@ -1,20 +1,24 @@
import React, { Component } from "react" import React, { Component } from "react"
import PropTypes from "prop-types"
import ReactDOM from "react-dom" import ReactDOM from "react-dom"
import { connect, Provider } from "react-redux" import { connect, Provider } from "react-redux"
import omit from "lodash/omit" import omit from "lodash/omit"
const SystemWrapper = (getSystem, ComponentToWrap ) => class extends Component { const SystemWrapper = (getSystem, ComponentToWrap ) => class extends Component {
render() { render() {
return <ComponentToWrap {...getSystem() } {...this.props} {...this.context} /> return <ComponentToWrap {...getSystem()} {...this.props} {...this.context} />
} }
} }
const RootWrapper = (reduxStore, ComponentToWrap) => class extends Component { const RootWrapper = (getSystem, reduxStore, ComponentToWrap) => class extends Component {
render() { render() {
const { getComponent } = getSystem()
const ErrorBoundary = getComponent("ErrorBoundary", true)
return ( return (
<Provider store={reduxStore}> <Provider store={reduxStore}>
<ComponentToWrap {...this.props} {...this.context} /> <ErrorBoundary targetName={ComponentToWrap?.name}>
<ComponentToWrap {...this.props} {...this.context} />
</ErrorBoundary>
</Provider> </Provider>
) )
} }
@@ -30,7 +34,7 @@ const makeContainer = (getSystem, component, reduxStore) => {
let wrappedWithSystem = SystemWrapper(getSystem, component, reduxStore) let wrappedWithSystem = SystemWrapper(getSystem, component, reduxStore)
let connected = connect( mapStateToProps )(wrappedWithSystem) let connected = connect( mapStateToProps )(wrappedWithSystem)
if(reduxStore) if(reduxStore)
return RootWrapper(reduxStore, connected) return RootWrapper(getSystem, reduxStore, connected)
return connected return connected
} }
@@ -66,73 +70,43 @@ export const makeMappedContainer = (getSystem, getStore, memGetComponent, getCom
} }
export const render = (getSystem, getStore, getComponent, getComponents, domNode) => { export const render = (getSystem, getStore, getComponent, getComponents, domNode) => {
let App = (getComponent(getSystem, getStore, getComponents, "App", "root")) let App = getComponent(getSystem, getStore, getComponents, "App", "root")
ReactDOM.render(( <App/> ), domNode) ReactDOM.render(<App/>, domNode)
} }
class ErrorBoundary extends Component { /**
constructor(props) { * Creates a class component from a stateless one and wrap it with Error Boundary
super(props) * to handle errors coming from a stateless component.
this.state = { hasError: false, error: null } */
} const createClass = (getSystem, OriginalComponent) => class extends Component {
static getDerivedStateFromError(error) {
return { hasError: true, error }
}
componentDidCatch(error, errorInfo) {
console.error(error, errorInfo) // eslint-disable-line no-console
}
render() { render() {
if (this.state.hasError) { const { getComponent } = getSystem()
return <Fallback name={this.props.targetName} /> const ErrorBoundary = getComponent("ErrorBoundary")
}
return this.props.children
}
}
ErrorBoundary.propTypes = {
targetName: PropTypes.string,
children: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.node),
PropTypes.node,
])
}
ErrorBoundary.defaultProps = {
targetName: "this component",
children: null,
}
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,
}
// Render try/catch wrapper
const createClass = OriginalComponent => class extends Component {
render() {
return ( return (
<ErrorBoundary targetName={OriginalComponent?.name}> <ErrorBoundary targetName={OriginalComponent?.name} getComponent={getComponent}>
<OriginalComponent {...this.props} /> <OriginalComponent {...this.props} />
</ErrorBoundary> </ErrorBoundary>
) )
} }
} }
const wrapRender = (component) => { const wrapRender = (getSystem, component) => {
const isStateless = component => !(component.prototype && component.prototype.isReactComponent) const isStateless = component => !(component.prototype && component.prototype.isReactComponent)
const target = isStateless(component) ? createClass(component) : component const target = isStateless(component) ? createClass(getSystem, component) : component
const { render: oriRender} = target.prototype const { render: oriRender} = target.prototype
/**
* This render method override handles errors that are throw in render method
* of class components.
*/
target.prototype.render = function render(...args) { target.prototype.render = function render(...args) {
try { try {
return oriRender.apply(this, args) return oriRender.apply(this, args)
} catch (error) { } catch (error) {
const { getComponent } = getSystem()
const Fallback = getComponent("Fallback")
console.error(error) // eslint-disable-line no-console console.error(error) // eslint-disable-line no-console
return <Fallback name={target.name} /> return <Fallback name={target.name} />
} }
@@ -159,11 +133,11 @@ export const getComponent = (getSystem, getStore, getComponents, componentName,
} }
if(!container) if(!container)
return wrapRender(component) return wrapRender(getSystem, component)
if(container === "root") if(container === "root")
return makeContainer(getSystem, component, getStore()) return makeContainer(getSystem, component, getStore())
// container == truthy // container == truthy
return makeContainer(getSystem, wrapRender(component)) return makeContainer(getSystem, wrapRender(getSystem, component))
} }

View File

@@ -1,5 +1,3 @@
import React from "react" import React from "react"
import PropTypes from "prop-types" import PropTypes from "prop-types"
@@ -16,27 +14,29 @@ export default class StandaloneLayout extends React.Component {
} }
render() { render() {
let { getComponent } = this.props const { getComponent } = this.props
const Container = getComponent("Container")
let Container = getComponent("Container") const Row = getComponent("Row")
let Row = getComponent("Row") const Col = getComponent("Col")
let Col = getComponent("Col")
const Topbar = getComponent("Topbar", true) const Topbar = getComponent("Topbar", true)
const BaseLayout = getComponent("BaseLayout", true) const BaseLayout = getComponent("BaseLayout", true)
const OnlineValidatorBadge = getComponent("onlineValidatorBadge", true) const OnlineValidatorBadge = getComponent("onlineValidatorBadge", true)
const ErrorBoundary = getComponent("ErrorBoundary", true)
return ( return (
<Container className='swagger-ui'> <Container className='swagger-ui'>
{Topbar ? <Topbar /> : null} <ErrorBoundary targetName="Topbar">
<BaseLayout /> {Topbar ? <Topbar /> : null}
<Row> </ErrorBoundary>
<Col> <BaseLayout />
<OnlineValidatorBadge /> <ErrorBoundary targetName="OnlineValidatorBadge">
</Col> <Row>
</Row> <Col>
<OnlineValidatorBadge />
</Col>
</Row>
</ErrorBoundary>
</Container> </Container>
) )
} }

View File

@@ -940,7 +940,7 @@ describe("bound system", function(){
expect(renderedComponent.text()).toEqual("😱 Could not render BrokenComponent, see the console.") expect(renderedComponent.text()).toEqual("😱 Could not render BrokenComponent, see the console.")
}) })
it("should catch errors thrown inside of pure component render methods", function() { it("should catch errors thrown inside of pure component", function() {
// Given // Given
class BrokenComponent extends PureComponent { class BrokenComponent extends PureComponent {
// eslint-disable-next-line react/require-render-return // eslint-disable-next-line react/require-render-return
@@ -962,16 +962,16 @@ describe("bound system", function(){
// When // When
let Component = system.getSystem().getComponent("BrokenComponent") let Component = system.getSystem().getComponent("BrokenComponent")
const renderedComponent = render(<Component />) const wrapper = mount(<Component />)
// Then // Then
expect(renderedComponent.text()).toEqual("😱 Could not render BrokenComponent, see the console.") expect(wrapper.text()).toEqual("😱 Could not render BrokenComponent, see the console.")
}) })
it("should catch errors thrown inside of stateless component functions", function() { it("should catch errors thrown inside of stateless component function", function() {
// Given // Given
// eslint-disable-next-line react/require-render-return // eslint-disable-next-line react/require-render-return
let BrokenComponent = function BrokenComponent() { throw new Error("This component is broken") } const BrokenComponent = () => { throw new Error("This component is broken") }
const system = new System({ const system = new System({
plugins: [ plugins: [
ViewPlugin, ViewPlugin,
@@ -985,15 +985,14 @@ describe("bound system", function(){
// When // When
const Component = system.getSystem().getComponent("BrokenComponent") const Component = system.getSystem().getComponent("BrokenComponent")
const renderedComponent = mount(<Component />) const wrapper = mount(<Component />)
expect(renderedComponent.text().startsWith("😱 Could not render")).toEqual(true) expect(wrapper.text()).toEqual("😱 Could not render BrokenComponent, see the console.")
}) })
it("should catch errors thrown inside of container components", function() { it("should catch errors thrown inside of container created from class component", function() {
// Given // Given
class BrokenComponent extends React.Component { class BrokenComponent extends React.Component {
// eslint-disable-next-line react/require-render-return
render() { render() {
throw new Error("This component is broken") throw new Error("This component is broken")
} }
@@ -1011,15 +1010,42 @@ describe("bound system", function(){
}) })
// When // When
let Component = system.getSystem().getComponent("BrokenComponent", true) const Component = system.getSystem().getComponent("BrokenComponent", true)
const renderedComponent = render( const wrapper = render(
<Provider store={system.getStore()}> <Provider store={system.getStore()}>
<Component /> <Component />
</Provider> </Provider>
) )
// Then // Then
expect(renderedComponent.text()).toEqual("😱 Could not render BrokenComponent, see the console.") expect(wrapper.text()).toEqual("😱 Could not render BrokenComponent, see the console.")
})
it("should catch errors thrown inside of container created from stateless component function", function() {
// Given
const BrokenComponent = () => { throw new Error("This component is broken") }
const system = new System({
plugins: [
ViewPlugin,
{
components: {
BrokenComponent
}
}
]
})
// When
const Component = system.getSystem().getComponent("BrokenComponent", true)
const wrapper = mount(
<Provider store={system.getStore()}>
<Component />
</Provider>
)
// Then
expect(wrapper.text()).toEqual("😱 Could not render BrokenComponent, see the console.")
}) })
}) })
}) })