From 118295e436a3fe4f0f4f73ad85c9d0f5cbd99be9 Mon Sep 17 00:00:00 2001 From: Jovi De Croock Date: Fri, 3 Jul 2026 09:23:08 +0200 Subject: [PATCH 1/2] Fix hydration-suspense crash due to sCU bailout Assisted-By: devx/f79ddc2d-9f3b-44d1-85dc-4cb2c75c5ad5 --- compat/src/internal.d.ts | 1 + compat/src/suspense.js | 4 +- .../test/browser/suspense-hydration.test.jsx | 155 ++++++++++++++++++ src/diff/index.js | 19 +-- 4 files changed, 167 insertions(+), 12 deletions(-) diff --git a/compat/src/internal.d.ts b/compat/src/internal.d.ts index 8cbbdc93ba..6a49bf24c7 100644 --- a/compat/src/internal.d.ts +++ b/compat/src/internal.d.ts @@ -45,4 +45,5 @@ export interface SuspenseComponent _pendingSuspensionCount: number; _suspenders: Component[]; _detachOnNextRender: null | VNode; + _mask?: [number, number]; } diff --git a/compat/src/suspense.js b/compat/src/suspense.js index c9385d2478..27f04564ad 100644 --- a/compat/src/suspense.js +++ b/compat/src/suspense.js @@ -192,12 +192,12 @@ Suspense.prototype.componentWillUnmount = function () { */ Suspense.prototype.render = function (props, state) { let vnode = this._vnode; - if (!vnode._mask) { + if (!vnode._mask && !(vnode._mask = this._mask)) { let root = vnode; while (root._parent) root = root._parent; root = root._mask || (root._mask = [0, 0]); - vnode._mask = [root[1]++, 0]; + vnode._mask = this._mask = [root[1]++, 0]; } if (this._detachOnNextRender) { diff --git a/compat/test/browser/suspense-hydration.test.jsx b/compat/test/browser/suspense-hydration.test.jsx index 32f1b85a9b..156ebf6514 100644 --- a/compat/test/browser/suspense-hydration.test.jsx +++ b/compat/test/browser/suspense-hydration.test.jsx @@ -267,6 +267,60 @@ describe('suspense hydration', () => { expect(ids[1]).to.equal('P1-0'); }); + it('keeps Suspense useId masks stable across updates while hydration is suspended', async () => { + let shouldSuspend = false; + let resolvePromise; + let suspendPromise; + let update; + let renderedIds = []; + + function Field() { + const id = useId(); + renderedIds.push(id); + return
Pay
; + } + + function MaybeSuspend() { + if (shouldSuspend) { + throw suspendPromise; + } + return ; + } + + function App() { + const [tick, setTick] = useState(0); + update = () => setTick(tick + 1); + return ( + + + + ); + } + + const html = renderToString(); + const serverId = /id="([^"]+)"/.exec(html)[1]; + scratch.innerHTML = html; + + renderedIds = []; + shouldSuspend = true; + suspendPromise = new Promise(resolve => { + resolvePromise = resolve; + }); + hydrate(, scratch); + rerender(); + + update(); + rerender(); + + shouldSuspend = false; + resolvePromise(); + await Promise.resolve(); + await new Promise(resolve => setTimeout(resolve)); + rerender(); + + expect(renderedIds).to.deep.equal([serverId]); + }); + it('should leave DOM untouched when suspending while hydrating', () => { scratch.innerHTML = '
Hello
'; clearLog(); @@ -291,6 +345,107 @@ describe('suspense hydration', () => { }); }); + it('Should not crash when oldVNode._children is null during shouldComponentUpdate optimization', () => { + const originalHtml = '
Hello
'; + scratch.innerHTML = originalHtml; + clearLog(); + + class ErrorBoundary extends React.Component { + constructor(props) { + super(props); + this.state = { hasError: false }; + } + + static getDerivedStateFromError() { + return { hasError: true }; + } + + render() { + return this.props.children; + } + } + + const [Lazy, resolve] = createLazy(); + function App() { + return ( + + + + + + ); + } + + hydrate(, scratch); + rerender(); // Flush rerender queue to mimic what preact will really do + expect(scratch.innerHTML).to.equal(originalHtml); + expect(getLog()).to.deep.equal([]); + clearLog(); + + let i = 0; + class ThrowOrRender extends React.Component { + shouldComponentUpdate() { + return i === 0; + } + render() { + if (i === 0) { + i++; + throw new Error('Test error'); + } + return
Hello
; + } + } + + return resolve(ThrowOrRender).then(() => { + rerender(); + expect(scratch.innerHTML).to.equal(originalHtml); + clearLog(); + }); + }); + + it('does not crash when a hydrated suspended component bails out with shouldComponentUpdate', () => { + scratch.innerHTML = '
ssr
'; + clearLog(); + + const promise = new Promise(() => {}); + let update; + + class Suspender extends React.Component { + shouldComponentUpdate() { + return false; + } + + render() { + throw promise; + } + } + + class App extends React.Component { + constructor(props) { + super(props); + this.state = { tick: 0 }; + update = () => this.setState({ tick: this.state.tick + 1 }); + } + + render() { + return ( + loading}> + + + ); + } + } + + hydrate(, scratch); + expect(scratch.innerHTML).to.equal('
ssr
'); + + update(); + expect(() => rerender()).not.to.throw(); + expect(scratch.innerHTML).to.equal('
ssr
'); + expect(getLog()).to.deep.equal([]); + clearLog(); + }); + it('should leave DOM untouched when suspending while hydrating', () => { scratch.innerHTML = '
Hello
'; clearLog(); diff --git a/src/diff/index.js b/src/diff/index.js index be7a4781b5..9ef5b04596 100644 --- a/src/diff/index.js +++ b/src/diff/index.js @@ -375,21 +375,20 @@ export function diff( newVNode._component._excess = oldDom; } newVNode._dom = oldDom; - } else { - if (excessDomChildren != NULL) { - for (let i = excessDomChildren.length; i--; ) { - removeNode(excessDomChildren[i]); - } + } else if (excessDomChildren != NULL) { + for (let i = excessDomChildren.length; i--; ) { + removeNode(excessDomChildren[i]); } - markAsForce(newVNode); } } else { newVNode._dom = oldVNode._dom; - if (!newVNode._children && oldVNode._children) { - newVNode._children = oldVNode._children; - } - if (!e.then) markAsForce(newVNode); } + + if (newVNode._children == NULL) { + newVNode._children = oldVNode._children || []; + } + + if (!e.then) markAsForce(newVNode); options._catchError(e, newVNode, oldVNode); } } else { From 7353da253cc94a274e5656421b82ff59a925f35d Mon Sep 17 00:00:00 2001 From: jdecroock Date: Fri, 3 Jul 2026 11:47:52 +0200 Subject: [PATCH 2/2] Revert "Fix useId stability across async Suspense (#5108)" This reverts commit 67f02906e857bdaf5872374019a700efc417e318. --- compat/src/internal.d.ts | 1 - compat/src/suspense.js | 15 +- .../test/browser/suspense-hydration.test.jsx | 247 ------------------ 3 files changed, 3 insertions(+), 260 deletions(-) diff --git a/compat/src/internal.d.ts b/compat/src/internal.d.ts index 6a49bf24c7..d3a3af5f6d 100644 --- a/compat/src/internal.d.ts +++ b/compat/src/internal.d.ts @@ -33,7 +33,6 @@ export interface FunctionComponent

extends PreactFunctionComponent

{ export interface VNode extends PreactVNode { $$typeof?: symbol; preactCompatNormalized?: boolean; - _mask?: [number, number]; } export interface SuspenseState { diff --git a/compat/src/suspense.js b/compat/src/suspense.js index 27f04564ad..8ee35a14e0 100644 --- a/compat/src/suspense.js +++ b/compat/src/suspense.js @@ -191,23 +191,14 @@ Suspense.prototype.componentWillUnmount = function () { * @param {import('./internal').SuspenseState} state */ Suspense.prototype.render = function (props, state) { - let vnode = this._vnode; - if (!vnode._mask && !(vnode._mask = this._mask)) { - let root = vnode; - while (root._parent) root = root._parent; - - root = root._mask || (root._mask = [0, 0]); - vnode._mask = this._mask = [root[1]++, 0]; - } - if (this._detachOnNextRender) { // When the Suspense's _vnode was created by a call to createVNode // (i.e. due to a setState further up in the tree) // it's _children prop is null, in this case we "forget" about the parked vnodes to detach - if (vnode._children) { + if (this._vnode._children) { const detachedParent = document.createElement('div'); - const detachedComponent = vnode._children[0]._component; - vnode._children[0] = detachedClone( + const detachedComponent = this._vnode._children[0]._component; + this._vnode._children[0] = detachedClone( this._detachOnNextRender, detachedParent, (detachedComponent._originalParentDom = detachedComponent._parentDom) diff --git a/compat/test/browser/suspense-hydration.test.jsx b/compat/test/browser/suspense-hydration.test.jsx index 156ebf6514..84bcb0a510 100644 --- a/compat/test/browser/suspense-hydration.test.jsx +++ b/compat/test/browser/suspense-hydration.test.jsx @@ -4,9 +4,7 @@ import React, { hydrate, Fragment, Suspense, - lazy, memo, - useId, useState } from 'preact/compat'; import { logCall, getLog, clearLog } from '../../../test/_util/logCall'; @@ -17,7 +15,6 @@ import { } from '../../../test/_util/helpers'; import { ul, li, div } from '../../../test/_util/dom'; import { createLazy, createSuspenseLoader } from './suspense-utils'; -import { renderToString, renderToStringAsync } from 'preact-render-to-string'; import { vi } from 'vitest'; /* eslint-env browser */ @@ -77,250 +74,6 @@ describe('suspense hydration', () => { } }); - it('is stable for async Suspense siblings resolving in different orders', async () => { - const getIds = html => - Object.fromEntries( - [...html.matchAll(/([AB])<\/span>/g)].map( - ([, id, name]) => [name, id] - ) - ); - - async function renderWithResolveOrder(order) { - const loaders = {}; - - function Field({ name }) { - const id = useId(); - return {name}; - } - - const createLazy = name => - lazy( - () => - new Promise(resolve => { - loaders[name] = () => - resolve({ default: () => }); - }) - ); - - const A = createLazy('A'); - const B = createLazy('B'); - const rendered = renderToStringAsync( -

- ); - - await Promise.resolve(); - order.some(name => loaders[name]()); - - return getIds(await rendered); - } - - const ordered = await renderWithResolveOrder(['A', 'B']); - const reversed = await renderWithResolveOrder(['B', 'A']); - - expect(new Set(Object.values(ordered)).size).to.equal(2); - expect(new Set(Object.values(reversed)).size).to.equal(2); - expect(reversed).to.deep.equal(ordered); - }); - - it('is stable for nested async Suspense siblings resolving in different orders', async () => { - const getIds = html => - Object.fromEntries( - [...html.matchAll(/([AB])<\/span>/g)].map( - ([, id, name]) => [name, id] - ) - ); - - async function renderWithResolveOrder(order) { - const loaders = {}; - - function Field({ name }) { - const id = useId(); - return {name}; - } - - const createLazy = name => - lazy( - () => - new Promise(resolve => { - loaders[name] = () => - resolve({ default: () => }); - }) - ); - - const A = createLazy('A'); - const B = createLazy('B'); - const rendered = renderToStringAsync( - - - - - - - - - ); - - await Promise.resolve(); - order.some(name => loaders[name]()); - - return getIds(await rendered); - } - - const ordered = await renderWithResolveOrder(['A', 'B']); - const reversed = await renderWithResolveOrder(['B', 'A']); - - expect(ordered).to.deep.equal({ A: 'P1-0', B: 'P2-0' }); - expect(reversed).to.deep.equal(ordered); - }); - - it('does not leak Suspense useId masks across abandoned renderToString attempts', () => { - const idsIn = html => [...html.matchAll(/P\d+-\d+/g)].map(([id]) => id); - - function Field() { - return {useId()}; - } - - function Suspends() { - throw Promise.resolve(); - } - - const tree = () => ( - <> - - - - - - - - ); - - const first = idsIn(renderToString(tree())); - expect(first).to.deep.equal(['P0-0', 'P1-0']); - - expect(() => - renderToString( - - - - ) - ).to.throw(/renderToStringAsync/); - - expect(idsIn(renderToString(tree()))).to.deep.equal(first); - }); - - it('keeps deeply nested Suspense useId masks compact', async () => { - function Field() { - const id = useId(); - return field; - } - - const Wrapper = ({ children }) => children; - let child = ( - - - - ); - - for (let i = 0; i < 10; i++) { - child = {child}; - } - - const html = await renderToStringAsync( - {child} - ); - - expect(html).to.equal('field'); - }); - - it('keeps nested Suspense ids distinct from parent useId calls', async () => { - const ids = []; - - function Field() { - ids.push(useId()); - return field; - } - - function Wrapper() { - ids.push(useId()); - return ( - - - - ); - } - - await renderToStringAsync( - - - - ); - - expect(ids[0]).to.equal('P0-0'); - expect(ids[1]).to.equal('P1-0'); - }); - - it('keeps Suspense useId masks stable across updates while hydration is suspended', async () => { - let shouldSuspend = false; - let resolvePromise; - let suspendPromise; - let update; - let renderedIds = []; - - function Field() { - const id = useId(); - renderedIds.push(id); - return
Pay
; - } - - function MaybeSuspend() { - if (shouldSuspend) { - throw suspendPromise; - } - return ; - } - - function App() { - const [tick, setTick] = useState(0); - update = () => setTick(tick + 1); - return ( - - - - ); - } - - const html = renderToString(); - const serverId = /id="([^"]+)"/.exec(html)[1]; - scratch.innerHTML = html; - - renderedIds = []; - shouldSuspend = true; - suspendPromise = new Promise(resolve => { - resolvePromise = resolve; - }); - hydrate(, scratch); - rerender(); - - update(); - rerender(); - - shouldSuspend = false; - resolvePromise(); - await Promise.resolve(); - await new Promise(resolve => setTimeout(resolve)); - rerender(); - - expect(renderedIds).to.deep.equal([serverId]); - }); - it('should leave DOM untouched when suspending while hydrating', () => { scratch.innerHTML = '
Hello
'; clearLog();