From a84fca440c7c55d6433172b65dd497c6d25beb1c Mon Sep 17 00:00:00 2001 From: Jacob Ebey Date: Mon, 31 Oct 2022 14:51:14 -0700 Subject: [PATCH 1/5] feat: use comments instead of element as marker feat: use custom element for hydration feat: add onError to renderToChunks feat: add renderToPipeableStream --- src/client.js | 80 ++++++++++++++------------ src/index.d.ts | 1 + src/index.js | 47 ++++++++------- src/stream-node.js | 61 ++++++++++++++++++++ src/stream.js | 4 ++ test/compat-render-chunked.test.js | 8 +-- test/compat-stream-node.test.js | 91 ++++++++++++++++++++++-------- test/compat-stream.test.js | 39 +++++++++++-- 8 files changed, 241 insertions(+), 90 deletions(-) diff --git a/src/client.js b/src/client.js index 4806b3fb..54e3bf3f 100644 --- a/src/client.js +++ b/src/client.js @@ -1,52 +1,58 @@ /* eslint-disable no-var, key-spacing, object-curly-spacing, prefer-arrow-callback, semi, keyword-spacing */ -/** - * @param {number} c Total number of hydration islands - */ -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); - } - 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-target'); - var target = document.querySelector('[data-id="' + id + '"]'); - if (target) { - while (target.firstChild !== null) { - target.removeChild(target.firstChild); - } - while (added[j].firstChild !== null) { - target.appendChild(added[j].firstChild); +function initPreactIslandElement() { + class PreactIslandElement extends HTMLElement { + connectedCallback() { + if (!this.isConnected) return; + + let i = this.getAttribute('data-target'); + if (!i) return; + + var d = this; + function f(el) { + var a = []; + for (var j = 0; j < el.childNodes.length; j++) { + var n = el.childNodes[j]; + if (n.nodeType === 8) { + a.push(n); + } else { + a.push(...f(n)); } - target.hydrate = true; } - if (--c === 0) { - o.disconnect(); - el.parentNode.removeChild(el); + return a; + } + var s, e; + for (var n of f(document)) { + 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; + for (; p != s; ) { + if (!p || p == s) break; + + e.parentNode.removeChild(p); + p = e.previousSibling; + } + for (; d.firstChild; ) { + e.parentNode.insertBefore(d.firstChild, e); } + d.parentNode.removeChild(d); } } - }); - o.observe(el, { childList: true }); + } + + customElements.define('preact-island', PreactIslandElement); } -const fn = initPreactIslands.toString(); +const fn = initPreactIslandElement.toString(); const INIT_SCRIPT = fn .slice(fn.indexOf('{') + 1, fn.lastIndexOf('}')) .replace(/\n\s+/gm, ''); -/** - * @param {number} total - */ -export function createInitScript(total) { - return ``; +export function createInitScript() { + return ``; } /** @@ -55,5 +61,5 @@ export function createInitScript(total) { * @returns {string} */ export function createSubtree(id, content) { - return `
${content}
`; + return ``; } diff --git a/src/index.d.ts b/src/index.d.ts index 6af243c5..9e458109 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -15,6 +15,7 @@ export function renderToString( export function shallowRender(vnode: VNode, context?: any): string; export interface ChunkedOptions { + onError(error: unknown): void; onWrite(chunk: string): void; context?: any; abortSignal?: AbortSignal; diff --git a/src/index.js b/src/index.js index e11c4029..b09df1d6 100644 --- a/src/index.js +++ b/src/index.js @@ -106,7 +106,7 @@ function renderToString(vnode, context, opts) { /** * @param {VNode} vnode - * @param {{ context?: any, onWrite: (str: string) => void, abortSignal?: AbortSignal }} options + * @param {{ context?: any, onError: (error: unknown) => void, onWrite: (str: string) => void, abortSignal?: AbortSignal }} options * @returns {Promise} */ export async function renderToChunks(vnode, { context, onWrite, abortSignal }) { @@ -388,7 +388,7 @@ function _renderToString( (component = susVNode[COMPONENT]) && component[CHILD_DID_SUSPEND] ) { - const id = 'preact-' + susVNode[MASK] + renderer.suspended.length; + const id = susVNode[MASK] + renderer.suspended.length; const abortSignal = renderer.abortSignal; @@ -408,22 +408,31 @@ function _renderToString( selectValue, vnode: susVNode, promise: Promise.race([ - error.then(() => { - if (abortSignal && abortSignal.aborted) { - return; + error.then( + () => { + if (abortSignal && abortSignal.aborted) { + return; + } + + const str = _renderToString( + susVNode.props.children, + context, + isSvgMode, + selectValue, + susVNode, + renderer + ); + + renderer.onWrite(createSubtree(id, str)); + }, + (error) => { + // TODO: Abort and send hydration code snippet to client + // to attempt to recover during hydration + if (renderer.onError) { + renderer.onError(error); + } } - - const str = _renderToString( - susVNode.props.children, - context, - isSvgMode, - selectValue, - susVNode, - renderer - ); - - renderer.onWrite(createSubtree(id, str)); - }), + ), race.promise ]) }); @@ -437,12 +446,12 @@ function _renderToString( renderer ); - return `${fallback}`; + return `${fallback}`; } } } - console.log('WOA', error, renderer); + // console.log('WOA', error, renderer); let errorHook = options[CATCH_ERROR]; if (errorHook) errorHook(error, vnode); return ''; diff --git a/src/stream-node.js b/src/stream-node.js index e69de29b..160abd07 100644 --- a/src/stream-node.js +++ b/src/stream-node.js @@ -0,0 +1,61 @@ +import { PassThrough } from 'node:stream'; + +import { renderToChunks } from './index'; + +/** + * @typedef {object} RenderToPipeableStreamOptions + * @property {() => void} [onShellReady] + * @property {() => void} [onAllReady] + * @property {() => void} [onError] + */ + +/** + * @param {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 index 0c36cebb..9e3ad7ab 100644 --- a/src/stream.js +++ b/src/stream.js @@ -18,6 +18,10 @@ export function renderToReadableStream(vnode, context) { start(controller) { renderToChunks(vnode, { context, + onError: (error) => { + allReady.reject(error); + controller.abort(error); + }, onWrite(s) { controller.enqueue(encoder.encode(s)); } diff --git a/test/compat-render-chunked.test.js b/test/compat-render-chunked.test.js index 65f2fe22..83559966 100644 --- a/test/compat-render-chunked.test.js +++ b/test/compat-render-chunked.test.js @@ -32,10 +32,10 @@ describe('renderToChunks', () => { await promise; expect(result).to.deep.equal([ - '
loading...
', + '
loading...
', '' ]); }); @@ -60,7 +60,7 @@ describe('renderToChunks', () => { suspended.resolve(); expect(result).to.deep.equal([ - '
loading...
', + '
loading...
', '' diff --git a/test/compat-stream-node.test.js b/test/compat-stream-node.test.js index d6935ac5..18174b60 100644 --- a/test/compat-stream-node.test.js +++ b/test/compat-stream-node.test.js @@ -1,30 +1,30 @@ -/** - * @param {ReadableStream} input - */ -function createSink(input) { - const decoder = new TextDecoder('utf-8'); - const queuingStrategy = new CountQueuingStrategy({ highWaterMark: 1 }); +import { PassThrough } from 'node:stream'; +import { h } from 'preact'; +import { expect } from 'chai'; +import { Suspense } from 'preact/compat'; +import { createSubtree, createInitScript } from '../src/client'; +import { renderToPipeableStream } from '../src/stream-node'; +import { Deferred } from '../src/util'; +import { createSuspender } from './utils'; +function streamToString(stream) { + const decoder = new TextDecoder(); 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 - ); + 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; +} - input.pipeTo(stream); +/** + * @param {ReadableStream} input + */ +function createSink() { + const stream = new PassThrough(); + const def = streamToString(stream); return { promise: def.promise, @@ -32,4 +32,45 @@ function createSink(input) { }; } -describe('', () => {}); +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 index 81530ae7..42a96ed5 100644 --- a/test/compat-stream.test.js +++ b/test/compat-stream.test.js @@ -1,7 +1,10 @@ import { h } from 'preact'; import { expect } from 'chai'; -import { Deferred } from '../src/util'; +import { Suspense } from 'preact/compat'; +import { createSubtree, createInitScript } from '../src/client'; import { renderToReadableStream } from '../src/stream'; +import { Deferred } from '../src/util'; +import { createSuspender } from './utils'; /** * @param {ReadableStream} input @@ -39,9 +42,35 @@ function createSink(input) { 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

']); + 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...
', + '' + ]); }); }); From d61cda8a8d52bad90cced7112774ac8b52ee1e3b Mon Sep 17 00:00:00 2001 From: Jacob Ebey Date: Mon, 31 Oct 2022 18:05:35 -0700 Subject: [PATCH 2/5] chore: use NodeIterator to locate comments This reduces code and *should* also be more performant than recursive JS iteration. See: https://developer.mozilla.org/en-US/docs/Web/API/NodeIterator --- src/client.js | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/src/client.js b/src/client.js index 54e3bf3f..f46509d4 100644 --- a/src/client.js +++ b/src/client.js @@ -10,16 +10,10 @@ function initPreactIslandElement() { var d = this; function f(el) { - var a = []; - for (var j = 0; j < el.childNodes.length; j++) { - var n = el.childNodes[j]; - if (n.nodeType === 8) { - a.push(n); - } else { - a.push(...f(n)); - } - } - return a; + let r = []; + let c = document.createNodeIterator(el, NodeFilter.SHOW_COMMENT); + while (c.nextNode()) r.push(c.referenceNode); + return r; } var s, e; for (var n of f(document)) { From bca7c5308acd6b3d8b2c46e5a272dd9db4f2a0f9 Mon Sep 17 00:00:00 2001 From: Jacob Ebey Date: Mon, 31 Oct 2022 18:14:56 -0700 Subject: [PATCH 3/5] chore: remove redundancy and minify code --- src/client.js | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/src/client.js b/src/client.js index f46509d4..02cf89a2 100644 --- a/src/client.js +++ b/src/client.js @@ -8,28 +8,25 @@ function initPreactIslandElement() { let i = this.getAttribute('data-target'); if (!i) return; - var d = this; - function f(el) { - let r = []; - let c = document.createNodeIterator(el, NodeFilter.SHOW_COMMENT); - while (c.nextNode()) r.push(c.referenceNode); - return r; - } - var s, e; - for (var n of f(document)) { + var d = this, + s, + e, + c = document.createNodeIterator(document, NodeFilter.SHOW_COMMENT); + 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; - for (; p != s; ) { + while (p != s) { if (!p || p == s) break; e.parentNode.removeChild(p); p = e.previousSibling; } - for (; d.firstChild; ) { + while (d.firstChild) { e.parentNode.insertBefore(d.firstChild, e); } d.parentNode.removeChild(d); From 31f1839ed995c978aa6c8ca5755550c2c38ddd49 Mon Sep 17 00:00:00 2001 From: Jacob Ebey Date: Mon, 31 Oct 2022 18:25:30 -0700 Subject: [PATCH 4/5] more minification --- src/client.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/client.js b/src/client.js index 02cf89a2..339d7229 100644 --- a/src/client.js +++ b/src/client.js @@ -3,13 +3,13 @@ function initPreactIslandElement() { class PreactIslandElement extends HTMLElement { connectedCallback() { - if (!this.isConnected) return; + var d = this; + if (!d.isConnected) return; let i = this.getAttribute('data-target'); if (!i) return; - var d = this, - s, + var s, e, c = document.createNodeIterator(document, NodeFilter.SHOW_COMMENT); while (c.nextNode()) { From 3ad1d627ab96df661f53c5e0fe60a24e77279612 Mon Sep 17 00:00:00 2001 From: Jacob Ebey Date: Mon, 31 Oct 2022 18:27:12 -0700 Subject: [PATCH 5/5] even more minification --- src/client.js | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/client.js b/src/client.js index 339d7229..b704ef84 100644 --- a/src/client.js +++ b/src/client.js @@ -11,7 +11,7 @@ function initPreactIslandElement() { var s, e, - c = document.createNodeIterator(document, NodeFilter.SHOW_COMMENT); + c = document.createNodeIterator(document, 128); while (c.nextNode()) { let n = c.referenceNode; if (n.data == 'preact-island:' + i) s = n; @@ -26,9 +26,8 @@ function initPreactIslandElement() { e.parentNode.removeChild(p); p = e.previousSibling; } - while (d.firstChild) { - e.parentNode.insertBefore(d.firstChild, e); - } + while (d.firstChild) e.parentNode.insertBefore(d.firstChild, e); + d.parentNode.removeChild(d); } }