From 11e8208406cc46e701207f85729ea76bc7968776 Mon Sep 17 00:00:00 2001 From: Marvin Hagemeister Date: 2024年3月24日 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 === '

AltStyle によって変換されたページ (->オリジナル) /

' || 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: 2024年3月24日 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
' + ]); + }); });