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:
@@ -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)/)'
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
45
src/core/plugins/view/error-boundary.jsx
Normal file
45
src/core/plugins/view/error-boundary.jsx
Normal 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
|
||||||
13
src/core/plugins/view/fallback.jsx
Normal file
13
src/core/plugins/view/fallback.jsx
Normal 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
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user