Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: use comments instead of element as marker #260

Merged
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 43 additions & 37 deletions src/client.js
Original file line number Diff line number Diff line change
@@ -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) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a better way to get HTML comments than this?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we are worried about perf we can switch to TreeWalker, but NodeIterator has better browser support if I remember right:
image

Copy link
Contributor Author

@jacob-ebey jacob-ebey Nov 7, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

More benchmarks if we want to switch:
Chome:

Name         Operations per second    Average time, ms
walker       1.0 x 10^5               0.01                ==============================>
iterator     5.0 x 10^4               0.02                ===============>
recursion    7.7 x 10^3               0.13                ==>

Safari:

Name         Operations per second    Average time, ms (main.ts, line 70)
walker       9.1 x 10^4               0.01                ==============================>
iterator     6.3 x 10^4               0.02                =====================>
recursion    3.6 x 10^3               0.28                =>

Walker with a filter is also significantly faster than without and that assumption holds true for iterator as well:

Name                Operations per second    Average time, ms
walker filter       9.1 x 10^4               0.01                ==============================>
walker no filter    1.0 x 10^4               0.10                ===>

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here's the source for the benchmark:

import Benchmark from "micro-benchmark";

function nest(_parent: HTMLElement, content: string, n: number) {
  let _el = document.createElement("div");
  _el.innerText = content;
  _parent.appendChild(_el);

  if (--n) nest(_el, content, n);
  else {
    _el.appendChild(document.createComment("FIND_ME"));
  }
}

nest(document.body, "👻", 2000);

function findCommentRecursive(node: ChildNode, text: string): Node | null {
  if (node.nodeType === Node.COMMENT_NODE && node.nodeValue === text) {
    return node;
  }

  let child = node.firstChild;
  while (child) {
    const found = findCommentRecursive(child, text);
    if (found) return found;
    child = child.nextSibling;
  }

  return null;
}

function findCommentNodeIterator(node: ChildNode, text: string): Node | null {
  const iterator = document.createNodeIterator(node, NodeFilter.SHOW_COMMENT);
  let current = iterator.nextNode();
  while (current) {
    if (current.nodeValue === text) return current;
    current = iterator.nextNode();
  }
  return null;
}

function findCommentTreeWalker(node: ChildNode, text: string): Node | null {
  const walker = document.createTreeWalker(node, NodeFilter.SHOW_COMMENT);
  let current = walker.nextNode();
  while (current) {
    if (current.nodeValue === text) return current;
    current = walker.nextNode();
  }
  return null;
}

let result = Benchmark.suite({
  specs: [
    {
      name: "recursion",
      fn: () => {
        findCommentRecursive(document.body, "FIND_ME");
      },
    },
    {
      name: "iterator",
      fn: () => {
        findCommentNodeIterator(document.body, "FIND_ME");
      },
    },
    {
      name: "walker",
      fn: () => {
        findCommentTreeWalker(document.body, "FIND_ME");
      },
    },
  ],
});

let report = Benchmark.report(result);
console.log(report);

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 `<script>(function(){var c=${total};${INIT_SCRIPT}}())</script>`;
export function createInitScript() {
return `<script>(function(){${INIT_SCRIPT}}())</script>`;
}

/**
Expand All @@ -55,5 +61,5 @@ export function createInitScript(total) {
* @returns {string}
*/
export function createSubtree(id, content) {
return `<div data-target="${id}">${content}</div>`;
return `<preact-island hidden data-target="${id}">${content}</preact-island>`;
}
1 change: 1 addition & 0 deletions src/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
47 changes: 28 additions & 19 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>}
*/
export async function renderToChunks(vnode, { context, onWrite, abortSignal }) {
Expand Down Expand Up @@ -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;

Expand All @@ -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
])
});
Expand All @@ -437,12 +446,12 @@ function _renderToString(
renderer
);

return `<preact-island data-id="${id}">${fallback}</preact-island>`;
return `<!--preact-island:${id}-->${fallback}<!--/preact-island:${id}-->`;
}
}
}

console.log('WOA', error, renderer);
// console.log('WOA', error, renderer);
let errorHook = options[CATCH_ERROR];
if (errorHook) errorHook(error, vnode);
return '';
Expand Down
61 changes: 61 additions & 0 deletions src/stream-node.js
Original file line number Diff line number Diff line change
@@ -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 });
}
};
}
4 changes: 4 additions & 0 deletions src/stream.js
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
Expand Down
8 changes: 4 additions & 4 deletions test/compat-render-chunked.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,10 @@ describe('renderToChunks', () => {
await promise;

expect(result).to.deep.equal([
'<div><preact-island data-id="preact-00">loading...</preact-island></div>',
'<div><!--preact-island:00-->loading...<!--/preact-island:00--></div>',
'<div hidden>',
createInitScript(1),
createSubtree('preact-00', '<p>it works</p>'),
createInitScript(),
createSubtree('00', '<p>it works</p>'),
'</div>'
]);
});
Expand All @@ -60,7 +60,7 @@ describe('renderToChunks', () => {
suspended.resolve();

expect(result).to.deep.equal([
'<div><preact-island data-id="preact-00">loading...</preact-island></div>',
'<div><!--preact-island:00-->loading...<!--/preact-island:00--></div>',
'<div hidden>',
createInitScript(1),
'</div>'
Expand Down
91 changes: 66 additions & 25 deletions test/compat-stream-node.test.js
Original file line number Diff line number Diff line change
@@ -1,35 +1,76 @@
/**
* @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,
stream
};
}

describe('', () => {});
describe('renderToPipeableStream', () => {
it('should render non-suspended JSX in one go', async () => {
const sink = createSink();
const { pipe } = renderToPipeableStream(<div class="foo">bar</div>, {
onAllReady: () => {
pipe(sink.stream);
}
});
const result = await sink.promise;

expect(result).to.deep.equal(['<div class="foo">bar</div>']);
});

it('should render fallback + attach loaded subtree on suspend', async () => {
const { Suspender, suspended } = createSuspender();

const sink = createSink();
const { pipe } = renderToPipeableStream(
<div>
<Suspense fallback="loading...">
<Suspender />
</Suspense>
</div>,
{
onShellReady: () => {
pipe(sink.stream);
}
}
);
suspended.resolve();

const result = await sink.promise;

expect(result).to.deep.equal([
'<div><!--preact-island:00-->loading...<!--/preact-island:00--></div>',
'<div hidden>',
createInitScript(),
createSubtree('00', '<p>it works</p>'),
'</div>'
]);
});
});
Loading