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
}