diff --git a/.changeset/happy-peas-type.md b/.changeset/happy-peas-type.md new file mode 100644 index 00000000..fa929705 --- /dev/null +++ b/.changeset/happy-peas-type.md @@ -0,0 +1,5 @@ +--- +'preact-render-to-string': minor +--- + +Insert comment markers for suspended trees, only in renderToStringAsync diff --git a/src/index.js b/src/index.js index ff4c774e..f6530f4f 100644 --- a/src/index.js +++ b/src/index.js @@ -29,6 +29,8 @@ const EMPTY_ARR = []; const isArray = Array.isArray; const assign = Object.assign; const EMPTY_STR = ''; +const BEGIN_SUSPENSE_DENOMINATOR = ''; +const END_SUSPENSE_DENOMINATOR = ''; // Global state for the current render pass let beforeDiff, afterDiff, renderHook, ummountHook; @@ -372,7 +374,14 @@ function _renderToString( if (renderHook) renderHook(vnode); - rendered = type.call(component, props, cctx); + try { + rendered = type.call(component, props, cctx); + } catch (e) { + if (asyncMode) { + vnode._suspended = true; + } + throw e; + } } component[DIRTY] = true; } @@ -403,6 +412,7 @@ function _renderToString( selectValue, vnode, asyncMode, + false, renderer ); } catch (err) { @@ -475,6 +485,21 @@ function _renderToString( if (options.unmount) options.unmount(vnode); + if (vnode._suspended) { + if (typeof str === 'string') { + return BEGIN_SUSPENSE_DENOMINATOR + str + END_SUSPENSE_DENOMINATOR; + } else if (isArray(str)) { + str.unshift(BEGIN_SUSPENSE_DENOMINATOR); + str.push(END_SUSPENSE_DENOMINATOR); + return str; + } + + return str.then( + (resolved) => + BEGIN_SUSPENSE_DENOMINATOR + resolved + END_SUSPENSE_DENOMINATOR + ); + } + return str; } catch (error) { if (!asyncMode && renderer && renderer.onError) { @@ -503,7 +528,7 @@ function _renderToString( const renderNestedChildren = () => { try { - return _renderToString( + const result = _renderToString( rendered, context, isSvgMode, @@ -512,22 +537,26 @@ function _renderToString( asyncMode, renderer ); + return vnode._suspended + ? BEGIN_SUSPENSE_DENOMINATOR + result + END_SUSPENSE_DENOMINATOR + : result; } catch (e) { if (!e || typeof e.then != 'function') throw e; - return e.then( - () => - _renderToString( - rendered, - context, - isSvgMode, - selectValue, - vnode, - asyncMode, - renderer - ), - renderNestedChildren - ); + return e.then(() => { + const result = _renderToString( + rendered, + context, + isSvgMode, + selectValue, + vnode, + asyncMode, + renderer + ); + return vnode._suspended + ? BEGIN_SUSPENSE_DENOMINATOR + result + END_SUSPENSE_DENOMINATOR + : result; + }, renderNestedChildren); } }; diff --git a/test/compat/async.test.jsx b/test/compat/async.test.jsx index 12e9ac5a..0ca78352 100644 --- a/test/compat/async.test.jsx +++ b/test/compat/async.test.jsx @@ -3,6 +3,7 @@ import { h, Fragment } from 'preact'; import { Suspense, useId, lazy, createContext } from 'preact/compat'; import { expect } from 'chai'; import { createSuspender } from '../utils.jsx'; +const wait = (ms) => new Promise((r) => setTimeout(r, ms)); describe('Async renderToString', () => { it('should render JSX after a suspense boundary', async () => { @@ -16,7 +17,30 @@ describe('Async renderToString', () => { ); - const expected = `
bar
`; + const expected = `
bar
`; + + suspended.resolve(); + + const rendered = await promise; + + expect(rendered).to.equal(expected); + }); + + it('should correctly denote null returns of suspending components', async () => { + const { Suspender, suspended } = createSuspender(); + + const Analytics = () => null; + + const promise = renderToStringAsync( + loading...}> + + + +
bar
+
+ ); + + const expected = `
bar
`; suspended.resolve(); @@ -49,7 +73,7 @@ describe('Async renderToString', () => { ); - const expected = ``; + const expected = ``; suspendedOne.resolve(); suspendedTwo.resolve(); @@ -85,10 +109,102 @@ describe('Async renderToString', () => { ); - const expected = ``; + const expected = ``; + + suspendedOne.resolve(); + suspendedTwo.resolve(); + + const rendered = await promise; + + expect(rendered).to.equal(expected); + }); + + it('should render JSX with nested suspense boundaries containing multiple suspending components', async () => { + const { + Suspender: SuspenderOne, + suspended: suspendedOne + } = createSuspender(); + const { + Suspender: SuspenderTwo, + suspended: suspendedTwo + } = createSuspender(); + const { + Suspender: SuspenderThree, + suspended: suspendedThree + } = createSuspender('three'); + + const promise = renderToStringAsync( + + ); + + const expected = ``; suspendedOne.resolve(); suspendedTwo.resolve(); + await wait(0); + suspendedThree.resolve(); + + const rendered = await promise; + + expect(rendered).to.equal(expected); + }); + + it('should render JSX with deeply nested suspense boundaries', async () => { + const { + Suspender: SuspenderOne, + suspended: suspendedOne + } = createSuspender(); + const { + Suspender: SuspenderTwo, + suspended: suspendedTwo + } = createSuspender(); + const { + Suspender: SuspenderThree, + suspended: suspendedThree + } = createSuspender(); + + const promise = renderToStringAsync( + + ); + + const expected = ``; + + suspendedOne.resolve(); + suspendedTwo.resolve(); + await wait(0); + suspendedThree.resolve(); const rendered = await promise; @@ -127,7 +243,7 @@ describe('Async renderToString', () => { ); - const expected = ``; + const expected = ``; suspendedOne.resolve(); suspendedTwo.resolve(); @@ -187,7 +303,7 @@ describe('Async renderToString', () => { suspended.resolve(); const rendered = await promise; - expect(rendered).to.equal('

ok

'); + expect(rendered).to.equal('

ok

'); }); it('should work with an in-render suspension', async () => { @@ -224,7 +340,10 @@ describe('Async renderToString', () => { ); - expect(rendered).to.equal(`
2
`); + // Before we get to the actual DOM this suspends twice + expect(rendered).to.equal( + `
2
` + ); }); describe('dangerouslySetInnerHTML', () => {