From f8ee3a7f275f38c94d50344c495b3069d533fb0e Mon Sep 17 00:00:00 2001 From: Jacob Ebey Date: Wed, 8 Jul 2026 23:01:35 -0700 Subject: [PATCH 1/2] feat: template-for based streaming --- src/lib/chunked.js | 30 ++++++++--------- src/lib/client.js | 81 +++++++++++++++++++++++----------------------- 2 files changed, 55 insertions(+), 56 deletions(-) diff --git a/src/lib/chunked.js b/src/lib/chunked.js index b5ec6c69..00715cae 100644 --- a/src/lib/chunked.js +++ b/src/lib/chunked.js @@ -1,7 +1,7 @@ -import { renderToString } from '../index.js'; -import { CHILD_DID_SUSPEND, COMPONENT, PARENT } from './constants.js'; -import { Deferred } from './util.js'; -import { createInitScript, createSubtree } from './client.js'; +import { renderToString } from "../index.js"; +import { CHILD_DID_SUSPEND, COMPONENT, PARENT } from "./constants.js"; +import { Deferred } from "./util.js"; +import { createInitScript, createSubtree } from "./client.js"; /** * @param {VNode} vnode @@ -17,7 +17,7 @@ export async function renderToChunks(vnode, { context, onWrite, abortSignal }) { abortSignal, onWrite, onError: handleError, - suspended: [] + suspended: [], }; // Synchronously render the shell @@ -32,16 +32,16 @@ export async function renderToChunks(vnode, { context, onWrite, abortSignal }) { // and causes browsers to reject the content. Instead, we inject the deferred // content before the closing tags, then emit them last. const docSuffixIndex = getDocumentClosingTagsIndex(shell); - const hasHtmlTag = shell.trimStart().startsWith('' : ''; + const prefix = hasHtmlTag ? "" : ""; onWrite(prefix + initialWrite); - onWrite(''); if (docSuffixIndex !== -1) onWrite(shell.slice(docSuffixIndex)); } else { onWrite(shell); @@ -55,7 +55,7 @@ export async function renderToChunks(vnode, { context, onWrite, abortSignal }) { * @returns {number} */ function getDocumentClosingTagsIndex(html) { - return html.lastIndexOf(''); + return html.lastIndexOf(""); } async function forkPromises(renderer) { @@ -63,7 +63,7 @@ async function forkPromises(renderer) { const suspensions = [...renderer.suspended]; await Promise.all(renderer.suspended.map((s) => s.promise)); renderer.suspended = renderer.suspended.filter( - (s) => !suspensions.includes(s) + (s) => !suspensions.includes(s), ); await forkPromises(renderer); } @@ -91,7 +91,7 @@ function handleError(error, vnode, renderChild) { if (abortSignal) { // @ts-ignore 2554 - implicit undefined arg if (abortSignal.aborted) race.resolve(); - else abortSignal.addEventListener('abort', race.resolve); + else abortSignal.addEventListener("abort", race.resolve); } const promise = error.then( @@ -102,16 +102,16 @@ function handleError(error, vnode, renderChild) { }, // TODO: Abort and send hydration code snippet to client // to attempt to recover during hydration - this.onError + this.onError, ); this.suspended.push({ id, vnode, - promise: Promise.race([promise, race.promise]) + promise: Promise.race([promise, race.promise]), }); const fallback = renderChild(vnode.props.fallback); - return found ? '' : `${fallback}`; + return found ? "" : `${fallback}`; } diff --git a/src/lib/client.js b/src/lib/client.js index e7fe401d..789fb433 100644 --- a/src/lib/client.js +++ b/src/lib/client.js @@ -1,52 +1,51 @@ /* eslint-disable no-var, key-spacing, object-curly-spacing, prefer-arrow-callback, semi, keyword-spacing */ // 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 == '$s:' + i) s = n; -// else if (n.data == '/$s:' + i) e = n; -// if (s && e) break; -// } -// if (s && e && s.parentNode !== document) { -// requestAnimationFrame(() => { -// var p = e.previousSibling; -// while (p != s) { -// if (!p || p == s) break; -// e.parentNode.removeChild(p); -// p = e.previousSibling; +// let observer = new MutationObserver((mutations) => { +// for (let mutation of mutations) { +// for (let node of mutation.addedNodes) { +// if (node.nodeName === "TEMPLATE") { +// let id = node.getAttribute("for"); +// if (id) { +// var s, +// e, +// c = document.createNodeIterator(document, 128); +// while (c.nextNode()) { +// let n = c.referenceNode; + +// if (n.data == '?start name="' + id + '"') s = n; +// else if (n.data == '?end name="' + id + '"') e = n; +// if (s && e) break; +// } +// if (s && e) { +// requestAnimationFrame(() => { +// let p = e.previousSibling; +// while (p != s) { +// if (!p || p == s) break; +// e.parentNode.removeChild(p); +// p = e.previousSibling; +// } +// // TODO: flush this out to better polyfill the browser behavior, +// // this is pretty basic but works as a x-browser POC +// let n = document.createElement("template"); +// s.replaceWith(n); +// n.insertAdjacentHTML("beforebegin", node.innerHTML); +// n.remove(); +// }); +// } // } - -// c = s; -// while (d.firstChild) { -// s = d.firstChild; -// d.removeChild(s); -// c.after(s); -// c = s; -// } - -// d.parentNode.removeChild(d); -// }); +// } // } // } -// } - -// customElements.define('preact-island', PreactIslandElement); +// }); +// observer.observe(document.body, { childList: true, subtree: true }); +// addEventListener("DOMContentLoaded", () => { +// observer.disconnect(); +// }); // } // To modify the INIT_SCRIPT, uncomment the above code, modify it, and paste it into https://try.terser.org/. -const INIT_SCRIPT = `class e extends HTMLElement{connectedCallback(){var e=this;if(!e.isConnected)return;let t=this.getAttribute("data-target");if(t){for(var r,a,i=document.createNodeIterator(document,128);i.nextNode();){let e=i.referenceNode;if(e.data=="$s:"+t?r=e:e.data=="/$s:"+t&&(a=e),r&&a)break}r&&a&&r.parentNode!==document&&requestAnimationFrame((()=>{for(var t=a.previousSibling;t!=r&&t&&t!=r;)a.parentNode.removeChild(t),t=a.previousSibling;for(i=r;e.firstChild;)r=e.firstChild,e.removeChild(r),i.after(r),i=r;e.parentNode.removeChild(e)}))}}}customElements.define("preact-island",e);`; +const INIT_SCRIPT = `let e=new MutationObserver(e=>{for(let n of e)for(let e of n.addedNodes)if("TEMPLATE"===e.nodeName){let n=e.getAttribute("for");if(n){for(var t,r,o=document.createNodeIterator(document,128);o.nextNode();){let e=o.referenceNode;if(e.data=='?start name="'+n+'"'?t=e:e.data=='?end name="'+n+'"'&&(r=e),t&&r)break}t&&r&&requestAnimationFrame(()=>{let o=r.previousSibling;for(;o!=t&&o&&o!=t;)r.parentNode.removeChild(o),o=r.previousSibling;let n=document.createElement("template");t.replaceWith(n),n.insertAdjacentHTML("beforebegin",e.innerHTML),n.remove()})}}});e.observe(document.body,{childList:!0,subtree:!0}),addEventListener("DOMContentLoaded",()=>{e.disconnect()});`; export function createInitScript() { return ``; @@ -58,5 +57,5 @@ export function createInitScript() { * @returns {string} */ export function createSubtree(id, content) { - return ``; + return ``; } From d0ec46413bf65d9c32231ba3902731a8c6256d74 Mon Sep 17 00:00:00 2001 From: Jacob Ebey Date: Thu, 9 Jul 2026 00:27:21 -0700 Subject: [PATCH 2/2] fix: preserve suspense stream markers --- src/lib/chunked.js | 6 ++-- src/lib/client.js | 47 ++++++++++++++++++++--------- test/compat/render-chunked.test.jsx | 28 +++++------------ test/compat/stream-node.test.jsx | 6 ++-- test/compat/stream.test.jsx | 6 ++-- 5 files changed, 47 insertions(+), 46 deletions(-) diff --git a/src/lib/chunked.js b/src/lib/chunked.js index 00715cae..cbdffb3f 100644 --- a/src/lib/chunked.js +++ b/src/lib/chunked.js @@ -37,11 +37,9 @@ export async function renderToChunks(vnode, { context, onWrite, abortSignal }) { docSuffixIndex !== -1 ? shell.slice(0, docSuffixIndex) : shell; const prefix = hasHtmlTag ? "" : ""; onWrite(prefix + initialWrite); - // onWrite(''); if (docSuffixIndex !== -1) onWrite(shell.slice(docSuffixIndex)); } else { onWrite(shell); @@ -113,5 +111,7 @@ function handleError(error, vnode, renderChild) { const fallback = renderChild(vnode.props.fallback); - return found ? "" : `${fallback}`; + return found + ? "" + : `${fallback}`; } diff --git a/src/lib/client.js b/src/lib/client.js index 789fb433..0a354e10 100644 --- a/src/lib/client.js +++ b/src/lib/client.js @@ -1,24 +1,40 @@ /* eslint-disable no-var, key-spacing, object-curly-spacing, prefer-arrow-callback, semi, keyword-spacing */ // function initPreactIslandElement() { +// let template = document.createElement("template"); +// template.innerHTML = ''; +// if (template.content.firstChild && template.content.firstChild.nodeType == 7) { +// return; +// } + +// function findDirectives(id) { +// var s, +// e, +// c = document.createNodeIterator(document, 128); +// while (c.nextNode()) { +// let n = c.referenceNode; + +// if (n.data == '?start name="' + id + '"') s = n; +// else if (n.data == '?end name="' + id + '"') e = n; +// if (s && e) return [s, e]; +// } +// } + // let observer = new MutationObserver((mutations) => { // for (let mutation of mutations) { // for (let node of mutation.addedNodes) { // if (node.nodeName === "TEMPLATE") { // let id = node.getAttribute("for"); // if (id) { -// var s, -// e, -// c = document.createNodeIterator(document, 128); -// while (c.nextNode()) { -// let n = c.referenceNode; - -// if (n.data == '?start name="' + id + '"') s = n; -// else if (n.data == '?end name="' + id + '"') e = n; -// if (s && e) break; -// } -// if (s && e) { +// let pair = findDirectives(id); +// if (pair) { // requestAnimationFrame(() => { +// pair = findDirectives(node.getAttribute("for")); +// if (!pair) { +// node.remove(); +// return; +// } +// let [s, e] = pair; // let p = e.previousSibling; // while (p != s) { // if (!p || p == s) break; @@ -31,7 +47,11 @@ // s.replaceWith(n); // n.insertAdjacentHTML("beforebegin", node.innerHTML); // n.remove(); +// e.remove(); +// node.remove(); // }); +// } else { +// node.remove(); // } // } // } @@ -39,13 +59,10 @@ // } // }); // observer.observe(document.body, { childList: true, subtree: true }); -// addEventListener("DOMContentLoaded", () => { -// observer.disconnect(); -// }); // } // To modify the INIT_SCRIPT, uncomment the above code, modify it, and paste it into https://try.terser.org/. -const INIT_SCRIPT = `let e=new MutationObserver(e=>{for(let n of e)for(let e of n.addedNodes)if("TEMPLATE"===e.nodeName){let n=e.getAttribute("for");if(n){for(var t,r,o=document.createNodeIterator(document,128);o.nextNode();){let e=o.referenceNode;if(e.data=='?start name="'+n+'"'?t=e:e.data=='?end name="'+n+'"'&&(r=e),t&&r)break}t&&r&&requestAnimationFrame(()=>{let o=r.previousSibling;for(;o!=t&&o&&o!=t;)r.parentNode.removeChild(o),o=r.previousSibling;let n=document.createElement("template");t.replaceWith(n),n.insertAdjacentHTML("beforebegin",e.innerHTML),n.remove()})}}});e.observe(document.body,{childList:!0,subtree:!0}),addEventListener("DOMContentLoaded",()=>{e.disconnect()});`; +const INIT_SCRIPT = `let t=document.createElement("template");if(t.innerHTML='',t.content.firstChild&&7==t.content.firstChild.nodeType)return;let e=new MutationObserver(e=>{let t=e=>{for(var o,r,n=document.createNodeIterator(document,128);n.nextNode();){let l=n.referenceNode;if(l.data=='?start name="'+e+'"'?o=l:l.data=='?end name="'+e+'"'&&(r=l),o&&r)return[o,r]}};for(let o of e)for(let e of o.addedNodes)if("TEMPLATE"===e.nodeName){let o=e.getAttribute("for");if(o){let r=t(o);r?requestAnimationFrame(()=>{let o=t(e.getAttribute("for"));if(!o)return void e.remove();let[r,n]=o,l=n.previousSibling;for(;l!=r&&l&&l!=r;)n.parentNode.removeChild(l),l=n.previousSibling;let d=document.createElement("template");r.replaceWith(d),d.insertAdjacentHTML("beforebegin",e.innerHTML),d.remove(),n.remove(),e.remove()}):e.remove()}}});e.observe(document.body,{childList:!0,subtree:!0});`; export function createInitScript() { return ``; diff --git a/test/compat/render-chunked.test.jsx b/test/compat/render-chunked.test.jsx index f0699fca..3f218b22 100644 --- a/test/compat/render-chunked.test.jsx +++ b/test/compat/render-chunked.test.jsx @@ -34,11 +34,9 @@ describe('renderToChunks', () => { await promise; expect(result).to.deep.equal([ - '
loading...
', - '' ]); }); @@ -62,10 +60,8 @@ describe('renderToChunks', () => { suspended.resolve(); expect(result).to.deep.equal([ - '
loading...
', - '' ]); }); @@ -109,11 +105,9 @@ describe('renderToChunks', () => { } expect(result).to.deep.equal([ - '
loading...
', - '' ]); }); @@ -142,11 +136,9 @@ describe('renderToChunks', () => { await promise; expect(result).to.deep.equal([ - '

id: P0-0

loading...
', - '' ]); }); @@ -182,12 +174,10 @@ describe('renderToChunks', () => { await promise; expect(result).toEqual([ - '

id: P0-0

loading...loading...
', - '' ]); }); @@ -213,8 +203,8 @@ describe('renderToChunks', () => { const fullHtml = result.join(''); - // Deferred wrapper must appear before , not after - const deferredPos = fullHtml.indexOf('