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.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..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, @@ -30,13 +33,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 +48,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]; @@ -61,7 +82,7 @@ export function renderToString(vnode, context) { false, undefined, parent, - false + MODE_SYNC ); } catch (e) { if (e.then) { @@ -70,11 +91,7 @@ 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); } } @@ -82,22 +99,10 @@ export function renderToString(vnode, context) { * 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 + * @returns {Promise} 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 previousSkipEffects = prepare(); const parent = h(Fragment, null); parent[CHILDREN] = [vnode]; @@ -109,7 +114,7 @@ export async function renderToStringAsync(vnode, context) { false, undefined, parent, - true + MODE_ASYNC ); if (Array.isArray(rendered)) { @@ -129,11 +134,95 @@ 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); + } +} + +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, content: string) => string} [renderSlot] Render slot marker + * @returns {ReadableStream|string} serialized HTML + */ +export function renderToStream( + vnode, + context, + renderSlot = DEFAULT_RENDER_SLOT +) { + const previousSkipEffects = prepare(); + + const parent = h(Fragment, null); + parent[CHILDREN] = [vnode]; + + try { + const rendered = _renderToString( + vnode, + context || EMPTY_OBJ, + false, + undefined, + parent, + MODE_STREAM + ); + + 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); } } @@ -204,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( @@ -212,7 +302,7 @@ function _renderToString( isSvgMode, selectValue, parent, - asyncMode + renderMode ) { // Ignore non-rendered VNodes/values if (vnode == null || vnode === true || vnode === false || vnode === '') { @@ -240,7 +330,7 @@ function _renderToString( isSvgMode, selectValue, parent, - asyncMode + renderMode ); if (typeof childRender === 'string') { @@ -305,7 +395,7 @@ function _renderToString( isSvgMode, selectValue, vnode, - asyncMode + renderMode ); } else { // Values are pre-escaped by the JSX transform @@ -386,7 +476,7 @@ function _renderToString( isSvgMode, selectValue, vnode, - asyncMode + renderMode ); return str; } catch (err) { @@ -418,7 +508,7 @@ function _renderToString( isSvgMode, selectValue, vnode, - asyncMode + renderMode ); } @@ -445,7 +535,7 @@ function _renderToString( isSvgMode, selectValue, vnode, - asyncMode + renderMode ); try { @@ -459,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(); @@ -613,7 +704,7 @@ function _renderToString( childSvgMode, selectValue, vnode, - asyncMode + renderMode ); } diff --git a/test/compat/stream.test.js b/test/compat/stream.test.js new file mode 100644 index 00000000..0016ee19 --- /dev/null +++ b/test/compat/stream.test.js @@ -0,0 +1,96 @@ +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('renderToStream', () => { + it('should render JSX after a suspense boundary', async () => { + const { Suspender, suspended } = createSuspender(); + + const promise = drain( + renderToStream( +

+

foo

+ + +
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

+ + +
bar
+
+
+

baz

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

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