Skip to content

Commit 0dfd8a9

Browse files
committed
Implement streamed hydration rfc
1 parent 4885f1d commit 0dfd8a9

File tree

2 files changed

+163
-24
lines changed

2 files changed

+163
-24
lines changed

compat/test/browser/suspense-hydration.test.jsx

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1018,6 +1018,120 @@ describe('suspense hydration', () => {
10181018
});
10191019
});
10201020

1021+
it('should properly hydrate suspense when resolves to a Fragment with $s:id markers', () => {
1022+
const originalHtml = ul([
1023+
li(0),
1024+
li(1),
1025+
'<!--$s:0-->',
1026+
li(2),
1027+
li(3),
1028+
'<!--/$s:0-->',
1029+
li(4),
1030+
li(5)
1031+
]);
1032+
1033+
const listeners = [vi.fn(), vi.fn(), vi.fn(), vi.fn(), vi.fn(), vi.fn()];
1034+
1035+
scratch.innerHTML = originalHtml;
1036+
clearLog();
1037+
1038+
const [Lazy, resolve] = createLazy();
1039+
hydrate(
1040+
<List>
1041+
<Fragment>
1042+
<ListItem onClick={listeners[0]}>0</ListItem>
1043+
<ListItem onClick={listeners[1]}>1</ListItem>
1044+
</Fragment>
1045+
<Suspense>
1046+
<Lazy />
1047+
</Suspense>
1048+
<Fragment>
1049+
<ListItem onClick={listeners[4]}>4</ListItem>
1050+
<ListItem onClick={listeners[5]}>5</ListItem>
1051+
</Fragment>
1052+
</List>,
1053+
scratch
1054+
);
1055+
rerender(); // Flush rerender queue to mimic what preact will really do
1056+
expect(getLog()).to.deep.equal([]);
1057+
expect(scratch.innerHTML).to.equal(originalHtml);
1058+
expect(listeners[5]).not.toHaveBeenCalled();
1059+
1060+
clearLog();
1061+
scratch.querySelector('li:last-child').dispatchEvent(createEvent('click'));
1062+
expect(listeners[5]).toHaveBeenCalledOnce();
1063+
1064+
return resolve(() => (
1065+
<Fragment>
1066+
<ListItem onClick={listeners[2]}>2</ListItem>
1067+
<ListItem onClick={listeners[3]}>3</ListItem>
1068+
</Fragment>
1069+
)).then(() => {
1070+
rerender();
1071+
expect(scratch.innerHTML).to.equal(originalHtml);
1072+
expect(getLog()).to.deep.equal([]);
1073+
clearLog();
1074+
1075+
scratch
1076+
.querySelector('li:nth-child(4)')
1077+
.dispatchEvent(createEvent('click'));
1078+
expect(listeners[3]).toHaveBeenCalledOnce();
1079+
1080+
scratch
1081+
.querySelector('li:last-child')
1082+
.dispatchEvent(createEvent('click'));
1083+
expect(listeners[5]).toHaveBeenCalledTimes(2);
1084+
});
1085+
});
1086+
1087+
it('should use updated DOM when stream patcher replaces content before suspend resolves', () => {
1088+
scratch.innerHTML =
1089+
'<!--$s:0--><span>Loading</span><!--/$s:0--><div>after</div>';
1090+
clearLog();
1091+
1092+
const [Lazy, resolve] = createLazy();
1093+
hydrate(
1094+
<>
1095+
<Suspense>
1096+
<Lazy />
1097+
</Suspense>
1098+
<div>after</div>
1099+
</>,
1100+
scratch
1101+
);
1102+
rerender();
1103+
expect(scratch.innerHTML).to.equal(
1104+
'<!--$s:0--><span>Loading</span><!--/$s:0--><div>after</div>'
1105+
);
1106+
expect(getLog()).to.deep.equal([]);
1107+
clearLog();
1108+
1109+
// Simulate stream patcher: replace fallback content while anchor comments
1110+
// remain. The deferred restoration should use the current DOM, not stale
1111+
// references to the removed <span>.
1112+
const endMarker = scratch.childNodes[2]; // <!--/$s:0-->
1113+
scratch.removeChild(scratch.childNodes[1]); // remove <span>Loading</span>
1114+
const resolved = document.createElement('div');
1115+
resolved.textContent = 'Resolved';
1116+
scratch.insertBefore(resolved, endMarker);
1117+
1118+
expect(scratch.innerHTML).to.equal(
1119+
'<!--$s:0--><div>Resolved</div><!--/$s:0--><div>after</div>'
1120+
);
1121+
// Clear the stream patcher's own DOM ops before asserting on rerender
1122+
clearLog();
1123+
1124+
return resolve(() => <div>Resolved</div>).then(() => {
1125+
rerender();
1126+
// Should match the stream-patched <div>Resolved</div>, no extra DOM ops
1127+
expect(scratch.innerHTML).to.equal(
1128+
'<!--$s:0--><div>Resolved</div><!--/$s:0--><div>after</div>'
1129+
);
1130+
expect(getLog()).to.deep.equal([]);
1131+
clearLog();
1132+
});
1133+
});
1134+
10211135
it('Should not crash when oldVNode._children is null during shouldComponentUpdate optimization', () => {
10221136
const originalHtml = '<div>Hello</div>';
10231137
scratch.innerHTML = originalHtml;

src/diff/index.js

Lines changed: 49 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -80,11 +80,35 @@ export function diff(
8080
if (
8181
oldVNode._flags & MODE_SUSPENDED &&
8282
// @ts-expect-error This is 1 or 0 (true or false)
83-
(isHydrating = oldVNode._flags & MODE_HYDRATE) &&
84-
oldVNode._component._excess
83+
(isHydrating = oldVNode._flags & MODE_HYDRATE)
8584
) {
86-
excessDomChildren = oldVNode._component._excess;
87-
oldDom = excessDomChildren[0];
85+
let startMarker = oldVNode._component._excess[0];
86+
if (
87+
startMarker &&
88+
startMarker.nodeType == 8 &&
89+
startMarker.data.startsWith('$s')
90+
) {
91+
// Deferred restoration: re-scan current DOM from the stored start marker.
92+
// This ensures we always hydrate against the most up-to-date DOM state,
93+
// even if a streaming SSR patcher replaced the content between markers.
94+
excessDomChildren = [];
95+
let depth = 1;
96+
let node = startMarker.nextSibling;
97+
while (node && depth > 0) {
98+
if (node.nodeType == 8) {
99+
if (node.data.startsWith('$s')) depth++;
100+
else if (node.data.startsWith('/$s')) {
101+
if (--depth == 0) break;
102+
}
103+
}
104+
excessDomChildren.push(node);
105+
node = node.nextSibling;
106+
}
107+
oldDom = excessDomChildren[0];
108+
} else {
109+
excessDomChildren = oldVNode._component._excess;
110+
oldDom = excessDomChildren[0];
111+
}
88112
oldVNode._component._excess = NULL;
89113
}
90114

@@ -311,46 +335,47 @@ export function diff(
311335
if (isHydrating || excessDomChildren != NULL) {
312336
if (e.then) {
313337
let commentMarkersToFind = 0,
314-
done;
338+
done,
339+
startMarker;
315340

316341
newVNode._flags |= isHydrating
317342
? MODE_HYDRATE | MODE_SUSPENDED
318343
: MODE_SUSPENDED;
319344

320-
newVNode._component._excess = [];
321345
for (let i = 0; i < excessDomChildren.length; i++) {
322346
let child = excessDomChildren[i];
323347
if (child == NULL || done) continue;
324348

325-
// When we encounter a boundary with $s we are opening
326-
// a boundary, this implies that we need to bump
327-
// the amount of markers we need to find before closing
328-
// the outer boundary.
329-
// We exclude the open and closing marker from
330-
// the future excessDomChildren but any nested one
331-
// needs to be included for future suspensions.
349+
// When we encounter a $s boundary marker we are opening a
350+
// suspended region. Track nesting depth to find the matching
351+
// close marker. We null out ALL nodes in the region so the
352+
// parent diff doesn't try to remove them; the children will be
353+
// re-scanned from the stored start marker on resume.
332354
if (child.nodeType == 8) {
333-
if (child.data == '$s') {
334-
if (commentMarkersToFind) {
335-
newVNode._component._excess.push(child);
355+
if (child.data.startsWith('$s')) {
356+
if (!commentMarkersToFind) {
357+
// Store outermost start marker for deferred restoration
358+
startMarker = child;
336359
}
337360
commentMarkersToFind++;
338-
} else if (child.data == '/$s') {
339-
commentMarkersToFind--;
340-
if (commentMarkersToFind) {
341-
newVNode._component._excess.push(child);
361+
} else if (child.data.startsWith('/$s')) {
362+
if (--commentMarkersToFind == 0) {
363+
done = true;
364+
oldDom = excessDomChildren[i];
342365
}
343-
done = commentMarkersToFind == 0;
344-
oldDom = excessDomChildren[i];
345366
}
346367
excessDomChildren[i] = NULL;
347368
} else if (commentMarkersToFind) {
348-
newVNode._component._excess.push(child);
349369
excessDomChildren[i] = NULL;
350370
}
351371
}
352372

353-
if (!done) {
373+
if (done) {
374+
// Store only the start marker; children are re-scanned on resume
375+
// so we always hydrate against the current DOM state.
376+
// TODO: consider just storing in _dom and getting rid of _excess altogether?
377+
newVNode._component._excess = [startMarker];
378+
} else {
354379
while (oldDom && oldDom.nodeType == 8 && oldDom.nextSibling) {
355380
oldDom = oldDom.nextSibling;
356381
}

0 commit comments

Comments
 (0)