diff --git a/.changeset/pink-gifts-kneel.md b/.changeset/pink-gifts-kneel.md new file mode 100644 index 00000000..7bc5c1f4 --- /dev/null +++ b/.changeset/pink-gifts-kneel.md @@ -0,0 +1,7 @@ +--- +"preact-render-to-string": minor +--- + +Allow prepass like behavior where a Promise +will be awaited and then continued, this is done with +the new `renderToStringAsync` export 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/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/src/index.d.ts b/src/index.d.ts index 6b7b7e5d..81db2bd7 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -1,7 +1,17 @@ import { VNode } from 'preact'; -export default function renderToString

(vnode: VNode

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

( + vnode: VNode

, + context?: any +): string; 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 renderToStringAsync

( + vnode: VNode

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

( + vnode: VNode

, + context?: any +): string; diff --git a/src/index.js b/src/index.js index 84b00bbc..c52a067f 100644 --- a/src/index.js +++ b/src/index.js @@ -60,8 +60,74 @@ export function renderToString(vnode, context) { context || EMPTY_OBJ, false, undefined, - parent + 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 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 + // 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, + true + ); + + if (Array.isArray(rendered)) { + 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; } finally { // options._commit, we don't schedule any effects in this library right now, // so we can pass an empty queue to this hook. @@ -137,9 +203,17 @@ function renderClassComponent(vnode, context) { * @param {boolean} isSvgMode * @param {any} selectValue * @param {VNode} parent - * @returns {string} + * @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 ''; @@ -153,16 +227,44 @@ 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, + asyncMode + ); + + 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; } @@ -202,7 +304,8 @@ function _renderToString(vnode, context, isSvgMode, selectValue, parent) { context, isSvgMode, selectValue, - vnode + vnode, + asyncMode ); } else { // Values are pre-escaped by the JSX transform @@ -282,7 +385,8 @@ function _renderToString(vnode, context, isSvgMode, selectValue, parent) { context, isSvgMode, selectValue, - vnode + vnode, + asyncMode ); return str; } catch (err) { @@ -313,7 +417,8 @@ function _renderToString(vnode, context, isSvgMode, selectValue, parent) { context, isSvgMode, selectValue, - vnode + vnode, + asyncMode ); } @@ -333,20 +438,44 @@ 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; + const renderChildren = () => + _renderToString( + rendered, + context, + isSvgMode, + selectValue, + vnode, + asyncMode + ); + + try { + // Recurse into children before invoking the after-diff hook + const str = renderChildren(); + + if (afterDiff) afterDiff(vnode); + vnode[PARENT] = undefined; - if (ummountHook) ummountHook(vnode); + if (ummountHook) ummountHook(vnode); + + return str; + } catch (error) { + if (!asyncMode) throw error; + + if (!error || typeof error.then !== 'function') throw error; + + const renderNestedChildren = () => { + try { + return renderChildren(); + } catch (e) { + return e.then( + () => renderChildren(), + () => renderNestedChildren() + ); + } + }; - return str; + return error.then(() => renderNestedChildren()); + } } // Serialize Element VNodes to HTML @@ -476,7 +605,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); @@ -488,7 +624,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 SELF_CLOSING = new Set([ diff --git a/src/util.js b/src/util.js index 397d01ad..c950ebf0 100644 --- a/src/util.js +++ b/src/util.js @@ -150,3 +150,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/compat/async.test.js b/test/compat/async.test.js new file mode 100644 index 00000000..476ebb99 --- /dev/null +++ b/test/compat/async.test.js @@ -0,0 +1,140 @@ +import { renderToStringAsync } from '../../src/index.js'; +import { h } 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 = renderToStringAsync( + loading...}> + +

bar
+ + + ); + + const expected = `
bar
`; + + suspended.resolve(); + + const rendered = await promise; + + expect(rendered).to.equal(expected); + }); + + it('should render JSX with nested suspended components', async () => { + const { + Suspender: SuspenderOne, + suspended: suspendedOne + } = createSuspender(); + const { + Suspender: SuspenderTwo, + suspended: suspendedTwo + } = createSuspender(); + + const promise = renderToStringAsync( + + ); + + const expected = ``; + + suspendedOne.resolve(); + suspendedTwo.resolve(); + + const rendered = await promise; + + 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( + + ); + + const expected = ``; + + suspendedOne.resolve(); + suspendedTwo.resolve(); + + const rendered = await promise; + + 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( + + ); + + const expected = ``; + + suspendedOne.resolve(); + suspendedTwo.resolve(); + suspendedThree.resolve(); + + const rendered = await promise; + + expect(rendered).to.equal(expected); + }); +}); diff --git a/test/utils.js b/test/utils.js index 4c2b5d2d..ae19dad8 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. @@ -11,6 +13,25 @@ export function dedent([str]) { .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 + }; +} + export const svgAttributes = { accentHeight: 'accent-height', accumulate: 'accumulate',