Skip to content

Commit e9415cf

Browse files
authored
Merge pull request #5239 from preactjs/fix/suspense-preserve-mounted-hooks
Preserve hook state when Suspense re-suspends
2 parents 04b2a86 + aa0cf80 commit e9415cf

5 files changed

Lines changed: 219 additions & 15 deletions

File tree

compat/src/suspense.js

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ function initSuspenseHooks() {
1717

1818
while ((vnode = vnode._parent)) {
1919
if ((component = vnode._component) && component._childDidSuspend) {
20+
// A component that suspends before ever committing has no state
21+
// worth keeping; mounted ones keep their hooks while parked.
22+
if (oldVNode && !oldVNode._component)
23+
newVNode._component.__hooks = UNDEFINED;
2024
// Don't call oldCatchError if we found a Suspense
2125
return component._childDidSuspend(error, newVNode);
2226
}
@@ -40,12 +44,20 @@ function initSuspenseHooks() {
4044

4145
function detachedClone(vnode, detachedParent, parentDom) {
4246
if (vnode) {
43-
if (vnode._component && vnode._component.__hooks) {
44-
vnode._component.__hooks._list.forEach(effect => {
45-
if (typeof effect._cleanup == 'function') effect._cleanup();
47+
const hooks = vnode._component && vnode._component.__hooks;
48+
if (hooks) {
49+
hooks._list.forEach(effect => {
50+
// Only effects carry `_passive`; clearing `_args` makes them run
51+
// again when the tree is revealed, memo/ref state stays intact.
52+
if (effect._passive != null) {
53+
if (typeof effect._cleanup == 'function') effect._cleanup();
54+
effect._cleanup = effect._args = UNDEFINED;
55+
}
4656
});
47-
48-
vnode._component.__hooks = null;
57+
// Drop effects queued by the aborted render; `options._render` swaps in
58+
// a fresh `_pendingEffects` array before anything is pushed again, so
59+
// sharing one empty array here is safe.
60+
hooks._pendingEffects = vnode._component._renderCallbacks = [];
4961
}
5062

5163
vnode = assign({ constructor: UNDEFINED }, vnode);

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: 129 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,90 @@ 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+
);
379+
});
380+
381+
it('should run a layout effect with changed deps once when revealed', async () => {
382+
let promise = Promise.resolve();
383+
let set;
384+
let first = true;
385+
const setup = vi.fn();
386+
const cleanup = vi.fn();
387+
function App() {
388+
const [n, setN] = useState(0);
389+
set = setN;
390+
useLayoutEffect(() => {
391+
setup(n);
392+
return () => cleanup(n);
393+
}, [n]);
394+
if (n && first) {
395+
first = false;
396+
throw promise;
397+
}
398+
return <p>{n}</p>;
399+
}
400+
401+
render(
402+
<Suspense fallback="loading">
403+
<App />
404+
</Suspense>,
405+
scratch
406+
);
407+
expect(setup).toHaveBeenCalledTimes(1);
408+
409+
set(1);
410+
rerender();
411+
expect(scratch.textContent).to.equal('loading');
412+
expect(cleanup).toHaveBeenCalledTimes(1);
413+
414+
await promise;
415+
await act(() => rerender());
416+
expect(scratch.textContent).to.equal('1');
417+
expect(setup.mock.calls).to.deep.equal([[0], [1]]);
418+
expect(cleanup).toHaveBeenCalledTimes(1);
419+
});
420+
421+
it('should handle a promise thrown from a layout effect', () => {
422+
let threw = false;
423+
function App() {
424+
useLayoutEffect(() => {
425+
if (!threw) {
426+
threw = true;
427+
throw Promise.resolve();
428+
}
429+
});
430+
return <p>x</p>;
431+
}
432+
433+
render(
434+
<Suspense fallback="loading">
435+
<App />
436+
</Suspense>,
437+
scratch
438+
);
439+
rerender();
321440
});
322441

323442
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)