Repository: preactjs/preact-render-to-string
Pull request #305 (2023-07-09 15:36:24)

Add support for `componentDidCatch` + `getDerivedStateFromError`
This PR adds support for error boundaries by supporting `componentDidCatch` and `getDerivedStateFromError`. To make this a non-breaking change this feature is disabled by default. It can be turned on by setting `options.errorBoundaries = true`.

This work is based on #66 and #67 .

changeset-bot[bot]: ###  🦋  Changeset detected

Latest commit: accec09e12dd5d0fd7631e4a00dcd40b58379c9f

**The changes in this PR will be included in the next version bump.**

<details><summary>This PR includes changesets to release 1 package</summary>

  | Name                    | Type  |
| ----------------------- | ----- |
| preact-render-to-string | Minor |

</details>

Not sure what this means? [Click here  to learn what changesets are](https://github.com/changesets/changesets/blob/main/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add another changeset to this PR](https://github.com/preactjs/preact-render-to-string/new/error-boundary-2?filename=.changeset/afraid-flowers-switch.md&value=---%0A%22preact-render-to-string%22%3A%20patch%0A---%0A%0AAdd%20support%20for%20%60componentDidCatch%60%20%2B%20%60getDerivedStateFromError%60%0A)



Patch:
diff --git a/.changeset/cold-otters-argue.md b/.changeset/cold-otters-argue.md
new file mode 100644
index 0000000..96a2945
--- /dev/null
+++ b/.changeset/cold-otters-argue.md
@@ -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;
+```
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 97a993d..0089272 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -7,7 +7,7 @@ on:
       - '**'
   push:
     branches:
-      - main
+      - master
 
 jobs:
   build_test:
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 199e0a9..0a6fb60 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -3,7 +3,7 @@ name: Release
 on:
   push:
     branches:
-      - main
+      - master
 
 jobs:
   release:
diff --git a/README.md b/README.md
index e48954d..fb326c3 100644
--- a/README.md
+++ b/README.md
@@ -84,6 +84,17 @@ app.get('/:fox', (req, res) => {
 });
 ```
 
+### Error Boundaries
+
+Rendering errors can be caught by Preact via `getDerivedStateFromErrors` or `componentDidCatch`. To enable that feature in `preact-render-to-string` set `errorBoundaries = true`
+
+```js
+import { options } from 'preact';
+
+// Enable error boundaries in `preact-render-to-string`
+options.errorBoundaries = true;
+```
+
 ---
 
 ### `Suspense` & `lazy` components with [`preact/compat`](https://www.npmjs.com/package/preact) & [`preact-ssr-prepass`](https://www.npmjs.com/package/preact-ssr-prepass)
@@ -94,50 +105,48 @@ npm install preact preact-render-to-string preact-ssr-prepass
 
 ```jsx
 export default () => {
-  return (
-    <h1>Home page</h1>
-  )
-}
+	return <h1>Home page</h1>;
+};
 ```
 
 ```jsx
-import { Suspense, lazy } from "preact/compat"
+import { Suspense, lazy } from 'preact/compat';
 
 // Creation of the lazy component
-const HomePage = lazy(() => import("./pages/home"))
+const HomePage = lazy(() => import('./pages/home'));
 
 const Main = () => {
-  return (
-    <Suspense fallback={<p>Loading</p>}>
-      <HomePage />
-    </Suspense>
-  )
-}
+	return (
+		<Suspense fallback={<p>Loading</p>}>
+			<HomePage />
+		</Suspense>
+	);
+};
 ```
 
 ```jsx
-import { render } from "preact-render-to-string"
-import prepass from "preact-ssr-prepass"
-import { Main } from "./main"
+import { render } from 'preact-render-to-string';
+import prepass from 'preact-ssr-prepass';
+import { Main } from './main';
 
 const main = async () => {
-  // Creation of the virtual DOM
-  const vdom = <Main />
-  
-  // Pre-rendering of lazy components
-  await prepass(vdom)
-  
-  // Rendering of components 
-  const html = render(vdom)
-  
-  console.log(html)
-  // <h1>Home page</h1>
-}
+	// Creation of the virtual DOM
+	const vdom = <Main />;
+
+	// Pre-rendering of lazy components
+	await prepass(vdom);
+
+	// Rendering of components
+	const html = render(vdom);
+
+	console.log(html);
+	// <h1>Home page</h1>
+};
 
 // Execution & error handling
-main().catch(error => {
-  console.error(error)
-})
+main().catch((error) => {
+	console.error(error);
+});
 ```
 
 ---
diff --git a/src/index.js b/src/index.js
index a0fa9ca..45bb9bf 100644
--- a/src/index.js
+++ b/src/index.js
@@ -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;
@@ -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);
@@ -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


Test patch:
diff --git a/test/render.test.js b/test/render.test.js
index 0d6c7a6..bd264b6 100644
--- a/test/render.test.js
+++ b/test/render.test.js
@@ -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>');
+			});
+		});
+	});
 });