From 11e8208406cc46e701207f85729ea76bc7968776 Mon Sep 17 00:00:00 2001 From: Marvin Hagemeister Date: Sun, 24 Mar 2024 10:36:24 +0100 Subject: [PATCH 1/2] feat: add streaming rendering via renderToStream --- src/index.d.ts | 4 + src/index.js | 148 +++++++++++++++++++++++++++++-------- test/compat/stream.test.js | 72 ++++++++++++++++++ 3 files changed, 193 insertions(+), 31 deletions(-) create mode 100644 test/compat/stream.test.js diff --git a/src/index.d.ts b/src/index.d.ts index 81db2bd7..cc462b88 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -11,6 +11,10 @@ export function renderToStringAsync

( vnode: VNode

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

( + vnode: VNode

, + context?: any +): ReadableStream; export function renderToStaticMarkup

( vnode: VNode

, context?: any diff --git a/src/index.js b/src/index.js index 6be17849..d60405de 100644 --- a/src/index.js +++ b/src/index.js @@ -30,13 +30,7 @@ const assign = Object.assign; // Global state for the current render pass let beforeDiff, afterDiff, renderHook, ummountHook; -/** - * 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 renderToString(vnode, context) { +function prepare() { // 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 @@ -51,6 +45,30 @@ export function renderToString(vnode, context) { renderHook = options[RENDER]; ummountHook = options.unmount; + return previousSkipEffects; +} + +/** + * @param {VNode} vnode + * @param {any} previousSkipEffects + */ +function finalize(vnode, previousSkipEffects) { + // 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 renderToString(vnode, context) { + const previousSkipEffects = prepare(); + const parent = h(Fragment, null); parent[CHILDREN] = [vnode]; @@ -70,34 +88,106 @@ export function renderToString(vnode, context) { 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; + finalize(vnode, previousSkipEffects); } } +const DEFAULT_RENDER_SLOT = (idx) => + ``; + /** * 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 + * @param {(idx: number) => string} [renderSlot] Render slot marker + * @returns {ReadableStream|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; +export function renderToStream( + vnode, + context, + renderSlot = DEFAULT_RENDER_SLOT +) { + const previousSkipEffects = prepare(); - // 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 outer = ''; + + /** @type {ReadableStreamDefaultController | null} */ + let controller = null; + + /** @type {ReadableStream | null} */ + let readable = null; + + let count = 0; + let idx = 0; + let pending = 0; + let suffix = ''; + for (let i = 0; i < rendered.length; i++) { + const element = rendered[i]; + if (typeof element.then === 'function') { + if (readable === null) { + readable = new ReadableStream({ + start(ctrl) { + controller = ctrl; + } + }); + } + + outer += renderSlot(idx); + idx++; + pending++; + element.then((r) => { + controller.enqueue(r); + if (count++ < 25 || --pending === 0) { + if (suffix !== '') { + controller.enqueue(suffix); + } + controller.close(); + } + }); + } else if (element === '' || element === '') { + suffix += element; + } else { + outer += element; + } + } + + if (readable === null) { + return outer + suffix; + } + + controller.enqueue(outer); + + return readable; + } + + return rendered; + } finally { + finalize(vnode, previousSkipEffects); + } +} + +/** + * Render Preact JSX + Components to an HTML string. + * @param {VNode} vnode JSX Element / VNode to render + * @param {Object} [context={}] Initial root context object + * @returns {Promise} serialized HTML + */ +export async function renderToStringAsync(vnode, context) { + const previousSkipEffects = prepare(); const parent = h(Fragment, null); parent[CHILDREN] = [vnode]; @@ -129,11 +219,7 @@ export async function renderToStringAsync(vnode, context) { 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. - if (options[COMMIT]) options[COMMIT](vnode, EMPTY_ARR); - options[SKIP_EFFECTS] = previousSkipEffects; - EMPTY_ARR.length = 0; + finalize(vnode, previousSkipEffects); } } diff --git a/test/compat/stream.test.js b/test/compat/stream.test.js new file mode 100644 index 00000000..48003035 --- /dev/null +++ b/test/compat/stream.test.js @@ -0,0 +1,72 @@ +import { renderToStream } from '../../src/index.js'; +import { h } from 'preact'; +import { Suspense } from 'preact/compat'; +import { expect } from 'chai'; +import { createSuspender } from '../utils.js'; + +/** + * @param {AsyncIterable} iter + * @returns {Promise} + */ +async function drain(iter) { + const out = []; + for await (const part of iter) { + out.push(part); + } + return out; +} + +describe('streaming renderToString', () => { + it('should render JSX after a suspense boundary', async () => { + const { Suspender, suspended } = createSuspender(); + + const promise = drain( + renderToStream( +

+

foo

+ loading...
}> + +
bar
+
+ +

baz

+ + ) + ); + suspended.resolve(); + const rendered = await promise; + expect(rendered).to.deep.equal([ + '

foo

baz

', + '
bar
' + ]); + }); + + it('should stream closing last', async () => { + const { Suspender, suspended } = createSuspender(); + + const promise = drain( + renderToStream( + + +
+

foo

+ loading...
}> + +
bar
+
+ +

baz

+ + + + ) + ); + suspended.resolve(); + const rendered = await promise; + expect(rendered).to.deep.equal([ + '

foo

baz

', + '
bar
', + '' + ]); + }); +}); From 81bc0a84c555d6867c4dc3ebd7db4a81b5f156f8 Mon Sep 17 00:00:00 2001 From: Marvin Hagemeister Date: Sun, 24 Mar 2024 14:48:06 +0100 Subject: [PATCH 2/2] WIP --- src/constants.js | 5 ++ src/index.js | 117 +++++++++++++++++++------------------ test/compat/stream.test.js | 34 +++++++++-- 3 files changed, 95 insertions(+), 61 deletions(-) diff --git a/src/constants.js b/src/constants.js index bedbc8ab..301ef1fa 100644 --- a/src/constants.js +++ b/src/constants.js @@ -14,3 +14,8 @@ export const PARENT = '__'; export const VNODE = '__v'; export const DIRTY = '__d'; export const NEXT_STATE = '__s'; + +// Rendering modes +export const MODE_SYNC = 0; +export const MODE_ASYNC = 1; +export const MODE_STREAM = 2; diff --git a/src/index.js b/src/index.js index d60405de..e35cd93e 100644 --- a/src/index.js +++ b/src/index.js @@ -14,6 +14,9 @@ import { DIFF, DIFFED, DIRTY, + MODE_ASYNC, + MODE_STREAM, + MODE_SYNC, NEXT_STATE, PARENT, RENDER, @@ -79,7 +82,7 @@ export function renderToString(vnode, context) { false, undefined, parent, - false + MODE_SYNC ); } catch (e) { if (e.then) { @@ -92,14 +95,57 @@ export function renderToString(vnode, context) { } } -const DEFAULT_RENDER_SLOT = (idx) => - ``; +/** + * Render Preact JSX + Components to an HTML string. + * @param {VNode} vnode JSX Element / VNode to render + * @param {Object} [context={}] Initial root context object + * @returns {Promise} serialized HTML + */ +export async function renderToStringAsync(vnode, context) { + const previousSkipEffects = prepare(); + + const parent = h(Fragment, null); + parent[CHILDREN] = [vnode]; + + try { + const rendered = _renderToString( + vnode, + context || EMPTY_OBJ, + false, + undefined, + parent, + MODE_ASYNC + ); + + 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 { + finalize(vnode, previousSkipEffects); + } +} + +const DEFAULT_RENDER_SLOT = (idx, content) => + `${content}`; /** * Render Preact JSX + Components to an HTML string. * @param {VNode} vnode JSX Element / VNode to render * @param {Object} [context={}] Initial root context object - * @param {(idx: number) => string} [renderSlot] Render slot marker + * @param {(idx: number, content: string) => string} [renderSlot] Render slot marker * @returns {ReadableStream|string} serialized HTML */ export function renderToStream( @@ -119,7 +165,7 @@ export function renderToStream( false, undefined, parent, - true + MODE_STREAM ); if (Array.isArray(rendered)) { @@ -180,49 +226,6 @@ export function renderToStream( } } -/** - * Render Preact JSX + Components to an HTML string. - * @param {VNode} vnode JSX Element / VNode to render - * @param {Object} [context={}] Initial root context object - * @returns {Promise} serialized HTML - */ -export async function renderToStringAsync(vnode, context) { - const previousSkipEffects = prepare(); - - 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 { - finalize(vnode, previousSkipEffects); - } -} - // Installed as setState/forceUpdate for function components function markAsDirty() { this.__d = true; @@ -290,6 +293,7 @@ function renderClassComponent(vnode, context) { * @param {any} selectValue * @param {VNode} parent * @param {boolean} asyncMode + * @param {number} renderMode * @returns {string | Promise | (string | Promise)[]} */ function _renderToString( @@ -298,7 +302,7 @@ function _renderToString( isSvgMode, selectValue, parent, - asyncMode + renderMode ) { // Ignore non-rendered VNodes/values if (vnode == null || vnode === true || vnode === false || vnode === '') { @@ -326,7 +330,7 @@ function _renderToString( isSvgMode, selectValue, parent, - asyncMode + renderMode ); if (typeof childRender === 'string') { @@ -391,7 +395,7 @@ function _renderToString( isSvgMode, selectValue, vnode, - asyncMode + renderMode ); } else { // Values are pre-escaped by the JSX transform @@ -472,7 +476,7 @@ function _renderToString( isSvgMode, selectValue, vnode, - asyncMode + renderMode ); return str; } catch (err) { @@ -504,7 +508,7 @@ function _renderToString( isSvgMode, selectValue, vnode, - asyncMode + renderMode ); } @@ -531,7 +535,7 @@ function _renderToString( isSvgMode, selectValue, vnode, - asyncMode + renderMode ); try { @@ -545,10 +549,11 @@ function _renderToString( return str; } catch (error) { - if (!asyncMode) throw error; + if (renderMode === MODE_SYNC) throw error; if (!error || typeof error.then !== 'function') throw error; + console.log(renderMode, error); const renderNestedChildren = () => { try { return renderChildren(); @@ -699,7 +704,7 @@ function _renderToString( childSvgMode, selectValue, vnode, - asyncMode + renderMode ); } diff --git a/test/compat/stream.test.js b/test/compat/stream.test.js index 48003035..0016ee19 100644 --- a/test/compat/stream.test.js +++ b/test/compat/stream.test.js @@ -16,7 +16,7 @@ async function drain(iter) { return out; } -describe('streaming renderToString', () => { +describe('renderToStream', () => { it('should render JSX after a suspense boundary', async () => { const { Suspender, suspended } = createSuspender(); @@ -24,7 +24,7 @@ describe('streaming renderToString', () => { renderToStream(

foo

- loading...
}> +
bar
@@ -36,7 +36,7 @@ describe('streaming renderToString', () => { suspended.resolve(); const rendered = await promise; expect(rendered).to.deep.equal([ - '

foo

baz

', + '

foo

baz

', '
bar
' ]); }); @@ -50,7 +50,7 @@ describe('streaming renderToString', () => {

foo

- loading...
}> +
bar
@@ -64,9 +64,33 @@ describe('streaming renderToString', () => { suspended.resolve(); const rendered = await promise; expect(rendered).to.deep.equal([ - '

foo

baz

', + '

foo

baz

', '
bar
', '' ]); }); + + it.only('should render suspense fallback in placeholder', async () => { + const { Suspender, suspended } = createSuspender(); + + const promise = drain( + renderToStream( +
+

foo

+ loading...

}> + +
bar
+
+
+

baz

+
+ ) + ); + suspended.resolve(); + const rendered = await promise; + expect(rendered).to.deep.equal([ + '

foo

baz

', + '
bar
' + ]); + }); }); 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