From 5799b58edd07a1a606dd6d3bae190b9d4847b241 Mon Sep 17 00:00:00 2001 From: Marvin Hagemeister Date: Sat, 15 Oct 2022 21:45:50 +0200 Subject: [PATCH 01/30] Disable eslint lines-around-comment rule --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 514446d2..b08fd104 100644 --- a/package.json +++ b/package.json @@ -60,7 +60,8 @@ "new-cap": 0, "curly": "off", "brace-style": "off", - "indent": "off" + "indent": "off", + "lines-around-comment": "off" }, "settings": { "react": { From 8b104e23e7dd6cff934b518606a2d35cf0802279 Mon Sep 17 00:00:00 2001 From: Marvin Hagemeister Date: Sun, 16 Oct 2022 07:20:37 +0200 Subject: [PATCH 02/30] Update test scripts to allow watch usage --- package.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index b08fd104..0c193982 100644 --- a/package.json +++ b/package.json @@ -31,9 +31,9 @@ "transpile": "microbundle src/index.js -f es,umd --target web --external preact", "transpile:jsx": "microbundle src/jsx.js -o dist/jsx.js --target web --external preact && microbundle dist/jsx.js -o dist/jsx.js -f cjs --external preact", "copy-typescript-definition": "copyfiles -f src/*.d.ts dist", - "test": "eslint src test && tsc && npm run test:mocha && npm run bench", - "test:mocha": "BABEL_ENV=test mocha -r @babel/register -r test/setup.js test/**/[!compat]*.test.js && npm run test:mocha:compat", - "test:mocha:compat": "BABEL_ENV=test mocha -r @babel/register -r test/setup.js test/compat.test.js", + "test": "eslint src test && tsc && npm run test:mocha && npm run test:mocha:compat && npm run bench", + "test:mocha": "BABEL_ENV=test mocha -r @babel/register -r test/setup.js test/**/[!compat]*.test.js", + "test:mocha:compat": "BABEL_ENV=test mocha -r @babel/register -r test/setup.js test/compat.test.js 'test/compat-*.test.js'", "format": "prettier src/**/*.{d.ts,js} test/**/*.js --write", "prepublishOnly": "npm run build", "release": "npm run build && git commit -am $npm_package_version && git tag $npm_package_version && git push && git push --tags && npm publish" From 20d189b72206f8d1fe055a251b0f6b4d4bec01ae Mon Sep 17 00:00:00 2001 From: Marvin Hagemeister Date: Sun, 16 Oct 2022 09:07:01 +0200 Subject: [PATCH 03/30] Add streaming renderer --- package.json | 12 ++ src/client.js | 59 +++++++++ src/constants.js | 3 + src/index.d.ts | 8 ++ src/index.js | 194 ++++++++++++++++++++++++++--- src/stream-node.js | 0 src/stream.js | 52 ++++++++ src/util.js | 14 +++ test/compat-render-chunked.test.js | 71 +++++++++++ test/compat-stream.test.js | 33 +++++ test/setup.js | 9 ++ test/utils.js | 22 ++++ 12 files changed, 459 insertions(+), 18 deletions(-) create mode 100644 src/client.js create mode 100644 src/stream-node.js create mode 100644 src/stream.js create mode 100644 test/compat-render-chunked.test.js create mode 100644 test/compat-stream.test.js diff --git a/package.json b/package.json index 0c193982..cbd247b4 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,18 @@ "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": { diff --git a/src/client.js b/src/client.js new file mode 100644 index 00000000..30ff4859 --- /dev/null +++ b/src/client.js @@ -0,0 +1,59 @@ +/** + * @param {string} id + */ +function initPreactIsland(id) { + const el = document.getElementById(id); + if (!el) return; + const stack = [document.body]; + let item; + + let startComment; + while ((item = stack.pop()) !== undefined) { + if ( + item.nodeType === 8 && + /** @type {Comment} **/ + item.data === id + ) { + startComment = item; + break; + } + + // eslint-disable-next-line prefer-spread + stack.push.apply(stack, item.childNodes); + } + + const parent = startComment.parentNode; + let next = startComment.nextSibling; + let endComment; + while (next !== null) { + if (next.nodeType === 8 && next.data === '/' + id) { + endComment = next; + break; + } + + const node = next; + next = next.nextSibling; + parent.removeChild(node); + } + + while (el.childNodes.length > 0) { + parent.insertBefore(el.firstChild, endComment); + } + + el.parentNode.removeChild(el); +} + +export const ISLAND_SCRIPT = ``; + +/** + * @param {string} id + * @param {string} content + * @returns {string} + */ +export function createSubtree(id, content) { + return ``; +} + +export function createCleanupScript() { + return ``; +} diff --git a/src/constants.js b/src/constants.js index bedbc8ab..0d48e5b0 100644 --- a/src/constants.js +++ b/src/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/index.d.ts b/src/index.d.ts index 221d349a..c2bf0504 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -13,4 +13,12 @@ export function renderToString( options?: Options ): string; export function shallowRender(vnode: VNode, context?: any): string; + +export interface ChunkedOptions { + onWrite(chunk: string): void; + context?: any; + abortSignal?: AbortSignal; +} +export function renderChunked(vnode: VNode, options: ChunkedOptions): void; + export default render; diff --git a/src/index.js b/src/index.js index bd596ba9..353d94ae 100644 --- a/src/index.js +++ b/src/index.js @@ -5,7 +5,8 @@ import { createComponent, UNSAFE_NAME, XLINK, - VOID_ELEMENTS + VOID_ELEMENTS, + Deferred } from './util'; import { options, h, Fragment } from 'preact'; import { _renderToStringPretty } from './pretty'; @@ -20,8 +21,12 @@ import { RENDER, SKIP_EFFECTS, VNODE, - CHILDREN + CHILDREN, + CHILD_DID_SUSPEND, + CATCH_ERROR, + MASK } from './constants'; +import { createSubtree, ISLAND_SCRIPT, createCleanupScript } from './client'; /** @typedef {import('preact').VNode} VNode */ @@ -89,6 +94,68 @@ function renderToString(vnode, context, opts) { return res; } +/** + * The suspended state of a loading boundary. Stores the renderer state to + * be able to continue rendering later. + * @typedef {{ id: string, promise: Promise, context: any, isSvgMode: boolean, selectValue: any, vnode: VNode, parent: VNode | null}} Suspended + */ + +/** + * @typedef {{ suspended: Suspended[], abortSignal: AbortSignal | undefined, start: number, onWrite: (str: string) => void }} RendererState + */ + +/** + * @param {VNode} vnode + * @param {{ context?: any, onWrite: (str: string) => void, abortSignal?: AbortSignal }} options + * @returns {Promise} + */ +export async function renderChunked(vnode, { context, onWrite, abortSignal }) { + context = 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; + + const parent = h(Fragment, null); + parent[CHILDREN] = [vnode]; + + /** @type {RendererState} */ + const renderer = { + start: Date.now(), + abortSignal, + onWrite, + suspended: [] + }; + + // Synchronously render the shell + const shell = _renderToString( + vnode, + context, + false, + undefined, + parent, + renderer + ); + onWrite(shell); + + // Wait for any suspended sub-trees if there are any + if (renderer.suspended.length > 0) { + onWrite(ISLAND_SCRIPT); + await Promise.all(renderer.suspended.map((s) => s.promise)); + onWrite(createCleanupScript()); + } + + // 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; +} + /** * @param {VNode} vnode * @param {Record} context @@ -215,9 +282,17 @@ const assign = Object.assign; * @param {boolean} isSvgMode * @param {any} selectValue * @param {VNode | null} 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 ''; @@ -236,7 +311,14 @@ function _renderToString(vnode, context, isSvgMode, selectValue, parent) { for (let i = 0; i < vnode.length; i++) { rendered = rendered + - _renderToString(vnode[i], context, isSvgMode, selectValue, parent); + _renderToString( + vnode[i], + context, + isSvgMode, + selectValue, + parent, + renderer + ); } return rendered; } @@ -276,20 +358,94 @@ function _renderToString(vnode, context, isSvgMode, selectValue, parent) { rendered = isTopLevelFragment ? rendered.props.children : rendered; // Recurse into children before invoking the after-diff hook - const str = _renderToString( - rendered, - context, - isSvgMode, - selectValue, - vnode - ); - - if (options[DIFFED]) options[DIFFED](vnode); - vnode[PARENT] = undefined; - if (options.unmount) options.unmount(vnode); + try { + const str = _renderToString( + rendered, + context, + isSvgMode, + selectValue, + vnode, + renderer + ); + + if (options[DIFFED]) options[DIFFED](vnode); + vnode[PARENT] = undefined; + + if (options.unmount) options.unmount(vnode); + + return str; + } catch (error) { + if (renderer !== undefined && error.then) { + /** @type {import('./internal').Component} */ + let component; + let susVNode = vnode; + + for (; (susVNode = susVNode[PARENT]); ) { + if ( + (component = susVNode[COMPONENT]) && + component[CHILD_DID_SUSPEND] + ) { + const id = + 'preact-island-' + susVNode[MASK] + renderer.suspended.length; + + const abortSignal = renderer.abortSignal; + + const race = new Deferred(); + if (abortSignal) { + if (abortSignal.aborted) race.resolve(); + else { + abortSignal.addEventListener('abort', race.resolve); + } + } + + renderer.suspended.push({ + id, + context, + isSvgMode, + parent, + selectValue, + vnode: susVNode, + promise: Promise.race([ + error.then(() => { + if (abortSignal && abortSignal.aborted) { + return; + } + + const str = _renderToString( + susVNode.props.children, + context, + isSvgMode, + selectValue, + susVNode, + renderer + ); + + renderer.onWrite(createSubtree(id, str)); + }), + race.promise + ]) + }); + + const fallback = _renderToString( + susVNode.props.fallback, + context, + isSvgMode, + selectValue, + susVNode, + renderer + ); + + return `${fallback}`; + } + } + } - return str; + console.log('WOA', error, renderer); + let errorHook = options[CATCH_ERROR]; + if (errorHook) errorHook(error, vnode); + return ''; + } } // Serialize Element VNodes to HTML @@ -380,7 +536,8 @@ function _renderToString(vnode, context, isSvgMode, selectValue, parent) { context, childSvgMode, selectValue, - vnode + vnode, + renderer ); // Skip if we received an empty string @@ -399,7 +556,8 @@ function _renderToString(vnode, context, isSvgMode, selectValue, parent) { context, childSvgMode, selectValue, - vnode + vnode, + renderer ); // Skip if we received an empty string diff --git a/src/stream-node.js b/src/stream-node.js new file mode 100644 index 00000000..e69de29b diff --git a/src/stream.js b/src/stream.js new file mode 100644 index 00000000..1a542b45 --- /dev/null +++ b/src/stream.js @@ -0,0 +1,52 @@ +import { Deferred } from './util'; +import { renderChunked } from './index'; + +/** @typedef {ReadableStream & { allReady: Promise}} RenderStream */ + +/** + * @param {VNode} vnode + * @param {any} [context] + * @returns {RenderStream} + */ +export function renderToReadableStream(vnode, context) { + /** @type {Deferred} */ + const allReady = new Deferred(); + /** @type {Deferred} */ + const suspended = new Deferred(); + const encoder = new TextEncoder('utf-8'); + + renderChunked(vnode, context); + + const ctx = { + suspended: [] + }; + + setTimeout(() => suspended.resolve(), 1000); + + /** @type {RenderStream} */ + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(shell)); + + if (ctx.suspended.length === 0) { + controller.close(); + allReady.resolve(); + return; + } + + suspended.promise + .then(() => { + controller.close(); + allReady.resolve(); + }) + .catch((error) => { + controller.error(error); + allReady.reject(error); + }); + } + }); + + stream.allReady = allReady.promise; + + return stream; +} diff --git a/src/util.js b/src/util.js index 5da7f18b..3775fb29 100644 --- a/src/util.js +++ b/src/util.js @@ -123,3 +123,17 @@ export function getContext(nodeName, context) { : 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/test/compat-render-chunked.test.js b/test/compat-render-chunked.test.js new file mode 100644 index 00000000..8c87f07e --- /dev/null +++ b/test/compat-render-chunked.test.js @@ -0,0 +1,71 @@ +import { h } from 'preact'; +import { expect } from 'chai'; +import { Suspense } from 'preact/compat'; +import { renderChunked } from '../src/index'; +import { + createCleanupScript, + createSubtree, + ISLAND_SCRIPT +} from '../src/client'; +import { createSuspender } from './utils'; + +describe('renderChunked', () => { + it('should render non-suspended JSX in one go', async () => { + const result = []; + await renderChunked(
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 = renderChunked( +
+ + + +
, + { onWrite: (s) => result.push(s) } + ); + suspended.resolve(); + + await promise; + + expect(result).to.deep.equal([ + '
loading...
', + ISLAND_SCRIPT, + createSubtree('preact-island-00', '

it works

'), + createCleanupScript() + ]); + }); + + it('should abort pending suspensions with AbortSignal', async () => { + const { Suspender, suspended } = createSuspender(); + + const controller = new AbortController(); + const result = []; + const promise = renderChunked( +
+ + + +
, + { onWrite: (s) => result.push(s), abortSignal: controller.signal } + ); + + controller.abort(); + await promise; + + suspended.resolve(); + + expect(result).to.deep.equal([ + '
loading...
', + ISLAND_SCRIPT, + createCleanupScript() + ]); + }); +}); diff --git a/test/compat-stream.test.js b/test/compat-stream.test.js new file mode 100644 index 00000000..376eb211 --- /dev/null +++ b/test/compat-stream.test.js @@ -0,0 +1,33 @@ +/** + * @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 + }; +} diff --git a/test/setup.js b/test/setup.js index 59139093..b3c24c21 100644 --- a/test/setup.js +++ b/test/setup.js @@ -1,4 +1,13 @@ import chai from 'chai'; import sinonChai from 'sinon-chai'; +import { + ReadableStream, + WritableStream, + CountQueuingStrategy +} from 'node:stream/web'; + +globalThis.ReadableStream = ReadableStream; +globalThis.WritableStream = WritableStream; +globalThis.CountQueuingStrategy = CountQueuingStrategy; chai.use(sinonChai); diff --git a/test/utils.js b/test/utils.js index abb3418a..c1e55787 100644 --- a/test/utils.js +++ b/test/utils.js @@ -1,3 +1,6 @@ +import { h } from 'preact'; +import { Deferred } from '../src/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 + }; +} From c2ae535085bd2f12066c41e86597b8df2e4d523f Mon Sep 17 00:00:00 2001 From: Marvin Hagemeister Date: Wed, 19 Oct 2022 07:47:45 +0200 Subject: [PATCH 04/30] Switch to element nodes as markers instead --- src/client.js | 88 +++++++++++++++--------------- src/index.d.ts | 5 +- src/index.js | 17 +++--- src/stream.js | 28 +++------- test/compat-render-chunked.test.js | 32 +++++------ test/compat-stream-node.test.js | 35 ++++++++++++ test/compat-stream.test.js | 14 +++++ 7 files changed, 129 insertions(+), 90 deletions(-) create mode 100644 test/compat-stream-node.test.js diff --git a/src/client.js b/src/client.js index 30ff4859..08fbd40d 100644 --- a/src/client.js +++ b/src/client.js @@ -1,59 +1,59 @@ +/* eslint-disable no-var, key-spacing, object-curly-spacing, prefer-arrow-callback, semi, keyword-spacing */ + /** - * @param {string} id + * @param {number} c Total number of hydration islands */ -function initPreactIsland(id) { - const el = document.getElementById(id); - if (!el) return; - const stack = [document.body]; - let item; - - let startComment; - while ((item = stack.pop()) !== undefined) { - if ( - item.nodeType === 8 && - /** @type {Comment} **/ - item.data === id - ) { - startComment = item; - break; - } - - // eslint-disable-next-line prefer-spread - stack.push.apply(stack, item.childNodes); +function initPreactIslands(c) { + var el = document.currentScript.parentNode; + if (!document.getElementById('praect-island-style')) { + var s = document.createElement('style'); + s.id = 'preact-island-style'; + s.textContent = 'preact-island{display:contents}'; + document.head.appendChild(s); } - - const parent = startComment.parentNode; - let next = startComment.nextSibling; - let endComment; - while (next !== null) { - if (next.nodeType === 8 && next.data === '/' + id) { - endComment = next; - break; + var o = new MutationObserver(function (m) { + for (var i = 0; i < m.length; i++) { + var added = m[i].addedNodes; + for (var j = 0; j < added.length; j++) { + if (added[j].nodeType !== 1) continue; + var id = added[j].getAttribute('data-id'); + var target = document.getElementById(id); + if (target) { + while (target.firstChild !== null) { + target.removeChild(target.firstChild); + } + while (added[j].firstChild !== null) { + target.appendChild(added[j].firstChild); + } + target.hydrate = true; + } + if (--c === 0) { + o.disconnect(); + el.parentNode.removeChild(el); + } + } } + }); + o.observe(el, { childList: true, subtree: false }); +} - const node = next; - next = next.nextSibling; - parent.removeChild(node); - } - - while (el.childNodes.length > 0) { - parent.insertBefore(el.firstChild, endComment); - } +const fn = initPreactIslands.toString(); +const INIT_SCRIPT = fn + .slice(fn.indexOf('{') + 1, fn.lastIndexOf('}')) + .replace(/\n\s+/gm, ''); - el.parentNode.removeChild(el); +/** + * @param {number} total + */ +export function createInitScript(total) { + return ``; } -export const ISLAND_SCRIPT = ``; - /** * @param {string} id * @param {string} content * @returns {string} */ export function createSubtree(id, content) { - return ``; -} - -export function createCleanupScript() { - return ``; + return `
${content}
`; } diff --git a/src/index.d.ts b/src/index.d.ts index c2bf0504..6af243c5 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -19,6 +19,9 @@ export interface ChunkedOptions { context?: any; abortSignal?: AbortSignal; } -export function renderChunked(vnode: VNode, options: ChunkedOptions): void; +export function renderToChunks( + vnode: VNode, + options: ChunkedOptions +): Promise; export default render; diff --git a/src/index.js b/src/index.js index 353d94ae..d560136c 100644 --- a/src/index.js +++ b/src/index.js @@ -26,7 +26,7 @@ import { CATCH_ERROR, MASK } from './constants'; -import { createSubtree, ISLAND_SCRIPT, createCleanupScript } from './client'; +import { createSubtree, createInitScript } from './client'; /** @typedef {import('preact').VNode} VNode */ @@ -109,7 +109,7 @@ function renderToString(vnode, context, opts) { * @param {{ context?: any, onWrite: (str: string) => void, abortSignal?: AbortSignal }} options * @returns {Promise} */ -export async function renderChunked(vnode, { context, onWrite, abortSignal }) { +export async function renderToChunks(vnode, { context, onWrite, abortSignal }) { context = context || {}; // Performance optimization: `renderToString` is synchronous and we @@ -143,10 +143,12 @@ export async function renderChunked(vnode, { context, onWrite, abortSignal }) { onWrite(shell); // Wait for any suspended sub-trees if there are any - if (renderer.suspended.length > 0) { - onWrite(ISLAND_SCRIPT); + const len = renderer.suspended.length; + if (len > 0) { + onWrite(''); } // options._commit, we don't schedule any effects in this library right now, @@ -386,8 +388,7 @@ function _renderToString( (component = susVNode[COMPONENT]) && component[CHILD_DID_SUSPEND] ) { - const id = - 'preact-island-' + susVNode[MASK] + renderer.suspended.length; + const id = 'preact-' + susVNode[MASK] + renderer.suspended.length; const abortSignal = renderer.abortSignal; @@ -436,7 +437,7 @@ function _renderToString( renderer ); - return `${fallback}`; + return `${fallback}`; } } } diff --git a/src/stream.js b/src/stream.js index 1a542b45..0c36cebb 100644 --- a/src/stream.js +++ b/src/stream.js @@ -1,5 +1,5 @@ import { Deferred } from './util'; -import { renderChunked } from './index'; +import { renderToChunks } from './index'; /** @typedef {ReadableStream & { allReady: Promise}} RenderStream */ @@ -11,30 +11,17 @@ import { renderChunked } from './index'; export function renderToReadableStream(vnode, context) { /** @type {Deferred} */ const allReady = new Deferred(); - /** @type {Deferred} */ - const suspended = new Deferred(); const encoder = new TextEncoder('utf-8'); - renderChunked(vnode, context); - - const ctx = { - suspended: [] - }; - - setTimeout(() => suspended.resolve(), 1000); - /** @type {RenderStream} */ const stream = new ReadableStream({ start(controller) { - controller.enqueue(encoder.encode(shell)); - - if (ctx.suspended.length === 0) { - controller.close(); - allReady.resolve(); - return; - } - - suspended.promise + renderToChunks(vnode, { + context, + onWrite(s) { + controller.enqueue(encoder.encode(s)); + } + }) .then(() => { controller.close(); allReady.resolve(); @@ -43,6 +30,7 @@ export function renderToReadableStream(vnode, context) { controller.error(error); allReady.reject(error); }); + // controller.enqueue(encoder.encode(shell)); } }); diff --git a/test/compat-render-chunked.test.js b/test/compat-render-chunked.test.js index 8c87f07e..93a9c4fe 100644 --- a/test/compat-render-chunked.test.js +++ b/test/compat-render-chunked.test.js @@ -1,18 +1,14 @@ import { h } from 'preact'; import { expect } from 'chai'; import { Suspense } from 'preact/compat'; -import { renderChunked } from '../src/index'; -import { - createCleanupScript, - createSubtree, - ISLAND_SCRIPT -} from '../src/client'; +import { renderToChunks } from '../src/index'; +import { createSubtree, createInitScript } from '../src/client'; import { createSuspender } from './utils'; -describe('renderChunked', () => { +describe('renderToChunks', () => { it('should render non-suspended JSX in one go', async () => { const result = []; - await renderChunked(
bar
, { + await renderToChunks(
bar
, { onWrite: (s) => result.push(s) }); @@ -23,7 +19,7 @@ describe('renderChunked', () => { const { Suspender, suspended } = createSuspender(); const result = []; - const promise = renderChunked( + const promise = renderToChunks(
@@ -36,10 +32,11 @@ describe('renderChunked', () => { await promise; expect(result).to.deep.equal([ - '
loading...
', - ISLAND_SCRIPT, - createSubtree('preact-island-00', '

it works

'), - createCleanupScript() + '
loading...
', + '' ]); }); @@ -48,7 +45,7 @@ describe('renderChunked', () => { const controller = new AbortController(); const result = []; - const promise = renderChunked( + const promise = renderToChunks(
@@ -63,9 +60,10 @@ describe('renderChunked', () => { suspended.resolve(); expect(result).to.deep.equal([ - '
loading...
', - ISLAND_SCRIPT, - createCleanupScript() + '
loading...
', + '' ]); }); }); diff --git a/test/compat-stream-node.test.js b/test/compat-stream-node.test.js new file mode 100644 index 00000000..d6935ac5 --- /dev/null +++ b/test/compat-stream-node.test.js @@ -0,0 +1,35 @@ +/** + * @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('', () => {}); diff --git a/test/compat-stream.test.js b/test/compat-stream.test.js index 376eb211..81530ae7 100644 --- a/test/compat-stream.test.js +++ b/test/compat-stream.test.js @@ -1,3 +1,8 @@ +import { h } from 'preact'; +import { expect } from 'chai'; +import { Deferred } from '../src/util'; +import { renderToReadableStream } from '../src/stream'; + /** * @param {ReadableStream} input */ @@ -31,3 +36,12 @@ function createSink(input) { stream }; } + +describe('renderToReadableStream', () => { + it('should render non-suspended JSX in one go', async () => { + const stream = renderToReadableStream(

hello

); + const state = createSink(stream); + const result = await state.promise; + expect(result).to.deep.equal(['

hello

']); + }); +}); From bee974833d0ccab943d6d1d17bc85cffc97f981e Mon Sep 17 00:00:00 2001 From: Marvin Hagemeister Date: Wed, 19 Oct 2022 08:15:21 +0200 Subject: [PATCH 05/30] Switch away from global ids --- src/client.js | 6 +++--- src/index.js | 2 +- test/compat-render-chunked.test.js | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/client.js b/src/client.js index 08fbd40d..7a15f10c 100644 --- a/src/client.js +++ b/src/client.js @@ -16,8 +16,8 @@ function initPreactIslands(c) { var added = m[i].addedNodes; for (var j = 0; j < added.length; j++) { if (added[j].nodeType !== 1) continue; - var id = added[j].getAttribute('data-id'); - var target = document.getElementById(id); + var id = added[j].getAttribute('data-target'); + var target = document.querySelector('[data-id="' + id + '"]'); if (target) { while (target.firstChild !== null) { target.removeChild(target.firstChild); @@ -55,5 +55,5 @@ export function createInitScript(total) { * @returns {string} */ export function createSubtree(id, content) { - return `
${content}
`; + return `
${content}
`; } diff --git a/src/index.js b/src/index.js index d560136c..e11c4029 100644 --- a/src/index.js +++ b/src/index.js @@ -437,7 +437,7 @@ function _renderToString( renderer ); - return `${fallback}`; + return `${fallback}`; } } } diff --git a/test/compat-render-chunked.test.js b/test/compat-render-chunked.test.js index 93a9c4fe..65f2fe22 100644 --- a/test/compat-render-chunked.test.js +++ b/test/compat-render-chunked.test.js @@ -32,7 +32,7 @@ describe('renderToChunks', () => { await promise; expect(result).to.deep.equal([ - '
loading...
', + '
loading...
', '