From 76aff2d330b201f6ad5fdb2475329cd0f0b3c7b3 Mon Sep 17 00:00:00 2001 From: Jovi De Croock Date: Mon, 23 Feb 2026 20:24:41 +0100 Subject: [PATCH 1/2] Implement streamed hydration rfc --- .../test/browser/suspense-hydration.test.jsx | 114 ++++++++++++++++++ src/diff/index.js | 70 +++++++---- 2 files changed, 162 insertions(+), 22 deletions(-) diff --git a/compat/test/browser/suspense-hydration.test.jsx b/compat/test/browser/suspense-hydration.test.jsx index 6a02112bf0..d4bc77b391 100644 --- a/compat/test/browser/suspense-hydration.test.jsx +++ b/compat/test/browser/suspense-hydration.test.jsx @@ -1018,6 +1018,120 @@ describe('suspense hydration', () => { }); }); + it('should properly hydrate suspense when resolves to a Fragment with $s:id markers', () => { + const originalHtml = ul([ + li(0), + li(1), + '', + li(2), + li(3), + '', + li(4), + li(5) + ]); + + const listeners = [vi.fn(), vi.fn(), vi.fn(), vi.fn(), vi.fn(), vi.fn()]; + + scratch.innerHTML = originalHtml; + clearLog(); + + const [Lazy, resolve] = createLazy(); + hydrate( + + + 0 + 1 + + + + + + 4 + 5 + + , + scratch + ); + rerender(); // Flush rerender queue to mimic what preact will really do + expect(getLog()).to.deep.equal([]); + expect(scratch.innerHTML).to.equal(originalHtml); + expect(listeners[5]).not.toHaveBeenCalled(); + + clearLog(); + scratch.querySelector('li:last-child').dispatchEvent(createEvent('click')); + expect(listeners[5]).toHaveBeenCalledOnce(); + + return resolve(() => ( + + 2 + 3 + + )).then(() => { + rerender(); + expect(scratch.innerHTML).to.equal(originalHtml); + expect(getLog()).to.deep.equal([]); + clearLog(); + + scratch + .querySelector('li:nth-child(4)') + .dispatchEvent(createEvent('click')); + expect(listeners[3]).toHaveBeenCalledOnce(); + + scratch + .querySelector('li:last-child') + .dispatchEvent(createEvent('click')); + expect(listeners[5]).toHaveBeenCalledTimes(2); + }); + }); + + it('should use updated DOM when stream patcher replaces content before suspend resolves', () => { + scratch.innerHTML = + 'Loading
after
'; + clearLog(); + + const [Lazy, resolve] = createLazy(); + hydrate( + <> + + + +
after
+ , + scratch + ); + rerender(); + expect(scratch.innerHTML).to.equal( + 'Loading
after
' + ); + expect(getLog()).to.deep.equal([]); + clearLog(); + + // Simulate stream patcher: replace fallback content while anchor comments + // remain. The deferred restoration should use the current DOM, not stale + // references to the removed . + const endMarker = scratch.childNodes[2]; // + scratch.removeChild(scratch.childNodes[1]); // remove Loading + const resolved = document.createElement('div'); + resolved.textContent = 'Resolved'; + scratch.insertBefore(resolved, endMarker); + + expect(scratch.innerHTML).to.equal( + '
Resolved
after
' + ); + // Clear the stream patcher's own DOM ops before asserting on rerender + clearLog(); + + return resolve(() =>
Resolved
).then(() => { + rerender(); + // Should match the stream-patched
Resolved
, no extra DOM ops + expect(scratch.innerHTML).to.equal( + '
Resolved
after
' + ); + expect(getLog()).to.deep.equal([]); + clearLog(); + }); + }); + it('Should not crash when oldVNode._children is null during shouldComponentUpdate optimization', () => { const originalHtml = '
Hello
'; scratch.innerHTML = originalHtml; diff --git a/src/diff/index.js b/src/diff/index.js index 46c5d8baca..bd93cab21f 100644 --- a/src/diff/index.js +++ b/src/diff/index.js @@ -83,8 +83,33 @@ export function diff( (isHydrating = oldVNode._flags & MODE_HYDRATE) && oldVNode._component._excess ) { - excessDomChildren = oldVNode._component._excess; - oldDom = excessDomChildren[0]; + let startMarker = oldVNode._component._excess[0]; + if ( + startMarker && + startMarker.nodeType == 8 && + startMarker.data.startsWith('$s') + ) { + // Deferred restoration: re-scan current DOM from the stored start marker. + // This ensures we always hydrate against the most up-to-date DOM state, + // even if a streaming SSR patcher replaced the content between markers. + excessDomChildren = []; + let depth = 1; + let node = startMarker.nextSibling; + while (node && depth > 0) { + if (node.nodeType == 8) { + if (node.data.startsWith('$s')) depth++; + else if (node.data.startsWith('/$s')) { + if (--depth == 0) break; + } + } + excessDomChildren.push(node); + node = node.nextSibling; + } + oldDom = excessDomChildren[0]; + } else { + excessDomChildren = oldVNode._component._excess; + oldDom = excessDomChildren[0]; + } oldVNode._component._excess = NULL; } @@ -311,46 +336,47 @@ export function diff( if (isHydrating || excessDomChildren != NULL) { if (e.then) { let commentMarkersToFind = 0, - done; + done, + startMarker; newVNode._flags |= isHydrating ? MODE_HYDRATE | MODE_SUSPENDED : MODE_SUSPENDED; - newVNode._component._excess = []; for (let i = 0; i < excessDomChildren.length; i++) { let child = excessDomChildren[i]; if (child == NULL || done) continue; - // When we encounter a boundary with $s we are opening - // a boundary, this implies that we need to bump - // the amount of markers we need to find before closing - // the outer boundary. - // We exclude the open and closing marker from - // the future excessDomChildren but any nested one - // needs to be included for future suspensions. + // When we encounter a $s boundary marker we are opening a + // suspended region. Track nesting depth to find the matching + // close marker. We null out ALL nodes in the region so the + // parent diff doesn't try to remove them; the children will be + // re-scanned from the stored start marker on resume. if (child.nodeType == 8) { - if (child.data == '$s') { - if (commentMarkersToFind) { - newVNode._component._excess.push(child); + if (child.data.startsWith('$s')) { + if (!commentMarkersToFind) { + // Store outermost start marker for deferred restoration + startMarker = child; } commentMarkersToFind++; - } else if (child.data == '/$s') { - commentMarkersToFind--; - if (commentMarkersToFind) { - newVNode._component._excess.push(child); + } else if (child.data.startsWith('/$s')) { + if (--commentMarkersToFind == 0) { + done = true; + oldDom = excessDomChildren[i]; } - done = commentMarkersToFind == 0; - oldDom = excessDomChildren[i]; } excessDomChildren[i] = NULL; } else if (commentMarkersToFind) { - newVNode._component._excess.push(child); excessDomChildren[i] = NULL; } } - if (!done) { + if (done) { + // Store only the start marker; children are re-scanned on resume + // so we always hydrate against the current DOM state. + // TODO: consider just storing in _dom and getting rid of _excess altogether? + newVNode._component._excess = [startMarker]; + } else { while (oldDom && oldDom.nodeType == 8 && oldDom.nextSibling) { oldDom = oldDom.nextSibling; } From 41e813e2d4f190d334a8ddda2724c08d1d34708b Mon Sep 17 00:00:00 2001 From: Jovi De Croock Date: Tue, 24 Feb 2026 07:38:15 +0100 Subject: [PATCH 2/2] Golfing --- src/diff/index.js | 60 +++++++++++++++++------------------------------ src/internal.d.ts | 2 +- 2 files changed, 22 insertions(+), 40 deletions(-) diff --git a/src/diff/index.js b/src/diff/index.js index bd93cab21f..d5275697d3 100644 --- a/src/diff/index.js +++ b/src/diff/index.js @@ -83,33 +83,25 @@ export function diff( (isHydrating = oldVNode._flags & MODE_HYDRATE) && oldVNode._component._excess ) { - let startMarker = oldVNode._component._excess[0]; - if ( - startMarker && - startMarker.nodeType == 8 && - startMarker.data.startsWith('$s') - ) { - // Deferred restoration: re-scan current DOM from the stored start marker. - // This ensures we always hydrate against the most up-to-date DOM state, - // even if a streaming SSR patcher replaced the content between markers. - excessDomChildren = []; - let depth = 1; - let node = startMarker.nextSibling; - while (node && depth > 0) { + let excess = oldVNode._component._excess; + excessDomChildren = []; + if (excess.nodeType == 8) { + // Re-scan DOM from stored start marker for streamed hydration + for ( + let depth = 1, node = excess.nextSibling; + node && depth > 0; + node = node.nextSibling + ) { if (node.nodeType == 8) { if (node.data.startsWith('$s')) depth++; - else if (node.data.startsWith('/$s')) { - if (--depth == 0) break; - } + else if (node.data.startsWith('/$s') && !--depth) break; } excessDomChildren.push(node); - node = node.nextSibling; } - oldDom = excessDomChildren[0]; } else { - excessDomChildren = oldVNode._component._excess; - oldDom = excessDomChildren[0]; + excessDomChildren.push(excess); } + oldDom = excessDomChildren[0]; oldVNode._component._excess = NULL; } @@ -336,7 +328,6 @@ export function diff( if (isHydrating || excessDomChildren != NULL) { if (e.then) { let commentMarkersToFind = 0, - done, startMarker; newVNode._flags |= isHydrating @@ -345,24 +336,17 @@ export function diff( for (let i = 0; i < excessDomChildren.length; i++) { let child = excessDomChildren[i]; - if (child == NULL || done) continue; + if (child == NULL) continue; - // When we encounter a $s boundary marker we are opening a - // suspended region. Track nesting depth to find the matching - // close marker. We null out ALL nodes in the region so the - // parent diff doesn't try to remove them; the children will be - // re-scanned from the stored start marker on resume. if (child.nodeType == 8) { if (child.data.startsWith('$s')) { - if (!commentMarkersToFind) { - // Store outermost start marker for deferred restoration - startMarker = child; - } + if (!commentMarkersToFind) startMarker = child; commentMarkersToFind++; } else if (child.data.startsWith('/$s')) { if (--commentMarkersToFind == 0) { - done = true; - oldDom = excessDomChildren[i]; + oldDom = child; + excessDomChildren[i] = NULL; + break; } } excessDomChildren[i] = NULL; @@ -371,18 +355,16 @@ export function diff( } } - if (done) { - // Store only the start marker; children are re-scanned on resume - // so we always hydrate against the current DOM state. - // TODO: consider just storing in _dom and getting rid of _excess altogether? - newVNode._component._excess = [startMarker]; + if (startMarker) { + // Store start marker directly; children re-scanned on resume + newVNode._component._excess = startMarker; } else { while (oldDom && oldDom.nodeType == 8 && oldDom.nextSibling) { oldDom = oldDom.nextSibling; } excessDomChildren[excessDomChildren.indexOf(oldDom)] = NULL; - newVNode._component._excess = [oldDom]; + newVNode._component._excess = oldDom; } newVNode._dom = oldDom; diff --git a/src/internal.d.ts b/src/internal.d.ts index 06021f43b8..fb206bbd23 100644 --- a/src/internal.d.ts +++ b/src/internal.d.ts @@ -163,7 +163,7 @@ export interface Component

constructor: ComponentType

; state: S; // Override Component["state"] to not be readonly for internal use, specifically Hooks - _excess?: PreactElement[]; + _excess?: PreactElement; _renderCallbacks: Array<() => void>; // Only class components _stateCallbacks: Array<() => void>; // Only class components _globalContext?: any;