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( + 'Loading
after
' + ); + 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;