Skip to content

Commit be33f7a

Browse files
committed
Fix hook state loss when Suspense re-suspends
1 parent 04b2a86 commit be33f7a

5 files changed

Lines changed: 158 additions & 17 deletions

File tree

compat/src/suspense.js

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -38,14 +38,19 @@ function initSuspenseHooks() {
3838
};
3939
}
4040

41-
function detachedClone(vnode, detachedParent, parentDom) {
41+
function detachedClone(vnode, detachedParent, parentDom, preserveHooks) {
4242
if (vnode) {
4343
if (vnode._component && vnode._component.__hooks) {
44-
vnode._component.__hooks._list.forEach(effect => {
45-
if (typeof effect._cleanup == 'function') effect._cleanup();
44+
const hooks = vnode._component.__hooks;
45+
hooks._list.forEach(effect => {
46+
if (!preserveHooks || effect._passive != null) {
47+
const cleanup = effect._cleanup;
48+
effect._cleanup = effect._args = UNDEFINED;
49+
if (typeof cleanup == 'function') cleanup();
50+
}
4651
});
47-
48-
vnode._component.__hooks = null;
52+
if (preserveHooks) hooks._pendingEffects = [];
53+
else vnode._component.__hooks = null;
4954
}
5055

5156
vnode = assign({ constructor: UNDEFINED }, vnode);
@@ -62,7 +67,7 @@ function detachedClone(vnode, detachedParent, parentDom) {
6267
vnode._children =
6368
vnode._children &&
6469
vnode._children.map(child =>
65-
detachedClone(child, detachedParent, parentDom)
70+
detachedClone(child, detachedParent, parentDom, preserveHooks)
6671
);
6772
}
6873

@@ -186,6 +191,10 @@ function createSuspense() {
186191
Suspense.prototype.componentWillUnmount = function () {
187192
this._suspenders = [];
188193
};
194+
Suspense.prototype.componentDidMount =
195+
Suspense.prototype.componentDidUpdate = function () {
196+
if (!this._pendingSuspensionCount) this._unmounted = false;
197+
};
189198

190199
/**
191200
* @this {import('./internal').SuspenseComponent}
@@ -203,7 +212,8 @@ function createSuspense() {
203212
this._vnode._children[0] = detachedClone(
204213
this._detachOnNextRender,
205214
detachedParent,
206-
(detachedComponent._originalParentDom = detachedComponent._parentDom)
215+
(detachedComponent._originalParentDom = detachedComponent._parentDom),
216+
this._unmounted == false
207217
);
208218
}
209219

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

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@ import { setupRerender } from 'preact/test-utils';
22
import React, {
33
createElement,
44
hydrate,
5+
render,
56
Fragment,
67
Suspense,
8+
use,
79
memo,
810
useState,
911
useSyncExternalStore
@@ -874,6 +876,75 @@ describe('suspense hydration', () => {
874876
});
875877
});
876878

879+
it('should preserve component state when re-suspending after streaming-style hydration', async () => {
880+
scratch.innerHTML =
881+
'<!--$s:2--><div><p>Hello</p><button>Count: 0</button></div><!--/$s:2-->';
882+
883+
let promise = Promise.resolve('Hello');
884+
let increment;
885+
function App() {
886+
const message = use(promise);
887+
const [count, setCount] = useState(0);
888+
increment = () => setCount(value => value + 1);
889+
return (
890+
<div>
891+
<p>{message}</p>
892+
<button>Count: {count}</button>
893+
</div>
894+
);
895+
}
896+
897+
hydrate(
898+
<Suspense fallback={<div>Fallback</div>}>
899+
<App />
900+
</Suspense>,
901+
scratch
902+
);
903+
await promise;
904+
rerender();
905+
rerender();
906+
907+
increment();
908+
rerender();
909+
expect(scratch.querySelector('button').textContent).to.equal('Count: 1');
910+
911+
promise = Promise.resolve('Hello');
912+
increment();
913+
rerender();
914+
await promise;
915+
rerender();
916+
rerender();
917+
918+
expect(scratch.querySelector('button').textContent).to.equal('Count: 2');
919+
});
920+
921+
it('should preserve component state when re-suspending after client render', async () => {
922+
let promise = Promise.resolve('Hello');
923+
let increment;
924+
function App() {
925+
use(promise);
926+
const [count, setCount] = useState(0);
927+
increment = () => setCount(value => value + 1);
928+
return <button>Count: {count}</button>;
929+
}
930+
931+
render(<Suspense fallback="Fallback"><App /></Suspense>, scratch);
932+
await promise;
933+
rerender();
934+
rerender();
935+
increment();
936+
rerender();
937+
expect(scratch.textContent).to.equal('Count: 1');
938+
939+
promise = Promise.resolve('Hello');
940+
increment();
941+
rerender();
942+
await promise;
943+
rerender();
944+
rerender();
945+
expect(scratch.textContent).to.equal('Count: 2');
946+
});
947+
877948
it('should correctly hydrate and rerender a memoized lazy data loader', () => {
878949
const originalHtml = '<p>Count: 5</p>';
879950
scratch.innerHTML = originalHtml;

compat/test/browser/suspense.test.jsx

Lines changed: 68 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import React, {
88
Fragment,
99
createContext,
1010
useState,
11+
useRef,
1112
useEffect,
1213
useLayoutEffect,
1314
memo
@@ -202,9 +203,43 @@ describe('suspense', () => {
202203
resolve().then(assert).catch(assert);
203204
});
204205

205-
it('should reset hooks of components', () => {
206+
it('should reset hooks when a component subtree suspends during mount', async () => {
207+
let resolve;
208+
let resolved = false;
209+
let initializations = 0;
210+
const promise = new Promise(r => {
211+
resolve = () => {
212+
resolved = true;
213+
r();
214+
return promise;
215+
};
216+
});
217+
218+
function App() {
219+
const [value] = useState(() => ++initializations);
220+
if (!resolved) throw promise;
221+
return <p>{value}</p>;
222+
}
223+
224+
render(
225+
<Suspense fallback="loading">
226+
<App />
227+
</Suspense>,
228+
scratch
229+
);
230+
rerender();
231+
expect(scratch.textContent).to.equal('loading');
232+
233+
await resolve();
234+
rerender();
235+
expect(scratch.innerHTML).to.equal('<p>2</p>');
236+
expect(initializations).to.equal(2);
237+
});
238+
239+
it('should preserve hooks of mounted components', () => {
206240
/** @type {(v) => void} */
207241
let set;
242+
let initialRef;
208243
const LazyComp = ({ name }) => <div>Hello from {name}</div>;
209244

210245
/** @type {() => Promise<void>} */
@@ -222,7 +257,10 @@ describe('suspense', () => {
222257

223258
const Parent = ({ children }) => {
224259
const [state, setState] = useState(false);
260+
const ref = useRef({});
225261
set = setState;
262+
if (!initialRef) initialRef = ref;
263+
else expect(ref).to.equal(initialRef);
226264

227265
return (
228266
<div>
@@ -249,15 +287,21 @@ describe('suspense', () => {
249287

250288
return resolve().then(() => {
251289
rerender();
252-
expect(scratch.innerHTML).to.eql(`<div><p>hi</p></div>`);
290+
expect(scratch.innerHTML).to.eql(
291+
`<div><p>hi</p><div>Hello from LazyComp</div></div>`
292+
);
253293
});
254294
});
255295

256-
it('should call effect cleanups', () => {
296+
it('should call effect cleanups and setups when hiding and revealing', async () => {
257297
/** @type {(v) => void} */
258298
let set;
299+
const effectSetupSpy = vi.fn();
259300
const effectSpy = vi.fn();
301+
const effectWithoutCleanupSpy = vi.fn();
302+
const layoutEffectSetupSpy = vi.fn();
260303
const layoutEffectSpy = vi.fn();
304+
const layoutEffectWithoutCleanupSpy = vi.fn();
261305
const LazyComp = ({ name }) => <div>Hello from {name}</div>;
262306

263307
/** @type {() => Promise<void>} */
@@ -277,16 +321,20 @@ describe('suspense', () => {
277321
const [state, setState] = useState(false);
278322
set = setState;
279323
useEffect(() => {
324+
effectSetupSpy();
280325
return () => {
281326
effectSpy();
282327
};
283-
}, []);
328+
}, [state]);
329+
useEffect(effectWithoutCleanupSpy, [state]);
284330

285331
useLayoutEffect(() => {
332+
layoutEffectSetupSpy();
286333
return () => {
287334
layoutEffectSpy();
288335
};
289336
}, []);
337+
useLayoutEffect(layoutEffectWithoutCleanupSpy, []);
290338

291339
return state ? (
292340
<div>{children}</div>
@@ -305,19 +353,29 @@ describe('suspense', () => {
305353
</Suspense>,
306354
scratch
307355
);
356+
expect(layoutEffectSetupSpy).toHaveBeenCalledOnce();
357+
expect(layoutEffectWithoutCleanupSpy).toHaveBeenCalledOnce();
308358

309359
set(true);
310360
rerender();
311361
expect(scratch.innerHTML).to.eql('<div>Suspended...</div>');
362+
363+
expect(effectSetupSpy).toHaveBeenCalledOnce();
364+
expect(effectWithoutCleanupSpy).toHaveBeenCalledOnce();
312365
expect(effectSpy).toHaveBeenCalledOnce();
313366
expect(layoutEffectSpy).toHaveBeenCalledOnce();
314367

315-
return resolve().then(() => {
316-
rerender();
317-
expect(effectSpy).toHaveBeenCalledOnce();
318-
expect(layoutEffectSpy).toHaveBeenCalledOnce();
319-
expect(scratch.innerHTML).to.eql(`<div><p>hi</p></div>`);
320-
});
368+
await resolve();
369+
await act(() => rerender());
370+
expect(effectSpy).toHaveBeenCalledOnce();
371+
expect(layoutEffectSpy).toHaveBeenCalledOnce();
372+
expect(effectSetupSpy).toHaveBeenCalledTimes(2);
373+
expect(effectWithoutCleanupSpy).toHaveBeenCalledTimes(2);
374+
expect(layoutEffectSetupSpy).toHaveBeenCalledTimes(2);
375+
expect(layoutEffectWithoutCleanupSpy).toHaveBeenCalledTimes(2);
376+
expect(scratch.innerHTML).to.eql(
377+
`<div><div>Hello from LazyComp</div></div>`
378+
);
321379
});
322380

323381
it('should support a call to setState before rendering the fallback', () => {

hooks/src/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,7 @@ export function useLayoutEffect(callback, args) {
299299
/** @type {import('./internal').EffectHookState} */
300300
const state = getHookState(currentIndex++, 4);
301301
if (!options._skipEffects && argsChanged(state._args, args)) {
302+
state._passive = false;
302303
state._value = callback;
303304
state._pendingArgs = args;
304305

mangle.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
"$_hydrationMismatch": "__m",
3333
"$_list": "__",
3434
"$_pendingEffects": "__h",
35+
"$_passive": "__P",
3536
"$_value": "__",
3637
"$_nextValue": "__N",
3738
"$_original": "__v",

0 commit comments

Comments
 (0)