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(
+ 'Loadingafter
'
+ );
+ 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..d5275697d3 100644
--- a/src/diff/index.js
+++ b/src/diff/index.js
@@ -83,7 +83,24 @@ export function diff(
(isHydrating = oldVNode._flags & MODE_HYDRATE) &&
oldVNode._component._excess
) {
- excessDomChildren = oldVNode._component._excess;
+ 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') && !--depth) break;
+ }
+ excessDomChildren.push(node);
+ }
+ } else {
+ excessDomChildren.push(excess);
+ }
oldDom = excessDomChildren[0];
oldVNode._component._excess = NULL;
}
@@ -311,52 +328,43 @@ export function diff(
if (isHydrating || excessDomChildren != NULL) {
if (e.then) {
let commentMarkersToFind = 0,
- 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.
+ if (child == NULL) continue;
+
if (child.nodeType == 8) {
- if (child.data == '$s') {
- if (commentMarkersToFind) {
- newVNode._component._excess.push(child);
- }
+ if (child.data.startsWith('$s')) {
+ if (!commentMarkersToFind) 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) {
+ oldDom = child;
+ excessDomChildren[i] = NULL;
+ break;
}
- done = commentMarkersToFind == 0;
- oldDom = excessDomChildren[i];
}
excessDomChildren[i] = NULL;
} else if (commentMarkersToFind) {
- newVNode._component._excess.push(child);
excessDomChildren[i] = NULL;
}
}
- if (!done) {
+ 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;