From 3ceee63897f6b39ba7153e9f7904497c3e941b60 Mon Sep 17 00:00:00 2001 From: Jovi De Croock Date: Tue, 14 Jul 2026 18:35:16 +0200 Subject: [PATCH] Stabilize useId with named Suspense boundaries --- compat/src/internal.d.ts | 3 +- compat/src/suspense.d.ts | 2 + compat/src/suspense.js | 11 +- .../test/browser/suspense-hydration.test.jsx | 115 ++++++++++++++++++ compat/test/ts/suspense.tsx | 2 +- hooks/src/internal.d.ts | 2 +- 6 files changed, 129 insertions(+), 6 deletions(-) diff --git a/compat/src/internal.d.ts b/compat/src/internal.d.ts index 131c4c2e57..e180cfbb60 100644 --- a/compat/src/internal.d.ts +++ b/compat/src/internal.d.ts @@ -34,6 +34,7 @@ export interface FunctionComponent

extends PreactFunctionComponent

{ export interface VNode extends PreactVNode { $$typeof?: symbol | string; preactCompatNormalized?: boolean; + _mask?: [string | number, number]; } export interface SuspenseState { @@ -47,5 +48,5 @@ export interface SuspenseComponent extends PreactComponent< _pendingSuspensionCount: number; _suspenders: Component[]; _detachOnNextRender: null | VNode; - _mask?: [number, number]; + _mask?: [string | number, number]; } diff --git a/compat/src/suspense.d.ts b/compat/src/suspense.d.ts index 65c2a9372a..8e43a940f5 100644 --- a/compat/src/suspense.d.ts +++ b/compat/src/suspense.d.ts @@ -12,6 +12,8 @@ export function lazy( export interface SuspenseProps { children?: ComponentChildren; fallback: ComponentChildren; + /** A stable, unique name for useId calls during resumed hydration. */ + name?: string; } export class Suspense extends Component { diff --git a/compat/src/suspense.js b/compat/src/suspense.js index f5681d0123..2e5085ba38 100644 --- a/compat/src/suspense.js +++ b/compat/src/suspense.js @@ -198,14 +198,19 @@ Suspense.prototype.componentWillUnmount = function () { * @param {import('./internal').SuspenseState} state */ Suspense.prototype.render = function (props, state) { + let vnode = this._vnode; + if (props.name) { + vnode._mask = this._mask || (this._mask = ['S' + props.name, 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 (this._vnode._children) { + if (vnode._children) { const detachedParent = document.createElement('div'); - const detachedComponent = this._vnode._children[0]._component; - this._vnode._children[0] = detachedClone( + const detachedComponent = vnode._children[0]._component; + 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 c201b6664d..b6d198231e 100644 --- a/compat/test/browser/suspense-hydration.test.jsx +++ b/compat/test/browser/suspense-hydration.test.jsx @@ -4,7 +4,9 @@ import React, { hydrate, Fragment, Suspense, + lazy, memo, + useId, useState } from 'preact/compat'; import { logCall, getLog, clearLog } from '../../../test/_util/logCall'; @@ -15,6 +17,7 @@ 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'; /* eslint-env browser, mocha */ describe('suspense hydration', () => { @@ -73,6 +76,118 @@ describe('suspense hydration', () => { } }); + it('keeps named Suspense ids stable across async resolution order', 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 }) { + return {name}; + } + + const createNamedLazy = name => + lazy( + () => + new Promise(resolve => { + loaders[name] = () => + resolve({ default: () => }); + }) + ); + + const A = createNamedLazy('A'); + const B = createNamedLazy('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: 'PSA-0', B: 'PSB-0' }); + expect(reversed).to.deep.equal(ordered); + }); + + it('keeps named Suspense ids stable when client-only work renders during hydration', async () => { + let renderedIds; + function Field({ name }) { + const id = useId(); + if (renderedIds) renderedIds[name] = id; + return {name}; + } + + function ResolvedContent() { + return ( + <> + + + + + + ); + } + + scratch.innerHTML = renderToString( + + + + ); + expect(scratch.innerHTML).to.equal( + 'outernested' + ); + + const [Lazy, resolve] = createLazy(); + renderedIds = {}; + let showClientContent; + function App() { + const [showClient, setShowClient] = useState(false); + showClientContent = () => setShowClient(true); + return ( + <> + {showClient && ( + + + + )} + + + + + ); + } + + hydrate(, scratch); + rerender(); + showClientContent(); + rerender(); + await resolve(ResolvedContent); + rerender(); + + expect(renderedIds).to.deep.equal({ + client: 'PSclient-0', + outer: 'PSouter-0', + nested: 'PSnested-0' + }); + }); + it('should leave DOM untouched when suspending while hydrating', () => { scratch.innerHTML = '
Hello
'; clearLog(); diff --git a/compat/test/ts/suspense.tsx b/compat/test/ts/suspense.tsx index c082f54663..7b235ad15b 100644 --- a/compat/test/ts/suspense.tsx +++ b/compat/test/ts/suspense.tsx @@ -30,7 +30,7 @@ const IsLazyFunc = React.lazy(() => componentPromise); class ReactSuspensefulFunc extends React.Component { render() { return ( - }> + }> ); diff --git a/hooks/src/internal.d.ts b/hooks/src/internal.d.ts index e219c58390..3d3edbc31a 100644 --- a/hooks/src/internal.d.ts +++ b/hooks/src/internal.d.ts @@ -43,7 +43,7 @@ export interface Component extends Omit< } export interface VNode extends Omit { - _mask?: [number, number]; + _mask?: [string | number, number]; _component?: Component; // Override with our specific Component type }