Skip to content

Commit

Permalink
Add support for componentDidCatch + getDerivedStateFromError
Browse files Browse the repository at this point in the history
  • Loading branch information
marvinhagemeister committed Jul 9, 2023
1 parent cbe881a commit accec09
Show file tree
Hide file tree
Showing 3 changed files with 336 additions and 2 deletions.
14 changes: 14 additions & 0 deletions .changeset/cold-otters-argue.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'preact-render-to-string': minor
---

Add support for error boundaries via `componentDidCatch` and `getDerivedStateFromError`

This feature is disabled by default and can be enabled by toggling the `errorBoundaries` option:

```js
import { options } from 'preact';

// Enable error boundaries
options.errorBoundaries = true;
```
77 changes: 75 additions & 2 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,15 @@ const EMPTY_OBJ = {};
function renderClassComponent(vnode, context) {
let type = /** @type {import("preact").ComponentClass<typeof vnode.props>} */ (vnode.type);

let c = new type(vnode.props, context);
let isMounting = true;
let c;
if (vnode[COMPONENT]) {
isMounting = false;
c = vnode[COMPONENT];
c.state = c[NEXT_STATE];
} else {
c = new type(vnode.props, context);
}

vnode[COMPONENT] = c;
c[VNODE] = vnode;
Expand All @@ -100,12 +108,14 @@ function renderClassComponent(vnode, context) {
c.state,
type.getDerivedStateFromProps(c.props, c.state)
);
} else if (c.componentWillMount) {
} else if (isMounting && c.componentWillMount) {
c.componentWillMount();

// If the user called setState in cWM we need to flush pending,
// state updates. This is the same behaviour in React.
c.state = c[NEXT_STATE] !== c.state ? c[NEXT_STATE] : c.state;
} else if (!isMounting && c.componentWillUpdate) {
c.componentWillUpdate();
}

if (renderHook) renderHook(vnode);
Expand Down Expand Up @@ -215,6 +225,69 @@ function _renderToString(vnode, context, isSvgMode, selectValue, parent) {
if (component.getChildContext != null) {
context = assign({}, context, component.getChildContext());
}

if (
(type.getDerivedStateFromError || component.componentDidCatch) &&
options.errorBoundaries
) {
let str = '';
// When a component returns a Fragment node we flatten it in core, so we
// need to mirror that logic here too
let isTopLevelFragment =
rendered != null &&
rendered.type === Fragment &&
rendered.key == null;
rendered = isTopLevelFragment ? rendered.props.children : rendered;

try {
str = _renderToString(
rendered,
context,
isSvgMode,
selectValue,
vnode
);
return str;
} catch (err) {
if (type.getDerivedStateFromError) {
component[NEXT_STATE] = type.getDerivedStateFromError(err);
}

if (component.componentDidCatch) {
component.componentDidCatch(err, {});
}

if (component[DIRTY]) {
rendered = renderClassComponent(vnode, context);
component = vnode[COMPONENT];

if (component.getChildContext != null) {
context = assign({}, context, component.getChildContext());
}

let isTopLevelFragment =
rendered != null &&
rendered.type === Fragment &&
rendered.key == null;
rendered = isTopLevelFragment ? rendered.props.children : rendered;

str = _renderToString(
rendered,
context,
isSvgMode,
selectValue,
vnode
);
}

return str;
} finally {
if (afterDiff) afterDiff(vnode);
vnode[PARENT] = undefined;

if (ummountHook) ummountHook(vnode);
}
}
}

// When a component returns a Fragment node we flatten it in core, so we
Expand Down
247 changes: 247 additions & 0 deletions test/render.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1351,4 +1351,251 @@ describe('render', () => {
expect(render(<App />)).to.equal('<div><p>P0-0</p><p>P0-1</p></div>');
});
});

describe('Error Handling', () => {
function Thrower() {
throw new Error('fail');
}

function renderWithError(vnode) {
options.errorBoundaries = true;
try {
return render(vnode);
} finally {
options.errorBoundaries = false;
}
}

describe('componentDidCatch', () => {
it('should disable componentDidCatch by default', () => {
class ErrorBoundary extends Component {
constructor(props) {
super(props);
this.state = { error: null };
}
componentDidCatch(error) {
this.setState({ error });
}

render() {
return this.state.error ? (
<p>{this.state.error.message}</p>
) : (
<Thrower />
);
}
}

expect(() => render(<ErrorBoundary />)).to.throw('fail');
});

it('should invoke componentDidCatch', () => {
let args = null;
class ErrorBoundary extends Component {
constructor(props) {
super(props);
this.state = { error: null };
}
componentDidCatch(error, info) {
args = { error: error.message, info };
this.setState({ error });
}

render() {
return this.state.error ? (
<p>{this.state.error.message}</p>
) : (
<Thrower />
);
}
}

let res = renderWithError(<ErrorBoundary />);
expect(res).to.equal('<p>fail</p>');
expect(args).to.deep.equal({ error: 'fail', info: {} });
});

it("should not invoke parent's componentDidCatch", () => {
class ErrorBoundary extends Component {
constructor(props) {
super(props);
this.state = { error: null };
}
componentDidCatch(error) {
this.setState({ error });
}

render() {
return this.state.error ? (
<p>{this.state.error.message}</p>
) : (
<Thrower />
);
}
}

let called = false;
class App extends Component {
componentDidCatch() {
called = true;
}
render() {
return <ErrorBoundary />;
}
}

renderWithError(<App />);
expect(called).to.equal(false, "Parent's componentDidCatch was called");
});

it('should invoke componentDidCatch if child throws in gDSFP', () => {
let throwerCatchCalled = false;

class Thrower extends Component {
static getDerivedStateFromProps(props) {
throw new Error('fail');
}

componentDidCatch() {
throwerCatchCalled = true;
}

render() {
return <p>it doesn't work</p>;
}
}

let args = null;
class ErrorBoundary extends Component {
constructor(props) {
super(props);
this.state = { error: null };
}
componentDidCatch(error, info) {
args = { error: error.message, info };
this.setState({ error });
}

render() {
return this.state.error ? (
<p>{this.state.error.message}</p>
) : (
<Thrower />
);
}
}

let res = renderWithError(<ErrorBoundary />);
expect(res).to.equal('<p>fail</p>');
expect(args).to.deep.equal({ error: 'fail', info: {} });

expect(throwerCatchCalled).to.equal(
false,
"Thrower's componentDidCatch should not be called"
);
});

it('should invoke componentWillUpdate on state render', () => {
let calledWillUpdate = false;

class ErrorBoundary extends Component {
constructor(props) {
super(props);
this.state = { error: null };
}
componentWillUpdate() {
calledWillUpdate = true;
}
componentDidCatch(error, info) {
this.setState({ error });
}

render() {
return this.state.error ? (
<p>{this.state.error.message}</p>
) : (
<Thrower />
);
}
}

let res = renderWithError(<ErrorBoundary />);
expect(res).to.equal('<p>fail</p>');
expect(calledWillUpdate).to.equal(
true,
'Did not call componentWillUpdate'
);
});
});

describe('getDerivedStateFromError', () => {
it('should disable gDSFE by default', () => {
class ErrorBoundary extends Component {
static getDerivedStateFromError(error) {
return { error };
}

render() {
return this.state.error ? (
<p>{this.state.error.message}</p>
) : (
<Thrower />
);
}
}

expect(() => render(<ErrorBoundary />)).to.throw('fail');
});

it('should be invoked', () => {
let calls = [];
let cDCState = null;
let renderState = null;
class ErrorBoundary extends Component {
static getDerivedStateFromError(error) {
calls.push(['gDSFE', error.message]);
return { foo: 1 };
}

componentDidCatch(error, info) {
calls.push(['cDC', error.message]);
cDCState = this.state;
this.setState({ bar: 2 });
}

render() {
renderState = this.state;
return this.state.foo ? <p>it works</p> : <Thrower />;
}
}

let res = renderWithError(<ErrorBoundary />);
expect(res).to.equal('<p>it works</p>');
expect(calls).to.deep.equal([
['gDSFE', 'fail'],
['cDC', 'fail']
]);
expect(cDCState).to.deep.equal({});
expect(renderState).to.deep.equal({
foo: 1,
bar: 2
});
});

it('should work without componentDidCatch', () => {
class ErrorBoundary extends Component {
static getDerivedStateFromError(error) {
return { error: error.message };
}

render() {
return this.state.error ? <p>{this.state.error}</p> : <Thrower />;
}
}

let res = renderWithError(<ErrorBoundary />);
expect(res).to.equal('<p>fail</p>');
});
});
});
});

0 comments on commit accec09

Please sign in to comment.