Skip to content

Commit

Permalink
feat: support catch error in SSR (alibaba#2041)
Browse files Browse the repository at this point in the history
* feat: support catch error when ssr

* fix: lint

* test: catch error

* v1.2.2

* test: get derived state from error

* test: componentDidCatch

* feat: support catch error only with getDerivedStateFromError

* fix: call componentDidCatch of instance

* fix: check constructor is exist

* v1.3.0

* v1.2.0
  • Loading branch information
chenjun1011 authored Dec 24, 2020
1 parent ab8db5b commit 5b7ee93
Show file tree
Hide file tree
Showing 6 changed files with 179 additions and 8 deletions.
2 changes: 1 addition & 1 deletion packages/rax-server-renderer/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
70 changes: 69 additions & 1 deletion packages/rax-server-renderer/src/__tests__/renderToString.js
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -353,4 +353,72 @@ describe('renderToString', () => {
const str = renderToString(<App />);
expect(str).toBe('<!-- _ --><div>light</div>');
});

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 (
<div>
<ErrorBoundary>
<MyWidget />
</ErrorBoundary>
</div>
);
};

const str = renderToString(<App />);
expect(str).toBe('<div><!--ERROR--></div>');
});

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 (
<div>
<ErrorBoundary>
<MyWidget />
</ErrorBoundary>
</div>
);
};

const str = renderToString(<App />);
expect(mockFn).toHaveBeenCalled();
expect(str).toBe('<div><!--ERROR--></div>');
});
});
17 changes: 15 additions & 2 deletions packages/rax-server-renderer/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const VOID_ELEMENTS = {
};

const TEXT_SPLIT_COMMENT = '<!--|-->';
const ERROR_COMMENT = '<!--ERROR-->';

const ESCAPE_LOOKUP = {
'&': '&amp;',
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion packages/rax/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
72 changes: 72 additions & 0 deletions packages/rax/src/vdom/__tests__/composite.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 <h1>Something went wrong.</h1>;
}

return this.props.children;
}
}

function BrokenRender(props) {
throw new Error('Hello');
}

render(
<ErrorBoundary>
<BrokenRender />
</ErrorBoundary>, 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 <h1>Something went wrong.</h1>;
}

return this.props.children;
}
}

function BrokenRender(props) {
throw new Error('Hello');
}

render(
<ErrorBoundary>
<BrokenRender />
</ErrorBoundary>, 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 {
Expand Down
24 changes: 21 additions & 3 deletions packages/rax/src/vdom/performInSandbox.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,34 @@ 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(() => {
const boundaryInternal = boundary[INTERNAL];
// 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);
}
});
Expand All @@ -33,4 +51,4 @@ export function handleError(instance, error) {
throw error;
}, 0);
}
}
}

0 comments on commit 5b7ee93

Please sign in to comment.