Skip to content

Commit 67f0290

Browse files
authored
Fix useId stability across async Suspense (#5108)
Assisted-By: devx/1b997072-d89c-4f6a-a18f-9525b3ef7a9e
1 parent d9e8984 commit 67f0290

3 files changed

Lines changed: 206 additions & 3 deletions

File tree

compat/src/internal.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ export interface FunctionComponent<P = {}> extends PreactFunctionComponent<P> {
3434
export interface VNode<T = any> extends PreactVNode<T> {
3535
$$typeof?: symbol | string;
3636
preactCompatNormalized?: boolean;
37+
_mask?: [number, number];
3738
}
3839

3940
export interface SuspenseState {

compat/src/suspense.js

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -198,14 +198,23 @@ Suspense.prototype.componentWillUnmount = function () {
198198
* @param {import('./internal').SuspenseState} state
199199
*/
200200
Suspense.prototype.render = function (props, state) {
201+
let vnode = this._vnode;
202+
if (!vnode._mask) {
203+
let root = vnode;
204+
while (root._parent) root = root._parent;
205+
206+
root = root._mask || (root._mask = [0, 0]);
207+
vnode._mask = [root[1]++, 0];
208+
}
209+
201210
if (this._detachOnNextRender) {
202211
// When the Suspense's _vnode was created by a call to createVNode
203212
// (i.e. due to a setState further up in the tree)
204213
// it's _children prop is null, in this case we "forget" about the parked vnodes to detach
205-
if (this._vnode._children) {
214+
if (vnode._children) {
206215
const detachedParent = document.createElement('div');
207-
const detachedComponent = this._vnode._children[0]._component;
208-
this._vnode._children[0] = detachedClone(
216+
const detachedComponent = vnode._children[0]._component;
217+
vnode._children[0] = detachedClone(
209218
this._detachOnNextRender,
210219
detachedParent,
211220
(detachedComponent._originalParentDom = detachedComponent._parentDom)

compat/test/browser/suspense-hydration.test.jsx

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ import React, {
44
hydrate,
55
Fragment,
66
Suspense,
7+
lazy,
78
memo,
9+
useId,
810
useState
911
} from 'preact/compat';
1012
import { logCall, getLog, clearLog } from '../../../test/_util/logCall';
@@ -15,6 +17,7 @@ import {
1517
} from '../../../test/_util/helpers';
1618
import { ul, li, div } from '../../../test/_util/dom';
1719
import { createLazy, createSuspenseLoader } from './suspense-utils';
20+
import { renderToString, renderToStringAsync } from 'preact-render-to-string';
1821

1922
/* eslint-env browser, mocha */
2023
describe('suspense hydration', () => {
@@ -73,6 +76,196 @@ describe('suspense hydration', () => {
7376
}
7477
});
7578

79+
it('is stable for async Suspense siblings resolving in different orders', async () => {
80+
const getIds = html =>
81+
Object.fromEntries(
82+
[...html.matchAll(/<span id="([^"]+)">([AB])<\/span>/g)].map(
83+
([, id, name]) => [name, id]
84+
)
85+
);
86+
87+
async function renderWithResolveOrder(order) {
88+
const loaders = {};
89+
90+
function Field({ name }) {
91+
const id = useId();
92+
return <span id={id}>{name}</span>;
93+
}
94+
95+
const createLazy = name =>
96+
lazy(
97+
() =>
98+
new Promise(resolve => {
99+
loaders[name] = () =>
100+
resolve({ default: () => <Field name={name} /> });
101+
})
102+
);
103+
104+
const A = createLazy('A');
105+
const B = createLazy('B');
106+
const rendered = renderToStringAsync(
107+
<div>
108+
<Suspense fallback={null}>
109+
<A />
110+
</Suspense>
111+
<Suspense fallback={null}>
112+
<B />
113+
</Suspense>
114+
</div>
115+
);
116+
117+
await Promise.resolve();
118+
order.some(name => loaders[name]());
119+
120+
return getIds(await rendered);
121+
}
122+
123+
const ordered = await renderWithResolveOrder(['A', 'B']);
124+
const reversed = await renderWithResolveOrder(['B', 'A']);
125+
126+
expect(new Set(Object.values(ordered)).size).to.equal(2);
127+
expect(new Set(Object.values(reversed)).size).to.equal(2);
128+
expect(reversed).to.deep.equal(ordered);
129+
});
130+
131+
it('is stable for nested async Suspense siblings resolving in different orders', async () => {
132+
const getIds = html =>
133+
Object.fromEntries(
134+
[...html.matchAll(/<span id="([^"]+)">([AB])<\/span>/g)].map(
135+
([, id, name]) => [name, id]
136+
)
137+
);
138+
139+
async function renderWithResolveOrder(order) {
140+
const loaders = {};
141+
142+
function Field({ name }) {
143+
const id = useId();
144+
return <span id={id}>{name}</span>;
145+
}
146+
147+
const createLazy = name =>
148+
lazy(
149+
() =>
150+
new Promise(resolve => {
151+
loaders[name] = () =>
152+
resolve({ default: () => <Field name={name} /> });
153+
})
154+
);
155+
156+
const A = createLazy('A');
157+
const B = createLazy('B');
158+
const rendered = renderToStringAsync(
159+
<Suspense fallback={null}>
160+
<Suspense fallback={null}>
161+
<A />
162+
</Suspense>
163+
<Suspense fallback={null}>
164+
<B />
165+
</Suspense>
166+
</Suspense>
167+
);
168+
169+
await Promise.resolve();
170+
order.some(name => loaders[name]());
171+
172+
return getIds(await rendered);
173+
}
174+
175+
const ordered = await renderWithResolveOrder(['A', 'B']);
176+
const reversed = await renderWithResolveOrder(['B', 'A']);
177+
178+
expect(ordered).to.deep.equal({ A: 'P1-0', B: 'P2-0' });
179+
expect(reversed).to.deep.equal(ordered);
180+
});
181+
182+
it('does not leak Suspense useId masks across abandoned renderToString attempts', () => {
183+
const idsIn = html => [...html.matchAll(/P\d+-\d+/g)].map(([id]) => id);
184+
185+
function Field() {
186+
return <i>{useId()}</i>;
187+
}
188+
189+
function Suspends() {
190+
throw Promise.resolve();
191+
}
192+
193+
const tree = () => (
194+
<>
195+
<Suspense fallback={null}>
196+
<Field />
197+
</Suspense>
198+
<Suspense fallback={null}>
199+
<Field />
200+
</Suspense>
201+
</>
202+
);
203+
204+
const first = idsIn(renderToString(tree()));
205+
expect(first).to.deep.equal(['P0-0', 'P1-0']);
206+
207+
expect(() =>
208+
renderToString(
209+
<Suspense fallback={null}>
210+
<Suspends />
211+
</Suspense>
212+
)
213+
).to.throw(/renderToStringAsync/);
214+
215+
expect(idsIn(renderToString(tree()))).to.deep.equal(first);
216+
});
217+
218+
it('keeps deeply nested Suspense useId masks compact', async () => {
219+
function Field() {
220+
const id = useId();
221+
return <span id={id}>field</span>;
222+
}
223+
224+
const Wrapper = ({ children }) => children;
225+
let child = (
226+
<Suspense fallback={null}>
227+
<Field />
228+
</Suspense>
229+
);
230+
231+
for (let i = 0; i < 10; i++) {
232+
child = <Wrapper>{child}</Wrapper>;
233+
}
234+
235+
const html = await renderToStringAsync(
236+
<Suspense fallback={null}>{child}</Suspense>
237+
);
238+
239+
expect(html).to.equal('<span id="P1-0">field</span>');
240+
});
241+
242+
it('keeps nested Suspense ids distinct from parent useId calls', async () => {
243+
const ids = [];
244+
245+
function Field() {
246+
ids.push(useId());
247+
return <span id={ids[1]}>field</span>;
248+
}
249+
250+
function Wrapper() {
251+
ids.push(useId());
252+
return (
253+
<Suspense fallback={null}>
254+
<Field />
255+
</Suspense>
256+
);
257+
}
258+
259+
await renderToStringAsync(
260+
<Suspense fallback={null}>
261+
<Wrapper />
262+
</Suspense>
263+
);
264+
265+
expect(ids[0]).to.equal('P0-0');
266+
expect(ids[1]).to.equal('P1-0');
267+
});
268+
76269
it('should leave DOM untouched when suspending while hydrating', () => {
77270
scratch.innerHTML = '<div>Hello</div>';
78271
clearLog();

0 commit comments

Comments
 (0)