diff --git a/src/client.js b/src/client.js
index 4806b3fb..b704ef84 100644
--- a/src/client.js
+++ b/src/client.js
@@ -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);
}
}
- });
- 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 +51,5 @@ export function createInitScript(total) {
* @returns {string}
*/
export function createSubtree(id, content) {
- return `
${content}
`;
+ return `${content}`;
}
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...
',
'',
- createInitScript(1),
- createSubtree('preact-00', '
it works
'),
+ createInitScript(),
+ createSubtree('00', '
it works
'),
'
'
]);
});
@@ -60,7 +60,7 @@ describe('renderToChunks', () => {
suspended.resolve();
expect(result).to.deep.equal([
- '',
+ 'loading...
',
'',
createInitScript(1),
'
'
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...
',
+ '',
+ createInitScript(),
+ createSubtree('00', '
it works
'),
+ '
'
+ ]);
+ });
+});
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...
',
+ '',
+ createInitScript(),
+ createSubtree('00', '
it works
'),
+ '
'
+ ]);
});
});