diff --git a/.gitignore b/.gitignore index b56b5152..aa43b8db 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,4 @@ /npm-debug.log .DS_Store /src/preact-render-to-string-tests.d.ts -/benchmarks/.v8.mjs +/benchmarks/.v8.modern.js diff --git a/jsx.d.ts b/jsx.d.ts index 90042308..277bda5a 100644 --- a/jsx.d.ts +++ b/jsx.d.ts @@ -10,12 +10,8 @@ interface Options { skipFalseAttributes?: boolean; } -export default function renderToStringPretty( +export default function render( vnode: VNode, context?: any, options?: Options ): string; - -export function shallowRender(vnode: VNode, context?: any): string; - -export default render; diff --git a/package-lock.json b/package-lock.json index a8daf1f5..9f2bdadd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,7 +29,8 @@ "prettier": "^2.2.1", "sinon": "^9.2.2", "sinon-chai": "^3.5.0", - "typescript": "^4.1.3" + "typescript": "^4.1.3", + "web-streams-polyfill": "^3.2.1" }, "peerDependencies": { "preact": ">=10" @@ -13111,6 +13112,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/unbox-primitive": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz", + "integrity": "sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "has-bigints": "^1.0.2", + "has-symbols": "^1.0.3", + "which-boxed-primitive": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/unicode-canonical-property-names-ecmascript": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-1.0.4.tgz", @@ -13245,6 +13261,15 @@ "defaults": "^1.0.3" } }, + "node_modules/web-streams-polyfill": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.2.1.tgz", + "integrity": "sha512-e0MO3wdXWKrLbL0DgGnUV7WHVuw9OUvL4hjgnPkIeEvESk74gAITi5G606JtZPp39cd8HA9VQzCIvA49LpPN5Q==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", @@ -24459,6 +24484,12 @@ "defaults": "^1.0.3" } }, + "web-streams-polyfill": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.2.1.tgz", + "integrity": "sha512-e0MO3wdXWKrLbL0DgGnUV7WHVuw9OUvL4hjgnPkIeEvESk74gAITi5G606JtZPp39cd8HA9VQzCIvA49LpPN5Q==", + "dev": true + }, "webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", diff --git a/package.json b/package.json index a4393301..7797f7df 100644 --- a/package.json +++ b/package.json @@ -21,11 +21,23 @@ "browser": "./dist/jsx.module.js", "require": "./dist/jsx.js" }, + "./stream": { + "types": "./stream.d.ts", + "import": "./dist/stream.mjs", + "browser": "./dist/stream.module.js", + "require": "./dist/stream.js" + }, + "./stream-node": { + "types": "./stream-node.d.ts", + "import": "./dist/stream-node.mjs", + "browser": "./dist/stream-node.module.js", + "require": "./dist/stream-node.js" + }, "./package.json": "./package.json" }, "scripts": { "bench": "BABEL_ENV=test node -r @babel/register benchmarks index.js", - "bench:v8": "BABEL_ENV=test microbundle benchmarks/index.js -f modern --alias benchmarkjs-pretty=benchmarks/lib/benchmark-lite.js --external none --target node --no-compress --no-sourcemap --raw -o benchmarks/.v8.mjs && v8 --module benchmarks/.v8.mjs", + "bench:v8": "BABEL_ENV=test microbundle benchmarks/index.js -f modern --alias benchmarkjs-pretty=benchmarks/lib/benchmark-lite.js --external none --target node --no-compress --no-sourcemap --raw -o benchmarks/.v8.mjs && v8 --module benchmarks/.v8.modern.js", "build": "npm run -s transpile && npm run -s transpile:jsx && npm run -s copy-typescript-definition", "postbuild": "node ./config/node-13-exports.js && node ./config/node-commonjs.js", "transpile": "microbundle src/index.js -f es,umd --target web --external preact", @@ -33,7 +45,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", @@ -61,7 +73,8 @@ "new-cap": 0, "curly": "off", "brace-style": "off", - "indent": "off" + "indent": "off", + "lines-around-comment": "off" }, "settings": { "react": { @@ -125,7 +138,8 @@ "prettier": "^2.2.1", "sinon": "^9.2.2", "sinon-chai": "^3.5.0", - "typescript": "^4.1.3" + "typescript": "^4.1.3", + "web-streams-polyfill": "^3.2.1" }, "dependencies": { "pretty-format": "^3.8.0" diff --git a/src/index.js b/src/index.js index 80954730..7c29d06c 100644 --- a/src/index.js +++ b/src/index.js @@ -1,5 +1,5 @@ -import { encodeEntities, styleObjToCss, UNSAFE_NAME, XLINK } from './util'; import { options, h, Fragment } from 'preact'; +import { encodeEntities, styleObjToCss, UNSAFE_NAME, XLINK } from './lib/util'; import { CHILDREN, COMMIT, @@ -11,10 +11,9 @@ import { PARENT, RENDER, SKIP_EFFECTS, - VNODE -} from './constants'; - -/** @typedef {import('preact').VNode} VNode */ + VNODE, + CATCH_ERROR +} from './lib/constants'; const EMPTY_ARR = []; const isArray = Array.isArray; @@ -27,9 +26,10 @@ let beforeDiff, afterDiff, renderHook; * Render Preact JSX + Components to an HTML string. * @param {VNode} vnode JSX Element / VNode to render * @param {Object} [context={}] Initial root context object + * @param {RendererState} [_rendererState] for internal use * @returns {string} serialized HTML */ -export default function renderToString(vnode, context) { +export default function renderToString(vnode, context, _rendererState) { // 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 @@ -47,7 +47,14 @@ export default function renderToString(vnode, context) { parent[CHILDREN] = [vnode]; try { - return _renderToString(vnode, context || {}, false, undefined, parent); + return _renderToString( + vnode, + context || {}, + false, + undefined, + parent, + _rendererState + ); } finally { // options._commit, we don't schedule any effects in this library right now, // so we can pass an empty queue to this hook. @@ -111,9 +118,17 @@ function renderClassComponent(vnode, context) { * @param {boolean} isSvgMode * @param {any} selectValue * @param {VNode} parent + * @param {RendererState | undefined} [renderer] * @returns {string} */ -function _renderToString(vnode, context, isSvgMode, selectValue, parent) { +function _renderToString( + vnode, + context, + isSvgMode, + selectValue, + parent, + renderer +) { // Ignore non-rendered VNodes/values if (vnode == null || vnode === true || vnode === false || vnode === '') { return ''; @@ -135,7 +150,14 @@ function _renderToString(vnode, context, isSvgMode, selectValue, parent) { rendered = rendered + - _renderToString(child, context, isSvgMode, selectValue, parent); + _renderToString( + child, + context, + isSvgMode, + selectValue, + parent, + renderer + ); if ( typeof child === 'string' || @@ -218,20 +240,42 @@ 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; - - if (options.unmount) options.unmount(vnode); + try { + // Recurse into children before invoking the after-diff hook + const str = _renderToString( + rendered, + context, + isSvgMode, + selectValue, + vnode, + renderer + ); + + if (afterDiff) afterDiff(vnode); + vnode[PARENT] = undefined; + + if (options.unmount) options.unmount(vnode); + + return str; + } catch (error) { + if (renderer && renderer.onError) { + let res = renderer.onError(error, vnode, (child) => + _renderToString( + child, + context, + isSvgMode, + selectValue, + vnode, + renderer + ) + ); + if (res !== undefined) return res; + } - return str; + let errorHook = options[CATCH_ERROR]; + if (errorHook) errorHook(error, vnode); + return ''; + } } // Serialize Element VNodes to HTML @@ -341,7 +385,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, + renderer + ); } if (afterDiff) afterDiff(vnode); diff --git a/src/internal.d.ts b/src/internal.d.ts new file mode 100644 index 00000000..26deff6f --- /dev/null +++ b/src/internal.d.ts @@ -0,0 +1,38 @@ +// import { VNode } from 'preact'; + +type VNode = import('preact').VNode; +type ComponentChildren = import('preact').ComponentChildren; + +interface Suspended { + id: string; + promise: Promise; + context: any; + isSvgMode: boolean; + selectValue: any; + vnode: VNode; + parent: VNode | null; +} + +interface RendererErrorHandler { + ( + this: RendererState, + error: any, + vnode: VNode<{ fallback: any }>, + renderChild: (child: ComponentChildren) => string + ): string | undefined; +} + +interface RendererState { + start: number; + suspended: Suspended[]; + abortSignal?: AbortSignal | undefined; + onWrite: (str: string) => void; + onError?: RendererErrorHandler; +} + +interface RenderToChunksOptions { + context?: any; + onError?: (error: any) => void; + onWrite: (str: string) => void; + abortSignal?: AbortSignal; +} diff --git a/src/jsx.d.ts b/src/jsx.d.ts index 90042308..6cee4c0d 100644 --- a/src/jsx.d.ts +++ b/src/jsx.d.ts @@ -16,6 +16,10 @@ export default function renderToStringPretty( options?: Options ): string; -export function shallowRender(vnode: VNode, context?: any): string; +export function shallowRender( + vnode: VNode, + context?: any, + options?: Options +): string; export default render; diff --git a/src/jsx.js b/src/jsx.js index 2afe9a76..479b6bcb 100644 --- a/src/jsx.js +++ b/src/jsx.js @@ -1,10 +1,8 @@ -import './polyfills'; +import './lib/polyfills'; import renderToString from './pretty'; -import { indent, encodeEntities } from './util'; +import { indent, encodeEntities } from './lib/util'; import prettyFormat from 'pretty-format'; -/** @typedef {import('preact').VNode} VNode */ - // we have to patch in Array support, Possible issue in npm.im/pretty-format let preactPlugin = { test(object) { diff --git a/src/lib/chunked.js b/src/lib/chunked.js new file mode 100644 index 00000000..7d699d49 --- /dev/null +++ b/src/lib/chunked.js @@ -0,0 +1,82 @@ +import renderToString from '../index'; +import { CHILD_DID_SUSPEND, COMPONENT, MASK, PARENT } from './constants'; +import { Deferred } from './util'; +import { createInitScript, createSubtree } from './client'; + +/** + * @param {VNode} vnode + * @param {RenderToChunksOptions} options + * @returns {Promise} + */ +export async function renderToChunks(vnode, { context, onWrite, abortSignal }) { + context = context || {}; + + /** @type {RendererState} */ + const renderer = { + start: Date.now(), + abortSignal, + onWrite, + onError: handleError, + suspended: [] + }; + + // Synchronously render the shell + // @ts-ignore - using third internal RendererState argument + const shell = renderToString(vnode, context, renderer); + onWrite(shell); + + // Wait for any suspended sub-trees if there are any + const len = renderer.suspended.length; + if (len > 0) { + onWrite(''); + } +} + +/** @type {RendererErrorHandler} */ +function handleError(error, vnode, renderChild) { + if (!error || !error.then) return; + + // walk up to the Suspense boundary + while ((vnode = vnode[PARENT])) { + let component = vnode[COMPONENT]; + if (component && component[CHILD_DID_SUSPEND]) { + break; + } + } + + if (!vnode) return; + + const id = vnode[MASK] + this.suspended.length; + + const race = new Deferred(); + + const abortSignal = this.abortSignal; + if (abortSignal) { + // @ts-ignore 2554 - implicit undefined arg + if (abortSignal.aborted) race.resolve(); + else abortSignal.addEventListener('abort', race.resolve); + } + + const promise = error.then( + () => { + if (abortSignal && abortSignal.aborted) return; + this.onWrite(createSubtree(id, renderChild(vnode.props.children))); + }, + // TODO: Abort and send hydration code snippet to client + // to attempt to recover during hydration + this.onError + ); + + this.suspended.push({ + id, + vnode, + promise: Promise.race([promise, race.promise]) + }); + + const fallback = renderChild(vnode.props.fallback); + + return `${fallback}`; +} diff --git a/src/lib/client.js b/src/lib/client.js new file mode 100644 index 00000000..b704ef84 --- /dev/null +++ b/src/lib/client.js @@ -0,0 +1,55 @@ +/* eslint-disable no-var, key-spacing, object-curly-spacing, prefer-arrow-callback, semi, keyword-spacing */ + +function initPreactIslandElement() { + class PreactIslandElement extends HTMLElement { + connectedCallback() { + var d = this; + if (!d.isConnected) return; + + let i = this.getAttribute('data-target'); + if (!i) return; + + var s, + e, + c = document.createNodeIterator(document, 128); + while (c.nextNode()) { + let n = c.referenceNode; + if (n.data == 'preact-island:' + i) s = n; + else if (n.data == '/preact-island:' + i) e = n; + if (s && e) break; + } + if (s && e) { + var p = e.previousSibling; + while (p != s) { + if (!p || p == s) break; + + e.parentNode.removeChild(p); + p = e.previousSibling; + } + while (d.firstChild) e.parentNode.insertBefore(d.firstChild, e); + + d.parentNode.removeChild(d); + } + } + } + + customElements.define('preact-island', PreactIslandElement); +} + +const fn = initPreactIslandElement.toString(); +const INIT_SCRIPT = fn + .slice(fn.indexOf('{') + 1, fn.lastIndexOf('}')) + .replace(/\n\s+/gm, ''); + +export function createInitScript() { + return ``; +} + +/** + * @param {string} id + * @param {string} content + * @returns {string} + */ +export function createSubtree(id, content) { + return ``; +} diff --git a/src/constants.js b/src/lib/constants.js similarity index 79% rename from src/constants.js rename to src/lib/constants.js index bedbc8ab..0d48e5b0 100644 --- a/src/constants.js +++ b/src/lib/constants.js @@ -4,13 +4,16 @@ export const RENDER = '__r'; export const DIFFED = 'diffed'; export const COMMIT = '__c'; export const SKIP_EFFECTS = '__s'; +export const CATCH_ERROR = '__e'; // VNode properties export const COMPONENT = '__c'; export const CHILDREN = '__k'; export const PARENT = '__'; +export const MASK = '__m'; // Component properties export const VNODE = '__v'; export const DIRTY = '__d'; export const NEXT_STATE = '__s'; +export const CHILD_DID_SUSPEND = '__c'; diff --git a/src/polyfills.js b/src/lib/polyfills.js similarity index 100% rename from src/polyfills.js rename to src/lib/polyfills.js diff --git a/src/util.js b/src/lib/util.js similarity index 81% rename from src/util.js rename to src/lib/util.js index 78a0a6ad..5684383b 100644 --- a/src/util.js +++ b/src/lib/util.js @@ -110,3 +110,29 @@ export function createComponent(vnode, context) { __h: [] }; } + +// Necessary for createContext api. Setting this property will pass +// the context value as `this.context` just for this component. +export function getContext(nodeName, context) { + let cxType = nodeName.contextType; + let provider = cxType && context[cxType.__c]; + return cxType != null + ? provider + ? provider.props.value + : cxType.__ + : context; +} + +/** + * @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/src/pretty.js b/src/pretty.js index fd73431d..90e7c018 100644 --- a/src/pretty.js +++ b/src/pretty.js @@ -8,12 +8,10 @@ import { UNSAFE_NAME, XLINK, VOID_ELEMENTS -} from './util'; -import { COMMIT, DIFF, DIFFED, RENDER, SKIP_EFFECTS } from './constants'; +} from './lib/util'; +import { COMMIT, DIFF, DIFFED, RENDER, SKIP_EFFECTS } from './lib/constants'; import { options, Fragment } from 'preact'; -/** @typedef {import('preact').VNode} VNode */ - // components without names, kept as a hash for later comparison to return consistent UnnamedComponentXX names. const UNNAMED = []; @@ -133,6 +131,7 @@ function _renderToStringPretty( !nodeName.prototype || typeof nodeName.prototype.render !== 'function' ) { + // let cctx = getContext(nodeName, context); // If a hook invokes setState() to invalidate the component during rendering, // re-render it up to 25 times to allow "settling" of memoized states. @@ -149,6 +148,7 @@ function _renderToStringPretty( rendered = nodeName.call(vnode.__c, props, cctx); } } else { + // let cctx = getContext(nodeName, context); // c = new nodeName(props, context); c = vnode.__c = new nodeName(props, cctx); diff --git a/src/stream-node.js b/src/stream-node.js new file mode 100644 index 00000000..1806a756 --- /dev/null +++ b/src/stream-node.js @@ -0,0 +1,60 @@ +import { PassThrough } from 'node:stream'; +import { renderToChunks } from './lib/chunked'; + +/** + * @typedef {object} RenderToPipeableStreamOptions + * @property {() => void} [onShellReady] + * @property {() => void} [onAllReady] + * @property {(error) => void} [onError] + */ + +/** + * @param {import('preact').VNode} vnode + * @param {RenderToPipeableStreamOptions} options + * @param {any} [context] + * @returns {{}} + */ +export function renderToPipeableStream(vnode, options, context) { + const encoder = new TextEncoder('utf-8'); + + const controller = new AbortController(); + const stream = new PassThrough(); + + renderToChunks(vnode, { + context, + abortSignal: controller.signal, + onError: (error) => { + if (options.onError) { + options.onError(error); + } + controller.abort(error); + }, + onWrite(s) { + stream.write(encoder.encode(s)); + } + }) + .then(() => { + options.onAllReady && options.onAllReady(); + stream.end(); + }) + .catch((error) => { + stream.destroy(error); + }); + + Promise.resolve().then(() => { + options.onShellReady && options.onShellReady(); + }); + + return { + abort() { + controller.abort(); + stream.destroy(new Error('aborted')); + }, + /** + * @param {import("stream").Writable} writable + */ + pipe(writable) { + stream.pipe(writable, { end: true }); + } + }; +} diff --git a/src/stream.js b/src/stream.js new file mode 100644 index 00000000..f951c34a --- /dev/null +++ b/src/stream.js @@ -0,0 +1,44 @@ +import { Deferred } from './lib/util'; +import { renderToChunks } from './lib/chunked'; + +/** @typedef {ReadableStream & { allReady: Promise}} RenderStream */ + +/** + * @param {import('preact').VNode} vnode + * @param {any} [context] + * @returns {RenderStream} + */ +export function renderToReadableStream(vnode, context) { + /** @type {Deferred} */ + const allReady = new Deferred(); + const encoder = new TextEncoder('utf-8'); + + /** @type {RenderStream} */ + const stream = new ReadableStream({ + start(controller) { + renderToChunks(vnode, { + context, + onError: (error) => { + allReady.reject(error); + controller.abort(error); + }, + onWrite(s) { + controller.enqueue(encoder.encode(s)); + } + }) + .then(() => { + controller.close(); + allReady.resolve(); + }) + .catch((error) => { + controller.error(error); + allReady.reject(error); + }); + // controller.enqueue(encoder.encode(shell)); + } + }); + + stream.allReady = allReady.promise; + + return stream; +} diff --git a/test/compat/render-chunked.test.js b/test/compat/render-chunked.test.js new file mode 100644 index 00000000..9d8a0c0e --- /dev/null +++ b/test/compat/render-chunked.test.js @@ -0,0 +1,69 @@ +import { h } from 'preact'; +import { expect } from 'chai'; +import { Suspense } from 'preact/compat'; +import { renderToChunks } from '../../src/lib/chunked'; +import { createSubtree, createInitScript } from '../../src/lib/client'; +import { createSuspender } from '../utils'; + +describe('renderToChunks', () => { + it('should render non-suspended JSX in one go', async () => { + const result = []; + await renderToChunks(
bar
, { + onWrite: (s) => result.push(s) + }); + + expect(result).to.deep.equal(['
bar
']); + }); + + it('should render fallback + attach loaded subtree on suspend', async () => { + const { Suspender, suspended } = createSuspender(); + + const result = []; + const promise = renderToChunks( +
+ + + +
, + { onWrite: (s) => result.push(s) } + ); + suspended.resolve(); + + await promise; + + expect(result).to.deep.equal([ + '
loading...
', + '' + ]); + }); + + it('should abort pending suspensions with AbortSignal', async () => { + const { Suspender, suspended } = createSuspender(); + + const controller = new AbortController(); + const result = []; + const promise = renderToChunks( +
+ + + +
, + { onWrite: (s) => result.push(s), abortSignal: controller.signal } + ); + + controller.abort(); + await promise; + + suspended.resolve(); + + expect(result).to.deep.equal([ + '
loading...
', + '' + ]); + }); +}); diff --git a/test/compat/stream-node.test.js b/test/compat/stream-node.test.js new file mode 100644 index 00000000..a2079cb4 --- /dev/null +++ b/test/compat/stream-node.test.js @@ -0,0 +1,76 @@ +import { PassThrough } from 'node:stream'; +import { h } from 'preact'; +import { expect } from 'chai'; +import { Suspense } from 'preact/compat'; +import { createSubtree, createInitScript } from '../../src/lib/client'; +import { renderToPipeableStream } from '../../src/stream-node'; +import { Deferred } from '../../src/lib/util'; +import { createSuspender } from '../utils'; + +function streamToString(stream) { + const decoder = new TextDecoder(); + const def = new Deferred(); + stream.on('data', (chunk) => { + chunks.push(decoder.decode(chunk)); + }); + stream.on('error', (err) => def.reject(err)); + stream.on('end', () => def.resolve(chunks)); + const chunks = []; + return def; +} + +/** + * @param {ReadableStream} input + */ +function createSink() { + const stream = new PassThrough(); + const def = streamToString(stream); + + return { + promise: def.promise, + stream + }; +} + +describe('renderToPipeableStream', () => { + it('should render non-suspended JSX in one go', async () => { + const sink = createSink(); + const { pipe } = renderToPipeableStream(
bar
, { + onAllReady: () => { + pipe(sink.stream); + } + }); + const result = await sink.promise; + + expect(result).to.deep.equal(['
bar
']); + }); + + it('should render fallback + attach loaded subtree on suspend', async () => { + const { Suspender, suspended } = createSuspender(); + + const sink = createSink(); + const { pipe } = renderToPipeableStream( +
+ + + +
, + { + onShellReady: () => { + pipe(sink.stream); + } + } + ); + suspended.resolve(); + + const result = await sink.promise; + + expect(result).to.deep.equal([ + '
loading...
', + '' + ]); + }); +}); diff --git a/test/compat/stream.test.js b/test/compat/stream.test.js new file mode 100644 index 00000000..48f91617 --- /dev/null +++ b/test/compat/stream.test.js @@ -0,0 +1,92 @@ +/*global globalThis*/ +import { h } from 'preact'; +import { expect } from 'chai'; +import { Suspense } from 'preact/compat'; +import { createSubtree, createInitScript } from '../../src/lib/client'; +import { renderToReadableStream } from '../../src/stream'; +import { Deferred } from '../../src/lib/util'; +import { createSuspender } from '../utils'; + +/** + * @param {ReadableStream} input + */ +function createSink(input) { + const decoder = new TextDecoder('utf-8'); + const queuingStrategy = new CountQueuingStrategy({ highWaterMark: 1 }); + + const def = new Deferred(); + const result = []; + + const stream = new WritableStream( + { + // Implement the sink + write(chunk) { + result.push(decoder.decode(chunk)); + }, + close() { + def.resolve(result); + }, + abort(err) { + def.reject(err); + } + }, + queuingStrategy + ); + + input.pipeTo(stream); + + return { + promise: def.promise, + stream + }; +} + +describe('renderToReadableStream', () => { + before(async () => { + // attempt to use native web streams in Node 18, otherwise fall back to a polyfill: + let streams; + try { + streams = await import('node:stream/web'); + } catch (e) { + streams = await import('web-streams-polyfill/ponyfill'); + } + const { ReadableStream, WritableStream, CountQueuingStrategy } = streams; + + globalThis.ReadableStream = ReadableStream; + globalThis.WritableStream = WritableStream; + globalThis.CountQueuingStrategy = CountQueuingStrategy; + }); + + it('should render non-suspended JSX in one go', async () => { + const stream = await renderToReadableStream(
bar
); + const sink = createSink(stream); + const result = await sink.promise; + + expect(result).to.deep.equal(['
bar
']); + }); + + it('should render fallback + attach loaded subtree on suspend', async () => { + const { Suspender, suspended } = createSuspender(); + + const stream = renderToReadableStream( +
+ + + +
, + { onWrite: (s) => result.push(s) } + ); + const sink = createSink(stream); + suspended.resolve(); + + const result = await sink.promise; + + expect(result).to.deep.equal([ + '
loading...
', + '' + ]); + }); +}); diff --git a/test/render.test.js b/test/render.test.js index 17391c3a..c1b08aa1 100644 --- a/test/render.test.js +++ b/test/render.test.js @@ -21,6 +21,7 @@ import { expect } from 'chai'; import { spy, stub, match } from 'sinon'; function shallowRender(vnode) { + const context = {}; return renderToStringJSX(vnode, context, { jsx: false, xml: false, diff --git a/test/utils.js b/test/utils.js index abb3418a..ef25901a 100644 --- a/test/utils.js +++ b/test/utils.js @@ -1,3 +1,6 @@ +import { h } from 'preact'; +import { Deferred } from '../src/lib/util'; + /** * tag to remove leading whitespace from tagged template * literal. @@ -10,3 +13,22 @@ export function dedent([str]) { .join('\n') .replace(/(^\n+|\n+\s*$)/g, ''); } + +export function createSuspender() { + const deferred = new Deferred(); + let resolved; + + deferred.promise.then(() => (resolved = true)); + function Suspender() { + if (!resolved) { + throw deferred.promise; + } + + return

it works

; + } + + return { + suspended: deferred, + Suspender + }; +}