Skip to content

Commit 76aff2d

Browse files
committed
Implement streamed hydration rfc
1 parent 4885f1d commit 76aff2d

2 files changed

Lines changed: 162 additions & 22 deletions

File tree

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: 48 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -83,8 +83,33 @@ export function diff(
8383
(isHydrating = oldVNode._flags & MODE_HYDRATE) &&
8484
oldVNode._component._excess
8585
) {
86-
excessDomChildren = oldVNode._component._excess;
87-
oldDom = excessDomChildren[0];
86+
let startMarker = oldVNode._component._excess[0];
87+
if (
88+
startMarker &&
89+
startMarker.nodeType == 8 &&
90+
startMarker.data.startsWith('$s')
91+
) {
92+
// Deferred restoration: re-scan current DOM from the stored start marker.
93+
// This ensures we always hydrate against the most up-to-date DOM state,
94+
// even if a streaming SSR patcher replaced the content between markers.
95+
excessDomChildren = [];
96+
let depth = 1;
97+
let node = startMarker.nextSibling;
98+
while (node && depth > 0) {
99+
if (node.nodeType == 8) {
100+
if (node.data.startsWith('$s')) depth++;
101+
else if (node.data.startsWith('/$s')) {
102+
if (--depth == 0) break;
103+
}
104+
}
105+
excessDomChildren.push(node);
106+
node = node.nextSibling;
107+
}
108+
oldDom = excessDomChildren[0];
109+
} else {
110+
excessDomChildren = oldVNode._component._excess;
111+
oldDom = excessDomChildren[0];
112+
}
88113
oldVNode._component._excess = NULL;
89114
}
90115

@@ -311,46 +336,47 @@ export function diff(
311336
if (isHydrating || excessDomChildren != NULL) {
312337
if (e.then) {
313338
let commentMarkersToFind = 0,
314-
done;
339+
done,
340+
startMarker;
315341

316342
newVNode._flags |= isHydrating
317343
? MODE_HYDRATE | MODE_SUSPENDED
318344
: MODE_SUSPENDED;
319345

320-
newVNode._component._excess = [];
321346
for (let i = 0; i < excessDomChildren.length; i++) {
322347
let child = excessDomChildren[i];
323348
if (child == NULL || done) continue;
324349

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.
350+
// When we encounter a $s boundary marker we are opening a
351+
// suspended region. Track nesting depth to find the matching
352+
// close marker. We null out ALL nodes in the region so the
353+
// parent diff doesn't try to remove them; the children will be
354+
// re-scanned from the stored start marker on resume.
332355
if (child.nodeType == 8) {
333-
if (child.data == '$s') {
334-
if (commentMarkersToFind) {
335-
newVNode._component._excess.push(child);
356+
if (child.data.startsWith('$s')) {
357+
if (!commentMarkersToFind) {
358+
// Store outermost start marker for deferred restoration
359+
startMarker = child;
336360
}
337361
commentMarkersToFind++;
338-
} else if (child.data == '/$s') {
339-
commentMarkersToFind--;
340-
if (commentMarkersToFind) {
341-
newVNode._component._excess.push(child);
362+
} else if (child.data.startsWith('/$s')) {
363+
if (--commentMarkersToFind == 0) {
364+
done = true;
365+
oldDom = excessDomChildren[i];
342366
}
343-
done = commentMarkersToFind == 0;
344-
oldDom = excessDomChildren[i];
345367
}
346368
excessDomChildren[i] = NULL;
347369
} else if (commentMarkersToFind) {
348-
newVNode._component._excess.push(child);
349370
excessDomChildren[i] = NULL;
350371
}
351372
}
352373

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

0 commit comments

Comments
 (0)