Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 114 additions & 0 deletions compat/test/browser/suspense-hydration.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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),
'<!--$s:0-->',
li(2),
li(3),
'<!--/$s:0-->',
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(
<List>
<Fragment>
<ListItem onClick={listeners[0]}>0</ListItem>
<ListItem onClick={listeners[1]}>1</ListItem>
</Fragment>
<Suspense>
<Lazy />
</Suspense>
<Fragment>
<ListItem onClick={listeners[4]}>4</ListItem>
<ListItem onClick={listeners[5]}>5</ListItem>
</Fragment>
</List>,
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(() => (
<Fragment>
<ListItem onClick={listeners[2]}>2</ListItem>
<ListItem onClick={listeners[3]}>3</ListItem>
</Fragment>
)).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 =
'<!--$s:0--><span>Loading</span><!--/$s:0--><div>after</div>';
clearLog();

const [Lazy, resolve] = createLazy();
hydrate(
<>
<Suspense>
<Lazy />
</Suspense>
<div>after</div>
</>,
scratch
);
rerender();
expect(scratch.innerHTML).to.equal(
'<!--$s:0--><span>Loading</span><!--/$s:0--><div>after</div>'
);
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 <span>.
const endMarker = scratch.childNodes[2]; // <!--/$s:0-->
scratch.removeChild(scratch.childNodes[1]); // remove <span>Loading</span>
const resolved = document.createElement('div');
resolved.textContent = 'Resolved';
scratch.insertBefore(resolved, endMarker);

expect(scratch.innerHTML).to.equal(
'<!--$s:0--><div>Resolved</div><!--/$s:0--><div>after</div>'
);
// Clear the stream patcher's own DOM ops before asserting on rerender
clearLog();

return resolve(() => <div>Resolved</div>).then(() => {
rerender();
// Should match the stream-patched <div>Resolved</div>, no extra DOM ops
expect(scratch.innerHTML).to.equal(
'<!--$s:0--><div>Resolved</div><!--/$s:0--><div>after</div>'
);
expect(getLog()).to.deep.equal([]);
clearLog();
});
});

it('Should not crash when oldVNode._children is null during shouldComponentUpdate optimization', () => {
const originalHtml = '<div>Hello</div>';
scratch.innerHTML = originalHtml;
Expand Down
58 changes: 33 additions & 25 deletions src/diff/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion src/internal.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ export interface Component<P = {}, S = {}>
constructor: ComponentType<P>;
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;
Expand Down
Loading