Skip to content

Commit bb52524

Browse files
authored
Implement streamed hydration rfc (#5035)
* Implement streamed hydration rfc * Golfing
1 parent 03253d5 commit bb52524

3 files changed

Lines changed: 149 additions & 27 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: 34 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,24 @@ export function diff(
8383
(isHydrating = oldVNode._flags & MODE_HYDRATE) &&
8484
oldVNode._component._excess
8585
) {
86-
excessDomChildren = oldVNode._component._excess;
86+
let excess = oldVNode._component._excess;
87+
excessDomChildren = [];
88+
if (excess.nodeType == 8) {
89+
// Re-scan DOM from stored start marker for streamed hydration
90+
for (
91+
let depth = 1, node = excess.nextSibling;
92+
node && depth > 0;
93+
node = node.nextSibling
94+
) {
95+
if (node.nodeType == 8) {
96+
if (node.data.startsWith('$s')) depth++;
97+
else if (node.data.startsWith('/$s') && !--depth) break;
98+
}
99+
excessDomChildren.push(node);
100+
}
101+
} else {
102+
excessDomChildren.push(excess);
103+
}
87104
oldDom = excessDomChildren[0];
88105
oldVNode._component._excess = NULL;
89106
}
@@ -310,52 +327,43 @@ export function diff(
310327
if (isHydrating || excessDomChildren != NULL) {
311328
if (e.then) {
312329
let commentMarkersToFind = 0,
313-
done;
330+
startMarker;
314331

315332
newVNode._flags |= isHydrating
316333
? MODE_HYDRATE | MODE_SUSPENDED
317334
: MODE_SUSPENDED;
318335

319-
newVNode._component._excess = [];
320336
for (let i = 0; i < excessDomChildren.length; i++) {
321-
const child = excessDomChildren[i];
322-
if (child == NULL || done) continue;
323-
324-
// When we encounter a boundary with $s we are opening
325-
// a boundary, this implies that we need to bump
326-
// the amount of markers we need to find before closing
327-
// the outer boundary.
328-
// We exclude the open and closing marker from
329-
// the future excessDomChildren but any nested one
330-
// needs to be included for future suspensions.
337+
let child = excessDomChildren[i];
338+
if (child == NULL) continue;
339+
331340
if (child.nodeType == 8) {
332-
if (child.data == '$s') {
333-
if (commentMarkersToFind) {
334-
newVNode._component._excess.push(child);
335-
}
341+
if (child.data.startsWith('$s')) {
342+
if (!commentMarkersToFind) startMarker = child;
336343
commentMarkersToFind++;
337-
} else if (child.data == '/$s') {
338-
commentMarkersToFind--;
339-
if (commentMarkersToFind) {
340-
newVNode._component._excess.push(child);
344+
} else if (child.data.startsWith('/$s')) {
345+
if (--commentMarkersToFind == 0) {
346+
oldDom = child;
347+
excessDomChildren[i] = NULL;
348+
break;
341349
}
342-
done = commentMarkersToFind == 0;
343-
oldDom = excessDomChildren[i];
344350
}
345351
excessDomChildren[i] = NULL;
346352
} else if (commentMarkersToFind) {
347-
newVNode._component._excess.push(child);
348353
excessDomChildren[i] = NULL;
349354
}
350355
}
351356

352-
if (!done) {
357+
if (startMarker) {
358+
// Store start marker directly; children re-scanned on resume
359+
newVNode._component._excess = startMarker;
360+
} else {
353361
while (oldDom && oldDom.nodeType == 8 && oldDom.nextSibling) {
354362
oldDom = oldDom.nextSibling;
355363
}
356364

357365
excessDomChildren[excessDomChildren.indexOf(oldDom)] = NULL;
358-
newVNode._component._excess = [oldDom];
366+
newVNode._component._excess = oldDom;
359367
}
360368

361369
newVNode._dom = oldDom;

src/internal.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,7 @@ export interface Component<P = {}, S = {}>
165165
constructor: ComponentType<P>;
166166
state: S; // Override Component["state"] to not be readonly for internal use, specifically Hooks
167167

168-
_excess?: PreactElement[];
168+
_excess?: PreactElement;
169169
_renderCallbacks: Array<() => void>; // Only class components
170170
_stateCallbacks: Array<() => void>; // Only class components
171171
_globalContext?: any;

0 commit comments

Comments
 (0)