From d0a7b1a4da761b4d7d03fffd1aec899a1414a261 Mon Sep 17 00:00:00 2001 From: Chris Sauve Date: Fri, 9 Jun 2023 10:28:03 -0400 Subject: [PATCH 1/9] Simple Suspense renderer --- package-lock.json | 4 +- src/index.js | 89 ++++++++++++++++++++++++++++++++++----------- src/util.js | 14 +++++++ test/render.test.js | 56 ++++++++++++++++++++++++++++ test/utils.js | 21 +++++++++++ 5 files changed, 161 insertions(+), 23 deletions(-) diff --git a/package-lock.json b/package-lock.json index e3964f99..e3a1e144 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "preact-render-to-string", - "version": "6.0.1", + "version": "6.0.3", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "preact-render-to-string", - "version": "6.0.1", + "version": "6.0.3", "license": "MIT", "dependencies": { "pretty-format": "^3.8.0" diff --git a/src/index.js b/src/index.js index 249266d4..79a7ebd7 100644 --- a/src/index.js +++ b/src/index.js @@ -48,13 +48,19 @@ export function renderToString(vnode, context) { parent[CHILDREN] = [vnode]; try { - return _renderToString( + const rendered = _renderToString( vnode, context || EMPTY_OBJ, false, undefined, parent ); + + if (Array.isArray(rendered)) { + return Promise.all(rendered).then((rendered) => rendered.join('')); + } + + return rendered; } finally { // options._commit, we don't schedule any effects in this library right now, // so we can pass an empty queue to this hook. @@ -120,7 +126,7 @@ function renderClassComponent(vnode, context) { * @param {boolean} isSvgMode * @param {any} selectValue * @param {VNode} parent - * @returns {string} + * @returns {string | Promise | (string | Promise)[]} */ function _renderToString(vnode, context, isSvgMode, selectValue, parent) { // Ignore non-rendered VNodes/values @@ -136,16 +142,43 @@ function _renderToString(vnode, context, isSvgMode, selectValue, parent) { // Recurse into children / Arrays if (isArray(vnode)) { - let rendered = ''; + let rendered = '', + renderArray; parent[CHILDREN] = vnode; for (let i = 0; i < vnode.length; i++) { let child = vnode[i]; if (child == null || typeof child === 'boolean') continue; - rendered = - rendered + - _renderToString(child, context, isSvgMode, selectValue, parent); + const childRender = _renderToString( + child, + context, + isSvgMode, + selectValue, + parent + ); + + if (typeof childRender === 'string') { + rendered += childRender; + } else { + renderArray = renderArray || []; + + if (rendered) renderArray.push(rendered); + + rendered = ''; + + if (Array.isArray(childRender)) { + renderArray.push(...childRender); + } else { + renderArray.push(childRender); + } + } + } + + if (renderArray) { + if (rendered) renderArray.push(rendered); + return renderArray; } + return rendered; } @@ -217,20 +250,28 @@ function _renderToString(vnode, context, isSvgMode, selectValue, parent) { rendered != null && rendered.type === Fragment && rendered.key == null; rendered = isTopLevelFragment ? rendered.props.children : rendered; - // Recurse into children before invoking the after-diff hook - const str = _renderToString( - rendered, - context, - isSvgMode, - selectValue, - vnode - ); - if (afterDiff) afterDiff(vnode); - vnode[PARENT] = undefined; - - if (ummountHook) ummountHook(vnode); - - return str; + try { + // Recurse into children before invoking the after-diff hook + const str = _renderToString( + rendered, + context, + isSvgMode, + selectValue, + vnode + ); + if (afterDiff) afterDiff(vnode); + vnode[PARENT] = undefined; + + if (ummountHook) ummountHook(vnode); + + return str; + } catch (error) { + if (!error || typeof error.then !== 'function') throw error; + + return error.then(() => + _renderToString(rendered, context, isSvgMode, selectValue, vnode) + ); + } } // Serialize Element VNodes to HTML @@ -357,7 +398,13 @@ function _renderToString(vnode, context, isSvgMode, selectValue, parent) { return s + '/>'; } - return s + '>' + html + ''; + const endTag = ''; + const startTag = s + '>'; + + if (Array.isArray(html)) return [startTag, ...html, endTag]; + else if (typeof html !== 'string') return [startTag, html, endTag]; + + return startTag + html + endTag; } const XLINK_REPLACE_REGEX = /^xlink:?/; diff --git a/src/util.js b/src/util.js index c3a598e1..fe3b4401 100644 --- a/src/util.js +++ b/src/util.js @@ -148,3 +148,17 @@ export function createComponent(vnode, context) { __h: [] }; } + +/** + * @template T + */ +export class Deferred { + constructor() { + // eslint-disable-next-line lines-around-comment + /** @type {Promise} */ + this.promise = new Promise((resolve, reject) => { + this.resolve = resolve; + this.reject = reject; + }); + } +} diff --git a/test/render.test.js b/test/render.test.js index 8c704ad4..d2b66b06 100644 --- a/test/render.test.js +++ b/test/render.test.js @@ -1,6 +1,7 @@ import render from '../src/index.js'; import renderToStringPretty from '../src/pretty.js'; import renderToStringJSX from '../src/jsx.js'; +import { createSuspender } from './utils.js'; import { h, Component, @@ -17,6 +18,7 @@ import { useMemo, useId } from 'preact/hooks'; +import { Suspense } from 'preact/compat'; import { expect } from 'chai'; import { spy, stub, match } from 'sinon'; @@ -38,6 +40,60 @@ describe('render', () => { expect(rendered).to.equal(expected); }); + it('should render JSX after a suspense boundary', async () => { + const { Suspender, suspended } = createSuspender(); + + const promise = render( + loading...}> + +
bar
+
+
+ ); + + const expected = `
bar
`; + + suspended.resolve(); + + const rendered = await promise; + + expect(rendered).to.equal(expected); + }); + + it('should render JSX with nested suspense boundary', async () => { + const { + Suspender: SuspenderOne, + suspended: suspendedOne + } = createSuspender(); + const { + Suspender: SuspenderTwo, + suspended: suspendedTwo + } = createSuspender(); + + const promise = render( +
    + + +
  • one
  • + +
  • two
  • +
    +
  • three
  • +
    +
    +
+ ); + + const expected = `
  • one
  • two
  • three
`; + + suspendedOne.resolve(); + suspendedTwo.resolve(); + + const rendered = await promise; + + expect(rendered).to.equal(expected); + }); + describe('whitespace', () => { it('should omit whitespace between elements', () => { let children = []; diff --git a/test/utils.js b/test/utils.js index abb3418a..207ef08c 100644 --- a/test/utils.js +++ b/test/utils.js @@ -1,3 +1,5 @@ +import { Deferred } from '../src/util'; + /** * tag to remove leading whitespace from tagged template * literal. @@ -10,3 +12,22 @@ export function dedent([str]) { .join('\n') .replace(/(^\n+|\n+\s*$)/g, ''); } + +export function createSuspender() { + const deferred = new Deferred(); + let resolved; + + deferred.promise.then(() => (resolved = true)); + function Suspender({ children = null }) { + if (!resolved) { + throw deferred.promise; + } + + return children; + } + + return { + suspended: deferred, + Suspender + }; +} From 5fbe601b93cdd148a7d6388af1d6ceecc6e7d8cc Mon Sep 17 00:00:00 2001 From: jdecroock Date: Thu, 8 Feb 2024 13:54:54 +0100 Subject: [PATCH 2/9] update simple suspense rendere --- package.json | 2 +- test/compat/async.test.js | 68 +++++++++++++++++++++++++++++++++++++++ test/render.test.js | 56 -------------------------------- 3 files changed, 69 insertions(+), 57 deletions(-) create mode 100644 test/compat/async.test.js diff --git a/package.json b/package.json index b78968f1..79dab839 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,7 @@ "copy-typescript-definition": "copyfiles -f src/*.d.ts dist", "test": "eslint src test && tsc && npm run test:mocha && npm run test:mocha:compat && npm run test:mocha:debug && npm run bench", "test:mocha": "BABEL_ENV=test mocha -r @babel/register -r test/setup.js test/*.test.js", - "test:mocha:compat": "BABEL_ENV=test mocha -r @babel/register -r test/setup.js 'test/compat/index.test.js'", + "test:mocha:compat": "BABEL_ENV=test mocha -r @babel/register -r test/setup.js 'test/compat/*.test.js'", "test:mocha:debug": "BABEL_ENV=test mocha -r @babel/register -r test/setup.js 'test/debug/index.test.js'", "format": "prettier src/**/*.{d.ts,js} test/**/*.js --write", "prepublishOnly": "npm run build", diff --git a/test/compat/async.test.js b/test/compat/async.test.js new file mode 100644 index 00000000..bffc87d7 --- /dev/null +++ b/test/compat/async.test.js @@ -0,0 +1,68 @@ +import render from '../../src/index.js'; +import { + h, + Component, + createContext, + Fragment, + options, + createRef +} from 'preact'; +import { Suspense } from 'preact/compat'; +import { expect } from 'chai'; +import { createSuspender } from '../utils.js'; + +describe('Async renderToString', () => { + it('should render JSX after a suspense boundary', async () => { + const { Suspender, suspended } = createSuspender(); + + const promise = render( + loading...}> + +
bar
+
+
+ ); + + const expected = `
bar
`; + + suspended.resolve(); + + const rendered = await promise; + + expect(rendered).to.equal(expected); + }); + + it('should render JSX with nested suspense boundary', async () => { + const { + Suspender: SuspenderOne, + suspended: suspendedOne + } = createSuspender(); + const { + Suspender: SuspenderTwo, + suspended: suspendedTwo + } = createSuspender(); + + const promise = render( +
    + + +
  • one
  • + +
  • two
  • +
    +
  • three
  • +
    +
    +
+ ); + + const expected = `
  • one
  • two
  • three
`; + + suspendedOne.resolve(); + suspendedTwo.resolve(); + + const rendered = await promise; + + expect(rendered).to.equal(expected); + }); +}); diff --git a/test/render.test.js b/test/render.test.js index 07c66f44..8d099b79 100644 --- a/test/render.test.js +++ b/test/render.test.js @@ -1,7 +1,6 @@ import render from '../src/index.js'; import renderToStringPretty from '../src/pretty.js'; import renderToStringJSX from '../src/jsx.js'; -import { createSuspender } from './utils.js'; import { h, Component, @@ -18,7 +17,6 @@ import { useMemo, useId } from 'preact/hooks'; -import { Suspense } from 'preact/compat'; import { expect } from 'chai'; import { spy, stub, match } from 'sinon'; import { svgAttributes, htmlAttributes } from './utils.js'; @@ -41,60 +39,6 @@ describe('render', () => { expect(rendered).to.equal(expected); }); - it('should render JSX after a suspense boundary', async () => { - const { Suspender, suspended } = createSuspender(); - - const promise = render( - loading...}> - -
bar
-
-
- ); - - const expected = `
bar
`; - - suspended.resolve(); - - const rendered = await promise; - - expect(rendered).to.equal(expected); - }); - - it('should render JSX with nested suspense boundary', async () => { - const { - Suspender: SuspenderOne, - suspended: suspendedOne - } = createSuspender(); - const { - Suspender: SuspenderTwo, - suspended: suspendedTwo - } = createSuspender(); - - const promise = render( -
    - - -
  • one
  • - -
  • two
  • -
    -
  • three
  • -
    -
    -
- ); - - const expected = `
  • one
  • two
  • three
`; - - suspendedOne.resolve(); - suspendedTwo.resolve(); - - const rendered = await promise; - - expect(rendered).to.equal(expected); - }); - describe('whitespace', () => { it('should omit whitespace between elements', () => { let children = []; From 9756cc88ed46bd48efc915dbd99cd0b97ec01544 Mon Sep 17 00:00:00 2001 From: jdecroock Date: Thu, 8 Feb 2024 13:56:27 +0100 Subject: [PATCH 3/9] add a possible promise string as the return value --- src/index.d.ts | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/src/index.d.ts b/src/index.d.ts index 6b7b7e5d..32a4ca7a 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -1,7 +1,19 @@ import { VNode } from 'preact'; -export default function renderToString

(vnode: VNode

, context?: any): string; +export default function renderToString

( + vnode: VNode

, + context?: any +): string | Promise; -export function render

(vnode: VNode

, context?: any): string; -export function renderToString

(vnode: VNode

, context?: any): string; -export function renderToStaticMarkup

(vnode: VNode

, context?: any): string; +export function render

( + vnode: VNode

, + context?: any +): string | Promise; +export function renderToString

( + vnode: VNode

, + context?: any +): string | Promise; +export function renderToStaticMarkup

( + vnode: VNode

, + context?: any +): string | Promise; From 6c58b5e37b4e0a77327840e0d84c230d70b9e92e Mon Sep 17 00:00:00 2001 From: Jovi De Croock Date: Thu, 8 Feb 2024 16:40:30 +0100 Subject: [PATCH 4/9] Update test/compat/async.test.js --- test/compat/async.test.js | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/test/compat/async.test.js b/test/compat/async.test.js index bffc87d7..961e607f 100644 --- a/test/compat/async.test.js +++ b/test/compat/async.test.js @@ -1,12 +1,5 @@ import render from '../../src/index.js'; -import { - h, - Component, - createContext, - Fragment, - options, - createRef -} from 'preact'; +import { h } from 'preact'; import { Suspense } from 'preact/compat'; import { expect } from 'chai'; import { createSuspender } from '../utils.js'; From 5f7559d6f3d862db6909207e447cdc0d50854fe1 Mon Sep 17 00:00:00 2001 From: Jovi De Croock Date: Thu, 8 Feb 2024 16:41:11 +0100 Subject: [PATCH 5/9] Create pink-gifts-kneel.md --- .changeset/pink-gifts-kneel.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/pink-gifts-kneel.md diff --git a/.changeset/pink-gifts-kneel.md b/.changeset/pink-gifts-kneel.md new file mode 100644 index 00000000..b73ff682 --- /dev/null +++ b/.changeset/pink-gifts-kneel.md @@ -0,0 +1,6 @@ +--- +"preact-render-to-string": major +--- + +Allow prepass like behavior in renderToString where a Promise +will be awaited and then continued From 80c0a706a3ebc2805c0474fec351d4e39c5cc522 Mon Sep 17 00:00:00 2001 From: jdecroock Date: Fri, 9 Feb 2024 11:37:50 +0100 Subject: [PATCH 6/9] non breaking --- .changeset/pink-gifts-kneel.md | 7 +-- src/index.d.ts | 12 ++--- src/index.js | 96 ++++++++++++++++++++++++++++++---- 3 files changed, 96 insertions(+), 19 deletions(-) diff --git a/.changeset/pink-gifts-kneel.md b/.changeset/pink-gifts-kneel.md index b73ff682..7bc5c1f4 100644 --- a/.changeset/pink-gifts-kneel.md +++ b/.changeset/pink-gifts-kneel.md @@ -1,6 +1,7 @@ --- -"preact-render-to-string": major +"preact-render-to-string": minor --- -Allow prepass like behavior in renderToString where a Promise -will be awaited and then continued +Allow prepass like behavior where a Promise +will be awaited and then continued, this is done with +the new `renderToStringAsync` export diff --git a/src/index.d.ts b/src/index.d.ts index 32a4ca7a..81db2bd7 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -3,17 +3,15 @@ import { VNode } from 'preact'; export default function renderToString

( vnode: VNode

, context?: any -): string | Promise; +): string; -export function render

( - vnode: VNode

, - context?: any -): string | Promise; -export function renderToString

( +export function render

(vnode: VNode

, context?: any): string; +export function renderToString

(vnode: VNode

, context?: any): string; +export function renderToStringAsync

( vnode: VNode

, context?: any ): string | Promise; export function renderToStaticMarkup

( vnode: VNode

, context?: any -): string | Promise; +): string; diff --git a/src/index.js b/src/index.js index 771f04ce..19567c65 100644 --- a/src/index.js +++ b/src/index.js @@ -54,13 +54,62 @@ export function renderToString(vnode, context) { const parent = h(Fragment, null); parent[CHILDREN] = [vnode]; + try { + return _renderToString( + vnode, + context || EMPTY_OBJ, + false, + undefined, + parent, + false + ); + } catch (e) { + if (e.then) { + throw new Error('Use "renderToStringAsync" for suspenseful rendering.'); + } + + throw e; + } finally { + // options._commit, we don't schedule any effects in this library right now, + // so we can pass an empty queue to this hook. + if (options[COMMIT]) options[COMMIT](vnode, EMPTY_ARR); + options[SKIP_EFFECTS] = previousSkipEffects; + EMPTY_ARR.length = 0; + } +} + +/** + * Render Preact JSX + Components to an HTML string. + * @param {VNode} vnode JSX Element / VNode to render + * @param {Object} [context={}] Initial root context object + * @returns {string} serialized HTML + */ +export function renderToStringAsync(vnode, context) { + // Performance optimization: `renderToString` is synchronous and we + // therefore don't execute any effects. To do that we pass an empty + // array to `options._commit` (`__c`). But we can go one step further + // and avoid a lot of dirty checks and allocations by setting + // `options._skipEffects` (`__s`) too. + const previousSkipEffects = options[SKIP_EFFECTS]; + options[SKIP_EFFECTS] = true; + + // store options hooks once before each synchronous render call + beforeDiff = options[DIFF]; + afterDiff = options[DIFFED]; + renderHook = options[RENDER]; + ummountHook = options.unmount; + + const parent = h(Fragment, null); + parent[CHILDREN] = [vnode]; + try { const rendered = _renderToString( vnode, context || EMPTY_OBJ, false, undefined, - parent + parent, + true ); if (Array.isArray(rendered)) { @@ -143,9 +192,17 @@ function renderClassComponent(vnode, context) { * @param {boolean} isSvgMode * @param {any} selectValue * @param {VNode} parent + * @param {boolean} asyncMode * @returns {string | Promise | (string | Promise)[]} */ -function _renderToString(vnode, context, isSvgMode, selectValue, parent) { +function _renderToString( + vnode, + context, + isSvgMode, + selectValue, + parent, + asyncMode +) { // Ignore non-rendered VNodes/values if (vnode == null || vnode === true || vnode === false || vnode === '') { return ''; @@ -171,7 +228,8 @@ function _renderToString(vnode, context, isSvgMode, selectValue, parent) { context, isSvgMode, selectValue, - parent + parent, + asyncMode ); if (typeof childRender === 'string') { @@ -235,7 +293,8 @@ function _renderToString(vnode, context, isSvgMode, selectValue, parent) { context, isSvgMode, selectValue, - vnode + vnode, + asyncMode ); } else { // Values are pre-escaped by the JSX transform @@ -315,7 +374,8 @@ function _renderToString(vnode, context, isSvgMode, selectValue, parent) { context, isSvgMode, selectValue, - vnode + vnode, + asyncMode ); return str; } catch (err) { @@ -346,7 +406,8 @@ function _renderToString(vnode, context, isSvgMode, selectValue, parent) { context, isSvgMode, selectValue, - vnode + vnode, + asyncMode ); } @@ -373,7 +434,8 @@ function _renderToString(vnode, context, isSvgMode, selectValue, parent) { context, isSvgMode, selectValue, - vnode + vnode, + asyncMode ); if (afterDiff) afterDiff(vnode); vnode[PARENT] = undefined; @@ -382,10 +444,19 @@ function _renderToString(vnode, context, isSvgMode, selectValue, parent) { return str; } catch (error) { + if (!asyncMode) throw error; + if (!error || typeof error.then !== 'function') throw error; return error.then(() => - _renderToString(rendered, context, isSvgMode, selectValue, vnode) + _renderToString( + rendered, + context, + isSvgMode, + selectValue, + vnode, + asyncMode + ) ); } } @@ -517,7 +588,14 @@ function _renderToString(vnode, context, isSvgMode, selectValue, parent) { // recurse into this element VNode's children let childSvgMode = type === 'svg' || (type !== 'foreignObject' && isSvgMode); - html = _renderToString(children, context, childSvgMode, selectValue, vnode); + html = _renderToString( + children, + context, + childSvgMode, + selectValue, + vnode, + asyncMode + ); } if (afterDiff) afterDiff(vnode); From ad7b6620be90e3440cfd4b93dde9e373ffb39212 Mon Sep 17 00:00:00 2001 From: Jovi De Croock Date: Fri, 9 Feb 2024 20:08:24 +0100 Subject: [PATCH 7/9] Update async.test.js --- test/compat/async.test.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/compat/async.test.js b/test/compat/async.test.js index 961e607f..00d4ab04 100644 --- a/test/compat/async.test.js +++ b/test/compat/async.test.js @@ -1,4 +1,4 @@ -import render from '../../src/index.js'; +import { renderToStringAsync } from '../../src/index.js'; import { h } from 'preact'; import { Suspense } from 'preact/compat'; import { expect } from 'chai'; @@ -8,7 +8,7 @@ describe('Async renderToString', () => { it('should render JSX after a suspense boundary', async () => { const { Suspender, suspended } = createSuspender(); - const promise = render( + const promise = renderToStringAsync( loading...}>

bar
@@ -35,7 +35,7 @@ describe('Async renderToString', () => { suspended: suspendedTwo } = createSuspender(); - const promise = render( + const promise = renderToStringAsync(
    From c57b2303cc1b7c1e22dda9f32b63daf4dcd918d9 Mon Sep 17 00:00:00 2001 From: David Dios Date: Mon, 19 Feb 2024 17:57:06 +0000 Subject: [PATCH 8/9] fixing nested Suspense boundaries (#334) --- package-lock.json | 4 ++-- src/index.js | 15 +++++++++++++-- test/compat/async.test.js | 38 +++++++++++++++++++++++++++++++++++++- 3 files changed, 52 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index ba54fea6..4d26bb77 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "preact-render-to-string", - "version": "6.2.2", + "version": "6.3.1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "preact-render-to-string", - "version": "6.2.2", + "version": "6.3.1", "license": "MIT", "dependencies": { "pretty-format": "^3.8.0" diff --git a/src/index.js b/src/index.js index 19567c65..debfa618 100644 --- a/src/index.js +++ b/src/index.js @@ -84,7 +84,7 @@ export function renderToString(vnode, context) { * @param {Object} [context={}] Initial root context object * @returns {string} serialized HTML */ -export function renderToStringAsync(vnode, context) { +export async function renderToStringAsync(vnode, context) { // Performance optimization: `renderToString` is synchronous and we // therefore don't execute any effects. To do that we pass an empty // array to `options._commit` (`__c`). But we can go one step further @@ -113,7 +113,18 @@ export function renderToStringAsync(vnode, context) { ); if (Array.isArray(rendered)) { - return Promise.all(rendered).then((rendered) => rendered.join('')); + let count = 0; + let resolved = rendered; + + // Resolving nested Promises with a maximum depth of 25 + while ( + resolved.some((element) => typeof element.then === 'function') && + count++ < 25 + ) { + resolved = (await Promise.all(resolved)).flat(); + } + + return resolved.join(''); } return rendered; diff --git a/test/compat/async.test.js b/test/compat/async.test.js index 00d4ab04..8a9d8299 100644 --- a/test/compat/async.test.js +++ b/test/compat/async.test.js @@ -25,7 +25,7 @@ describe('Async renderToString', () => { expect(rendered).to.equal(expected); }); - it('should render JSX with nested suspense boundary', async () => { + it('should render JSX with nested suspended components', async () => { const { Suspender: SuspenderOne, suspended: suspendedOne @@ -58,4 +58,40 @@ describe('Async renderToString', () => { expect(rendered).to.equal(expected); }); + + it('should render JSX with nested suspense boundaries', async () => { + const { + Suspender: SuspenderOne, + suspended: suspendedOne + } = createSuspender(); + const { + Suspender: SuspenderTwo, + suspended: suspendedTwo + } = createSuspender(); + + const promise = renderToStringAsync( +
      + + +
    • one
    • + + +
    • two
    • +
      +
      +
    • three
    • +
      +
      +
    + ); + + const expected = `
    • one
    • two
    • three
    `; + + suspendedOne.resolve(); + suspendedTwo.resolve(); + + const rendered = await promise; + + expect(rendered).to.equal(expected); + }); }); From cf815872c68b742a9175918865976dae8a83dc9b Mon Sep 17 00:00:00 2001 From: David Dios Date: Tue, 20 Feb 2024 09:32:44 +0000 Subject: [PATCH 9/9] fixing multiple suspended child components (#335) --- src/index.js | 32 +++++++++++++++++------------ test/compat/async.test.js | 43 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 13 deletions(-) diff --git a/src/index.js b/src/index.js index debfa618..c52a067f 100644 --- a/src/index.js +++ b/src/index.js @@ -438,9 +438,8 @@ function _renderToString( rendered != null && rendered.type === Fragment && rendered.key == null; rendered = isTopLevelFragment ? rendered.props.children : rendered; - try { - // Recurse into children before invoking the after-diff hook - const str = _renderToString( + const renderChildren = () => + _renderToString( rendered, context, isSvgMode, @@ -448,6 +447,11 @@ function _renderToString( vnode, asyncMode ); + + try { + // Recurse into children before invoking the after-diff hook + const str = renderChildren(); + if (afterDiff) afterDiff(vnode); vnode[PARENT] = undefined; @@ -459,16 +463,18 @@ function _renderToString( if (!error || typeof error.then !== 'function') throw error; - return error.then(() => - _renderToString( - rendered, - context, - isSvgMode, - selectValue, - vnode, - asyncMode - ) - ); + const renderNestedChildren = () => { + try { + return renderChildren(); + } catch (e) { + return e.then( + () => renderChildren(), + () => renderNestedChildren() + ); + } + }; + + return error.then(() => renderNestedChildren()); } } diff --git a/test/compat/async.test.js b/test/compat/async.test.js index 8a9d8299..476ebb99 100644 --- a/test/compat/async.test.js +++ b/test/compat/async.test.js @@ -94,4 +94,47 @@ describe('Async renderToString', () => { expect(rendered).to.equal(expected); }); + + it('should render JSX with multiple suspended direct children within a single suspense boundary', async () => { + const { + Suspender: SuspenderOne, + suspended: suspendedOne + } = createSuspender(); + const { + Suspender: SuspenderTwo, + suspended: suspendedTwo + } = createSuspender(); + const { + Suspender: SuspenderThree, + suspended: suspendedThree + } = createSuspender(); + + const promise = renderToStringAsync( +
      + + +
    • one
    • +
      + + +
    • two
    • +
      +
      + +
    • three
    • +
      +
      +
    + ); + + const expected = `
    • one
    • two
    • three
    `; + + suspendedOne.resolve(); + suspendedTwo.resolve(); + suspendedThree.resolve(); + + const rendered = await promise; + + expect(rendered).to.equal(expected); + }); });