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 === '

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

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