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 all commits
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
74 changes: 35 additions & 39 deletions src/client.js
Original file line number Diff line number Diff line change
@@ -1,52 +1,48 @@
/* 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);
}
target.hydrate = true;
}
if (--c === 0) {
o.disconnect();
el.parentNode.removeChild(el);
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);
}
Comment on lines +12 to 32
Copy link
Member

Choose a reason for hiding this comment

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

Doing a deep walk here seems expensive. What about using a hidden element to denote the start of any island, followed by a comment sibling to denote its end?

  <link id="preact-island-00">
  <h1>hello world</h1>
  this is some text
  <!--preact-island-00-->
Suggested change
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);
}
var n = 'preact-island-' + id,
s = document.getElementById(n),
p = s.parentNode,
e = s,
b;
while (e) {
b = e.nextSibling;
p.removeChild(e);
if (e.nodeType === 8 && e.data === n) break;
e = b;
}
while (b = d.firstChild) p.insertBefore(b, e);

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Breaks semantic HTML for lists and whatnot. Same reason to not use a CE for denoting the fallback.

Copy link
Member

Choose a reason for hiding this comment

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

Yeah I think there is no way around using comments as we always risk breaking HTML or CSS selectors (:nth-child, etc)

Copy link
Member

Choose a reason for hiding this comment

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

Does this matter much if the elements are removed immediately?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes because when the fallback is displayed (potentially a long while) styles / semantics will be broken.

}
});
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 +51,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