diff --git a/compat/src/suspense.js b/compat/src/suspense.js
index 15c44f02f3..5f922c79d8 100644
--- a/compat/src/suspense.js
+++ b/compat/src/suspense.js
@@ -17,6 +17,10 @@ function initSuspenseHooks() {
while ((vnode = vnode._parent)) {
if ((component = vnode._component) && component._childDidSuspend) {
+ // A component that suspends before ever committing has no state
+ // worth keeping; mounted ones keep their hooks while parked.
+ if (oldVNode && !oldVNode._component)
+ newVNode._component.__hooks = UNDEFINED;
// Don't call oldCatchError if we found a Suspense
return component._childDidSuspend(error, newVNode);
}
@@ -40,12 +44,20 @@ function initSuspenseHooks() {
function detachedClone(vnode, detachedParent, parentDom) {
if (vnode) {
- if (vnode._component && vnode._component.__hooks) {
- vnode._component.__hooks._list.forEach(effect => {
- if (typeof effect._cleanup == 'function') effect._cleanup();
+ const hooks = vnode._component && vnode._component.__hooks;
+ if (hooks) {
+ hooks._list.forEach(effect => {
+ // Only effects carry `_passive`; clearing `_args` makes them run
+ // again when the tree is revealed, memo/ref state stays intact.
+ if (effect._passive != null) {
+ if (typeof effect._cleanup == 'function') effect._cleanup();
+ effect._cleanup = effect._args = UNDEFINED;
+ }
});
-
- vnode._component.__hooks = null;
+ // Drop effects queued by the aborted render; `options._render` swaps in
+ // a fresh `_pendingEffects` array before anything is pushed again, so
+ // sharing one empty array here is safe.
+ hooks._pendingEffects = vnode._component._renderCallbacks = [];
}
vnode = assign({ constructor: UNDEFINED }, vnode);
diff --git a/compat/test/browser/suspense-hydration.test.jsx b/compat/test/browser/suspense-hydration.test.jsx
index b1570c58b8..7d6b5d098a 100644
--- a/compat/test/browser/suspense-hydration.test.jsx
+++ b/compat/test/browser/suspense-hydration.test.jsx
@@ -2,8 +2,10 @@ import { setupRerender } from 'preact/test-utils';
import React, {
createElement,
hydrate,
+ render,
Fragment,
Suspense,
+ use,
memo,
useState,
useSyncExternalStore
@@ -874,6 +876,75 @@ describe('suspense hydration', () => {
});
});
+ it('should preserve component state when re-suspending after streaming-style hydration', async () => {
+ scratch.innerHTML =
+ '
';
+
+ let promise = Promise.resolve('Hello');
+ let increment;
+ function App() {
+ const message = use(promise);
+ const [count, setCount] = useState(0);
+ increment = () => setCount(value => value + 1);
+ return (
+
+
{message}
+
+
+ );
+ }
+
+ hydrate(
+ Fallback}>
+
+ ,
+ scratch
+ );
+ await promise;
+ rerender();
+ rerender();
+
+ increment();
+ rerender();
+ expect(scratch.querySelector('button').textContent).to.equal('Count: 1');
+
+ promise = Promise.resolve('Hello');
+ increment();
+ rerender();
+ await promise;
+ rerender();
+ rerender();
+
+ expect(scratch.querySelector('button').textContent).to.equal('Count: 2');
+ });
+
+ it('should preserve component state when re-suspending after client render', async () => {
+ let promise = Promise.resolve('Hello');
+ let increment;
+ function App() {
+ use(promise);
+ const [count, setCount] = useState(0);
+ increment = () => setCount(value => value + 1);
+ return ;
+ }
+
+ render(, scratch);
+ await promise;
+ rerender();
+ rerender();
+ increment();
+ rerender();
+ expect(scratch.textContent).to.equal('Count: 1');
+
+ promise = Promise.resolve('Hello');
+ increment();
+ rerender();
+ await promise;
+ rerender();
+ rerender();
+ expect(scratch.textContent).to.equal('Count: 2');
+ });
+
it('should correctly hydrate and rerender a memoized lazy data loader', () => {
const originalHtml = 'Count: 5
';
scratch.innerHTML = originalHtml;
diff --git a/compat/test/browser/suspense.test.jsx b/compat/test/browser/suspense.test.jsx
index 595ca857be..55f9751cc1 100644
--- a/compat/test/browser/suspense.test.jsx
+++ b/compat/test/browser/suspense.test.jsx
@@ -8,6 +8,7 @@ import React, {
Fragment,
createContext,
useState,
+ useRef,
useEffect,
useLayoutEffect,
memo
@@ -202,9 +203,43 @@ describe('suspense', () => {
resolve().then(assert).catch(assert);
});
- it('should reset hooks of components', () => {
+ it('should reset hooks when a component subtree suspends during mount', async () => {
+ let resolve;
+ let resolved = false;
+ let initializations = 0;
+ const promise = new Promise(r => {
+ resolve = () => {
+ resolved = true;
+ r();
+ return promise;
+ };
+ });
+
+ function App() {
+ const [value] = useState(() => ++initializations);
+ if (!resolved) throw promise;
+ return {value}
;
+ }
+
+ render(
+
+
+ ,
+ scratch
+ );
+ rerender();
+ expect(scratch.textContent).to.equal('loading');
+
+ await resolve();
+ rerender();
+ expect(scratch.innerHTML).to.equal('2
');
+ expect(initializations).to.equal(2);
+ });
+
+ it('should preserve hooks of mounted components', () => {
/** @type {(v) => void} */
let set;
+ let initialRef;
const LazyComp = ({ name }) => Hello from {name}
;
/** @type {() => Promise} */
@@ -222,7 +257,10 @@ describe('suspense', () => {
const Parent = ({ children }) => {
const [state, setState] = useState(false);
+ const ref = useRef({});
set = setState;
+ if (!initialRef) initialRef = ref;
+ else expect(ref).to.equal(initialRef);
return (
@@ -249,15 +287,21 @@ describe('suspense', () => {
return resolve().then(() => {
rerender();
- expect(scratch.innerHTML).to.eql(`
`);
+ expect(scratch.innerHTML).to.eql(
+ `
`
+ );
});
});
- it('should call effect cleanups', () => {
+ it('should call effect cleanups and setups when hiding and revealing', async () => {
/** @type {(v) => void} */
let set;
+ const effectSetupSpy = vi.fn();
const effectSpy = vi.fn();
+ const effectWithoutCleanupSpy = vi.fn();
+ const layoutEffectSetupSpy = vi.fn();
const layoutEffectSpy = vi.fn();
+ const layoutEffectWithoutCleanupSpy = vi.fn();
const LazyComp = ({ name }) =>
Hello from {name}
;
/** @type {() => Promise
} */
@@ -277,16 +321,20 @@ describe('suspense', () => {
const [state, setState] = useState(false);
set = setState;
useEffect(() => {
+ effectSetupSpy();
return () => {
effectSpy();
};
- }, []);
+ }, [state]);
+ useEffect(effectWithoutCleanupSpy, [state]);
useLayoutEffect(() => {
+ layoutEffectSetupSpy();
return () => {
layoutEffectSpy();
};
}, []);
+ useLayoutEffect(layoutEffectWithoutCleanupSpy, []);
return state ? (
{children}
@@ -305,19 +353,90 @@ describe('suspense', () => {
,
scratch
);
+ expect(layoutEffectSetupSpy).toHaveBeenCalledOnce();
+ expect(layoutEffectWithoutCleanupSpy).toHaveBeenCalledOnce();
set(true);
rerender();
expect(scratch.innerHTML).to.eql('Suspended...
');
+
+ expect(effectSetupSpy).toHaveBeenCalledOnce();
+ expect(effectWithoutCleanupSpy).toHaveBeenCalledOnce();
expect(effectSpy).toHaveBeenCalledOnce();
expect(layoutEffectSpy).toHaveBeenCalledOnce();
- return resolve().then(() => {
- rerender();
- expect(effectSpy).toHaveBeenCalledOnce();
- expect(layoutEffectSpy).toHaveBeenCalledOnce();
- expect(scratch.innerHTML).to.eql(``);
- });
+ await resolve();
+ await act(() => rerender());
+ expect(effectSpy).toHaveBeenCalledOnce();
+ expect(layoutEffectSpy).toHaveBeenCalledOnce();
+ expect(effectSetupSpy).toHaveBeenCalledTimes(2);
+ expect(effectWithoutCleanupSpy).toHaveBeenCalledTimes(2);
+ expect(layoutEffectSetupSpy).toHaveBeenCalledTimes(2);
+ expect(layoutEffectWithoutCleanupSpy).toHaveBeenCalledTimes(2);
+ expect(scratch.innerHTML).to.eql(
+ ``
+ );
+ });
+
+ it('should run a layout effect with changed deps once when revealed', async () => {
+ let promise = Promise.resolve();
+ let set;
+ let first = true;
+ const setup = vi.fn();
+ const cleanup = vi.fn();
+ function App() {
+ const [n, setN] = useState(0);
+ set = setN;
+ useLayoutEffect(() => {
+ setup(n);
+ return () => cleanup(n);
+ }, [n]);
+ if (n && first) {
+ first = false;
+ throw promise;
+ }
+ return {n}
;
+ }
+
+ render(
+
+
+ ,
+ scratch
+ );
+ expect(setup).toHaveBeenCalledTimes(1);
+
+ set(1);
+ rerender();
+ expect(scratch.textContent).to.equal('loading');
+ expect(cleanup).toHaveBeenCalledTimes(1);
+
+ await promise;
+ await act(() => rerender());
+ expect(scratch.textContent).to.equal('1');
+ expect(setup.mock.calls).to.deep.equal([[0], [1]]);
+ expect(cleanup).toHaveBeenCalledTimes(1);
+ });
+
+ it('should handle a promise thrown from a layout effect', () => {
+ let threw = false;
+ function App() {
+ useLayoutEffect(() => {
+ if (!threw) {
+ threw = true;
+ throw Promise.resolve();
+ }
+ });
+ return x
;
+ }
+
+ render(
+
+
+ ,
+ scratch
+ );
+ rerender();
});
it('should support a call to setState before rendering the fallback', () => {
diff --git a/hooks/src/index.js b/hooks/src/index.js
index 7e01bc76ec..f71c60c5b6 100644
--- a/hooks/src/index.js
+++ b/hooks/src/index.js
@@ -299,6 +299,7 @@ export function useLayoutEffect(callback, args) {
/** @type {import('./internal').EffectHookState} */
const state = getHookState(currentIndex++, 4);
if (!options._skipEffects && argsChanged(state._args, args)) {
+ state._passive = false;
state._value = callback;
state._pendingArgs = args;
diff --git a/mangle.json b/mangle.json
index 2e8515a059..5193d8cbf6 100644
--- a/mangle.json
+++ b/mangle.json
@@ -32,6 +32,7 @@
"$_hydrationMismatch": "__m",
"$_list": "__",
"$_pendingEffects": "__h",
+ "$_passive": "__P",
"$_value": "__",
"$_nextValue": "__N",
"$_original": "__v",