diff --git a/packages/rax-server-renderer/package.json b/packages/rax-server-renderer/package.json index 6f2d39c32d..cd1e7e1abb 100644 --- a/packages/rax-server-renderer/package.json +++ b/packages/rax-server-renderer/package.json @@ -1,6 +1,6 @@ { "name": "rax-server-renderer", - "version": "1.2.1", + "version": "1.3.0", "description": "Rax renderer for server-side render.", "license": "BSD-3-Clause", "main": "lib/index.js", diff --git a/packages/rax-server-renderer/src/__tests__/renderToString.js b/packages/rax-server-renderer/src/__tests__/renderToString.js index 76889ee84a..72082aff3f 100644 --- a/packages/rax-server-renderer/src/__tests__/renderToString.js +++ b/packages/rax-server-renderer/src/__tests__/renderToString.js @@ -1,6 +1,6 @@ /* @jsx createElement */ -import {createElement, useState, useEffect, createContext, useContext, useReducer} from 'rax'; +import {createElement, Component, useState, useEffect, createContext, useContext, useReducer} from 'rax'; import {renderToString} from '../index'; describe('renderToString', () => { @@ -353,4 +353,72 @@ describe('renderToString', () => { const str = renderToString(); expect(str).toBe('
light
'); }); + + it('should catch error with componentDidCatch', function() { + class ErrorBoundary extends Component { + constructor(props) { + super(props); + } + + componentDidCatch(error, errorInfo) { + // log error + } + + render() { + return this.props.children; + } + } + + function MyWidget() { + throw new Error('widget error'); + } + + function App() { + return ( +
+ + + +
+ ); + }; + + const str = renderToString(); + expect(str).toBe('
'); + }); + + it('should call componentDidCatch when catch error', function() { + const mockFn = jest.fn(); + class ErrorBoundary extends Component { + constructor(props) { + super(props); + } + + componentDidCatch(error, errorInfo) { + mockFn(); + } + + render() { + return this.props.children; + } + } + + function MyWidget() { + throw new Error('widget error'); + } + + function App() { + return ( +
+ + + +
+ ); + }; + + const str = renderToString(); + expect(mockFn).toHaveBeenCalled(); + expect(str).toBe('
'); + }); }); diff --git a/packages/rax-server-renderer/src/index.js b/packages/rax-server-renderer/src/index.js index 8e036696df..b2ad87341f 100644 --- a/packages/rax-server-renderer/src/index.js +++ b/packages/rax-server-renderer/src/index.js @@ -22,6 +22,7 @@ const VOID_ELEMENTS = { }; const TEXT_SPLIT_COMMENT = ''; +const ERROR_COMMENT = ''; const ESCAPE_LOOKUP = { '&': '&', @@ -357,8 +358,11 @@ class ServerRenderer { const type = element.type; if (type) { + const isClassComponent = type.prototype && type.prototype.render; + const isFunctionComponent = typeof type === 'function'; + // class component || function component - if (type.prototype && type.prototype.render || typeof type === 'function') { + if (isClassComponent || isFunctionComponent) { const instance = createInstance(element, context); const currentComponent = { @@ -399,7 +403,16 @@ class ServerRenderer { // Reset owner after render, or it will casue memory leak. shared.Host.owner = null; - return this.renderElementToString(renderedElement, currentContext); + if (isClassComponent && instance.componentDidCatch) { + try { + return this.renderElementToString(renderedElement, currentContext); + } catch(e) { + instance.componentDidCatch(e); + return ERROR_COMMENT; + } + } else { + return this.renderElementToString(renderedElement, currentContext); + } } else if (typeof type === 'string') { // shoud set the identifier to false before render child this.previousWasTextNode = false; diff --git a/packages/rax/package.json b/packages/rax/package.json index 9940d54e7e..7d41a505c6 100644 --- a/packages/rax/package.json +++ b/packages/rax/package.json @@ -1,6 +1,6 @@ { "name": "rax", - "version": "1.1.4", + "version": "1.2.0", "description": "A universal React-compatible render engine.", "license": "BSD-3-Clause", "main": "index.js", diff --git a/packages/rax/src/vdom/__tests__/composite.js b/packages/rax/src/vdom/__tests__/composite.js index 50feb4672e..992b78c7de 100644 --- a/packages/rax/src/vdom/__tests__/composite.js +++ b/packages/rax/src/vdom/__tests__/composite.js @@ -690,6 +690,78 @@ describe('CompositeComponent', function() { expect(container.childNodes[0].childNodes[0].data).toBe('Caught an error: Hello.'); }); + it('should update state to the next render when catch error.', () => { + let container = createNodeElement('div'); + class ErrorBoundary extends Component { + constructor(props) { + super(props); + this.state = { hasError: false }; + } + + static getDerivedStateFromError(error) { + return { hasError: true }; + } + + componentDidCatch(error, errorInfo) { + // log + } + + render() { + if (this.state.hasError) { + return

Something went wrong.

; + } + + return this.props.children; + } + } + + function BrokenRender(props) { + throw new Error('Hello'); + } + + render( + + + , container); + + jest.runAllTimers(); + expect(container.childNodes[0].childNodes[0].data).toBe('Something went wrong.'); + }); + + it('should catch error only with getDerivedStateFromError.', () => { + let container = createNodeElement('div'); + class ErrorBoundary extends Component { + constructor(props) { + super(props); + this.state = { hasError: false }; + } + + static getDerivedStateFromError(error) { + return { hasError: true }; + } + + render() { + if (this.state.hasError) { + return

Something went wrong.

; + } + + return this.props.children; + } + } + + function BrokenRender(props) { + throw new Error('Hello'); + } + + render( + + + , container); + + jest.runAllTimers(); + expect(container.childNodes[0].childNodes[0].data).toBe('Something went wrong.'); + }); + it('should render correct when prevRenderedComponent did not generate nodes', () => { let container = createNodeElement('div'); class Frag extends Component { diff --git a/packages/rax/src/vdom/performInSandbox.js b/packages/rax/src/vdom/performInSandbox.js index 6ec73fafb1..37b574eb6b 100644 --- a/packages/rax/src/vdom/performInSandbox.js +++ b/packages/rax/src/vdom/performInSandbox.js @@ -14,8 +14,18 @@ export default function performInSandbox(fn, instance, callback) { } } +/** + * A class component becomes an error boundary if + * it defines either (or both) of the lifecycle methods static getDerivedStateFromError() or componentDidCatch(). + * Use static getDerivedStateFromError() to render a fallback UI after an error has been thrown. + * Use componentDidCatch() to log error information. + * @param {*} instance + * @param {*} error + */ export function handleError(instance, error) { - let boundary = getNearestParent(instance, parent => parent.componentDidCatch); + let boundary = getNearestParent(instance, parent => { + return parent.componentDidCatch || (parent.constructor && parent.constructor.getDerivedStateFromError); + }); if (boundary) { scheduleLayout(() => { @@ -23,7 +33,15 @@ export function handleError(instance, error) { // Should not attempt to recover an unmounting error boundary if (boundaryInternal) { performInSandbox(() => { - boundary.componentDidCatch(error); + if (boundary.componentDidCatch) { + boundary.componentDidCatch(error); + } + + // Update state to the next render to show the fallback UI. + if (boundary.constructor && boundary.constructor.getDerivedStateFromError) { + const state = boundary.constructor.getDerivedStateFromError(); + boundary.setState(state); + } }, boundaryInternal.__parentInstance); } }); @@ -33,4 +51,4 @@ export function handleError(instance, error) { throw error; }, 0); } -} \ No newline at end of file +}