diff --git a/.changeset/brown-fishes-exercise.md b/.changeset/brown-fishes-exercise.md new file mode 100644 index 00000000..7215082b --- /dev/null +++ b/.changeset/brown-fishes-exercise.md @@ -0,0 +1,5 @@ +--- +'preact-render-to-string': major +--- + +Support Preact v11, this breaks compat with Preact 10 diff --git a/jsx.d.ts b/jsx.d.ts index ee2ba580..6645ee5a 100644 --- a/jsx.d.ts +++ b/jsx.d.ts @@ -1,13 +1,14 @@ import { VNode } from 'preact'; interface Options { - jsx?: boolean; - xml?: boolean; - functions?: boolean - functionNames?: boolean, - skipFalseAttributes?: boolean - pretty?: boolean | string; + jsx?: boolean; + xml?: boolean; + functions?: boolean; + functionNames?: boolean; + skipFalseAttributes?: boolean; + pretty?: boolean | string; + errorBoundaries?: boolean; } -export function render(vnode: VNode, context?: any, options?: Options):string; +export function render(vnode: VNode, context?: any, options?: Options): string; export default render; diff --git a/package-lock.json b/package-lock.json index 96391807..5f99f9a4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,14 +26,14 @@ "lint-staged": "^10.5.3", "microbundle": "^0.13.0", "mocha": "^8.2.1", - "preact": "^10.5.7", + "preact": "^11.0.0-experimental.0", "prettier": "^2.2.1", "sinon": "^9.2.2", "sinon-chai": "^3.5.0", "typescript": "^4.1.3" }, "peerDependencies": { - "preact": ">=10" + "preact": ">=11" } }, "node_modules/@babel/code-frame": { @@ -10664,10 +10664,14 @@ } }, "node_modules/preact": { - "version": "10.5.7", - "resolved": "https://registry.npmjs.org/preact/-/preact-10.5.7.tgz", - "integrity": "sha512-4oEpz75t/0UNcwmcsjk+BIcDdk68oao+7kxcpc1hQPNs2Oo3ZL9xFz8UBf350mxk/VEdD41L5b4l2dE3Ug3RYg==", - "dev": true + "version": "11.0.0-experimental.0", + "resolved": "https://registry.npmjs.org/preact/-/preact-11.0.0-experimental.0.tgz", + "integrity": "sha512-TXVw49O11z34ouJOe9+wzN9/ReoXoNyO1Jth48SiJDuqLKrdAiuXkBlmtTWeDgiaJr1SSPDMBdLufMDjDkb5bg==", + "dev": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } }, "node_modules/preferred-pm": { "version": "3.0.3", @@ -22209,9 +22213,9 @@ "dev": true }, "preact": { - "version": "10.5.7", - "resolved": "https://registry.npmjs.org/preact/-/preact-10.5.7.tgz", - "integrity": "sha512-4oEpz75t/0UNcwmcsjk+BIcDdk68oao+7kxcpc1hQPNs2Oo3ZL9xFz8UBf350mxk/VEdD41L5b4l2dE3Ug3RYg==", + "version": "11.0.0-experimental.0", + "resolved": "https://registry.npmjs.org/preact/-/preact-11.0.0-experimental.0.tgz", + "integrity": "sha512-TXVw49O11z34ouJOe9+wzN9/ReoXoNyO1Jth48SiJDuqLKrdAiuXkBlmtTWeDgiaJr1SSPDMBdLufMDjDkb5bg==", "dev": true }, "preferred-pm": { diff --git a/package.json b/package.json index 9b1df539..bbcfe7e2 100644 --- a/package.json +++ b/package.json @@ -97,7 +97,7 @@ "bugs": "https://github.com/developit/preact-render-to-string/issues", "homepage": "https://github.com/developit/preact-render-to-string", "peerDependencies": { - "preact": ">=10" + "preact": ">=11" }, "devDependencies": { "@babel/plugin-transform-react-jsx": "^7.12.12", @@ -114,7 +114,7 @@ "lint-staged": "^10.5.3", "microbundle": "^0.13.0", "mocha": "^8.2.1", - "preact": "^10.5.7", + "preact": "^11.0.0-experimental.0", "prettier": "^2.2.1", "sinon": "^9.2.2", "sinon-chai": "^3.5.0", diff --git a/src/index.d.ts b/src/index.d.ts index 221d349a..30c63b30 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -4,6 +4,8 @@ interface Options { shallow?: boolean; xml?: boolean; pretty?: boolean | string; + /** Enable or disable error boundaries (default: false) */ + errorBoundaries?: boolean; } export function render(vnode: VNode, context?: any, options?: Options): string; diff --git a/src/index.js b/src/index.js index 7714f36f..8fa99374 100644 --- a/src/index.js +++ b/src/index.js @@ -4,7 +4,8 @@ import { isLargeString, styleObjToCss, assign, - getChildren + getChildren, + createInternalFromVnode } from './util'; import { options, Fragment } from 'preact'; @@ -15,8 +16,7 @@ const SHALLOW = { shallow: true }; // components without names, kept as a hash for later comparison to return consistent UnnamedComponentXX names. const UNNAMED = []; -const VOID_ELEMENTS = - /^(area|base|br|col|embed|hr|img|input|link|meta|param|source|track|wbr)$/; +const VOID_ELEMENTS = /^(area|base|br|col|embed|hr|img|input|link|meta|param|source|track|wbr)$/; const UNSAFE_NAME = /[\s\n\\/='"\0<>]/; @@ -32,6 +32,7 @@ const noop = () => {}; * @param {Boolean} [options.xml=false] If `true`, uses self-closing tags for elements without children. * @param {Boolean} [options.pretty=false] If `true`, adds whitespace for readability * @param {RegExp|undefined} [options.voidElements] RegeEx that matches elements that are considered void (self-closing) + * @param {boolean|undefined} [options.errorBoundaries=false] Enables support for error boundaries */ renderToString.render = renderToString; @@ -128,14 +129,15 @@ function _renderToString(vnode, context, opts, inner, isSvgMode, selectValue) { setState: noop, forceUpdate: noop, // hooks - __h: [] + data: {} }); // options._diff if (options.__b) options.__b(vnode); + const internal = createInternalFromVnode(vnode, context); // options._render - if (options.__r) options.__r(vnode); + if (options.__r) options.__r(internal); if ( !nodeName.prototype || @@ -204,6 +206,53 @@ function _renderToString(vnode, context, opts, inner, isSvgMode, selectValue) { } if (options.diffed) options.diffed(vnode); + + if ( + opts.errorBoundaries && + (c.componentDidCatch || nodeName.getDerivedStateFromError) + ) { + try { + return _renderToString( + rendered, + context, + opts, + opts.shallowHighOrder !== false, + isSvgMode, + selectValue + ); + } catch (err) { + if (nodeName.getDerivedStateFromError) { + let nextState = nodeName.getDerivedStateFromError(err); + c.state = c._nextState = c.__s = Object.assign( + {}, + c.state, + nextState + ); + } + + if (c.componentDidCatch) c.componentDidCatch(err, {}); + + let nextState = + c._nextState !== c.state + ? c._nextState + : c.__s !== c.state + ? c.__s + : c.state; + + // Check if there is a potential state update. Note that + // we'll ignore any state updates inside cWU for now + // because that's an infinite loop anyway in the browser + if (c.state !== nextState && c.componentWillUpdate) { + c.componentWillUpdate(); + } + + // Flush potential state updates + c.state = nextState; + + rendered = c.render(c.props, c.state, c.context); + } + } + return _renderToString( rendered, context, diff --git a/src/jsx.d.ts b/src/jsx.d.ts index 98fad555..8dcafef3 100644 --- a/src/jsx.d.ts +++ b/src/jsx.d.ts @@ -3,6 +3,8 @@ import { VNode } from 'preact'; interface Options { jsx?: boolean; xml?: boolean; + /** Enable or disable error boundaries (default: false) */ + errorBoundaries?: boolean; functions?: boolean; functionNames?: boolean; skipFalseAttributes?: boolean; diff --git a/src/util.js b/src/util.js index 5ade429e..48840878 100644 --- a/src/util.js +++ b/src/util.js @@ -76,3 +76,12 @@ export function getChildren(accumulator, children) { } return accumulator; } + +export function createInternalFromVnode(vnode, context) { + return { + type: vnode.type, + props: vnode.props, + data: {}, + c: context + }; +} diff --git a/test/compat.test.js b/test/compat.test.js index e6fc6b96..e51fa096 100644 --- a/test/compat.test.js +++ b/test/compat.test.js @@ -1,5 +1,6 @@ import { render } from '../src'; -import { createElement } from 'preact/compat'; +import { h } from 'preact'; +import { createElement, Component } from 'preact/compat'; import { expect } from 'chai'; describe('compat', () => { @@ -9,4 +10,50 @@ describe('compat', () => { expect(rendered).to.equal(expected); }); + + it('should apply defaultProps (func)', () => { + const Test = (props) =>
; + Test.defaultProps = { + foo: 'default foo', + bar: 'default bar' + }; + + expect(render(), 'defaults').to.equal( + '
' + ); + expect(render(), 'partial').to.equal( + '
' + ); + expect(render(), 'overridden').to.equal( + '
' + ); + expect(render(), 'overridden').to.equal( + '
' + ); + }); + + it('should apply defaultProps (class)', () => { + class Test extends Component { + render(props) { + return
; + } + } + Test.defaultProps = { + foo: 'default foo', + bar: 'default bar' + }; + + expect(render(), 'defaults').to.equal( + '
' + ); + expect(render(), 'partial').to.equal( + '
' + ); + expect(render(), 'overridden').to.equal( + '
' + ); + expect(render(), 'overridden').to.equal( + '
' + ); + }); }); diff --git a/test/render.test.js b/test/render.test.js index 27a08f62..9654e046 100644 --- a/test/render.test.js +++ b/test/render.test.js @@ -3,6 +3,7 @@ import { h, Component, createContext, Fragment, options } from 'preact'; import { useState, useContext, useEffect, useLayoutEffect } from 'preact/hooks'; import { expect } from 'chai'; import { spy, stub, match } from 'sinon'; +import { createInternalFromVnode } from '../src/util'; describe('render', () => { describe('Basic JSX', () => { @@ -322,27 +323,6 @@ describe('render', () => { match({}) ); }); - - it('should apply defaultProps', () => { - const Test = (props) =>
; - Test.defaultProps = { - foo: 'default foo', - bar: 'default bar' - }; - - expect(render(), 'defaults').to.equal( - '
' - ); - expect(render(), 'partial').to.equal( - '
' - ); - expect(render(), 'overridden').to.equal( - '
' - ); - expect(render(), 'overridden').to.equal( - '
' - ); - }); }); describe('Classical Components', () => { @@ -416,31 +396,6 @@ describe('render', () => { ); }); - it('should apply defaultProps', () => { - class Test extends Component { - render(props) { - return
; - } - } - Test.defaultProps = { - foo: 'default foo', - bar: 'default bar' - }; - - expect(render(), 'defaults').to.equal( - '
' - ); - expect(render(), 'partial').to.equal( - '
' - ); - expect(render(), 'overridden').to.equal( - '
' - ); - expect(render(), 'overridden').to.equal( - '
' - ); - }); - it('should initialize state as an empty object', () => { const fn = spy(); class Test extends Component { @@ -1113,10 +1068,10 @@ describe('render', () => { expect(calls).to.deep.equal([ ['_diff', [vnode1]], - ['_render', [vnode1]], + ['_render', [createInternalFromVnode(vnode1, {})]], ['diffed', [vnode1]], ['_diff', [vnode2]], - ['_render', [vnode2]], + ['_render', [createInternalFromVnode(vnode2, {})]], ['diffed', [vnode2]], ['_commit', [vnode1, []]] ]); @@ -1182,4 +1137,248 @@ describe('render', () => { '' ); }); + + describe('Error Handling', () => { + function Thrower() { + throw new Error('fail'); + } + + function renderWithError(vnode) { + return render(vnode, {}, { errorBoundaries: true }); + } + + 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 ? ( +

{this.state.error.message}

+ ) : ( + + ); + } + } + + expect(() => render()).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 ? ( +

{this.state.error.message}

+ ) : ( + + ); + } + } + + let res = renderWithError(); + expect(res).to.equal('

fail

'); + 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 ? ( +

{this.state.error.message}

+ ) : ( + + ); + } + } + + let called = false; + class App extends Component { + componentDidCatch() { + called = true; + } + render() { + return ; + } + } + + renderWithError(); + 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

it doesn't work

; + } + } + + 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 ? ( +

{this.state.error.message}

+ ) : ( + + ); + } + } + + let res = renderWithError(); + expect(res).to.equal('

fail

'); + 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 ? ( +

{this.state.error.message}

+ ) : ( + + ); + } + } + + let res = renderWithError(); + expect(res).to.equal('

fail

'); + 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 ? ( +

{this.state.error.message}

+ ) : ( + + ); + } + } + + expect(() => render()).to.throw('fail'); + }); + + it('should be invoked', () => { + let calls = []; + let cDCState = null; + class ErrorBoundary extends Component { + static getDerivedStateFromError(error) { + calls.push(['gDSFE', error.message]); + return { foo: 1 }; + } + + constructor(props) { + super(props); + this.state = { error: null }; + } + + componentDidCatch(error, info) { + calls.push(['cDC', error.message]); + cDCState = this.state; + this.setState({ bar: 2 }); + } + + render() { + return this.state.foo ?

it works

: ; + } + } + + let res = renderWithError(); + expect(res).to.equal('

it works

'); + expect(calls).to.deep.equal([ + ['gDSFE', 'fail'], + ['cDC', 'fail'] + ]); + expect(cDCState).to.deep.equal({ + foo: 1, + error: null + }); + }); + + it('should work without componentDidCatch', () => { + class ErrorBoundary extends Component { + static getDerivedStateFromError(error) { + return { error: error.message }; + } + + render() { + return this.state.error ?

{this.state.error}

: ; + } + } + + let res = renderWithError(); + expect(res).to.equal('

fail

'); + }); + }); + }); }); pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy