diff --git a/compat/src/suspense.js b/compat/src/suspense.js
index d22f04be8f..ee8ce60168 100644
--- a/compat/src/suspense.js
+++ b/compat/src/suspense.js
@@ -42,11 +42,21 @@ options.unmount = function (vnode) {
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();
- });
-
- vnode._component.__hooks = null;
+ if (vnode._dom != null && !vnode._component._excess) {
+ // Already-mounted component: preserve hook state but run
+ // effect cleanups. Effects will rerun when unsuspended.
+ vnode._component.__hooks._list.forEach(effect => {
+ if (typeof effect._cleanup == 'function') {
+ effect._cleanup();
+ effect._cleanup = undefined;
+ }
+ });
+ } else {
+ // Component suspended during initial mount (never committed
+ // to DOM). Discard hook state entirely, matching React's
+ // behavior of discarding the WIP fiber.
+ vnode._component.__hooks = null;
+ }
}
vnode = assign({}, vnode);
@@ -206,7 +216,32 @@ Suspense.prototype.render = function (props, state) {
);
}
+ // Save reference to this vnode. After diffChildren, its _children
+ // array becomes the "old children" for future diffs. We need this
+ // reference to swap in original vnodes when retrying after suspension.
+ this._suspendedVNode = this._vnode;
this._detachOnNextRender = null;
+ } else if (state._suspended) {
+ // Parent re-rendered while suspended (not from _childDidSuspend).
+ // Try rendering children again. If they re-throw, _childDidSuspend
+ // will re-catch and re-suspend. This supports patterns like
+ // react-freeze where a never-resolving thenable is thrown to freeze
+ // a subtree and later cleared by a parent re-render.
+
+ // Restore the original vnode tree into the old children array
+ // so the diff can reuse existing component instances (preserving
+ // hook state like useState).
+ const suspendedVNode = state._suspended;
+ if (this._suspendedVNode && this._suspendedVNode._children) {
+ this._suspendedVNode._children[0] = removeOriginal(
+ suspendedVNode,
+ suspendedVNode._component._parentDom,
+ suspendedVNode._component._originalParentDom
+ );
+ }
+
+ this._pendingSuspensionCount = 0;
+ state._suspended = this._suspendedVNode = this._suspenders = null;
}
return [
diff --git a/compat/test/browser/suspense.test.jsx b/compat/test/browser/suspense.test.jsx
index 37720a41ea..f0f57bf6f0 100644
--- a/compat/test/browser/suspense.test.jsx
+++ b/compat/test/browser/suspense.test.jsx
@@ -159,7 +159,7 @@ describe('suspense', () => {
resolve().then(assert).catch(assert);
});
- it('should reset hooks of components', () => {
+ it('should preserve hooks of already-mounted components', () => {
/** @type {(v) => void} */
let set;
const LazyComp = ({ name }) =>
Hello from {name}
;
@@ -206,7 +206,11 @@ describe('suspense', () => {
return resolve().then(() => {
rerender();
- expect(scratch.innerHTML).to.eql(``);
+ // Parent was already mounted so hook state is preserved (state
+ // stays true) and the resolved Lazy component renders.
+ expect(scratch.innerHTML).to.eql(
+ ''
+ );
});
});
@@ -273,7 +277,10 @@ describe('suspense', () => {
rerender();
expect(effectSpy).toHaveBeenCalledOnce();
expect(layoutEffectSpy).toHaveBeenCalledOnce();
- expect(scratch.innerHTML).to.eql(``);
+ // Parent state preserved: state=true so renders children branch
+ expect(scratch.innerHTML).to.eql(
+ ''
+ );
});
});
@@ -2662,4 +2669,284 @@ describe('suspense', () => {
expect(renderCount).to.equal(renderCountAfterSuspend);
expect(scratch.innerHTML).to.equal('Loading...
');
});
+
+ describe('suspension with never-resolving thenable', () => {
+ const neverResolve = { then() {} };
+
+ function ThrowWhen({ suspend, children }) {
+ if (suspend) {
+ throw neverResolve;
+ }
+ return createElement(Fragment, null, children);
+ }
+
+ it('should unsuspend when children stop throwing on parent re-render', () => {
+ /** @type {(v: boolean) => void} */
+ let setSuspend;
+ function App() {
+ const [suspend, ss] = useState(false);
+ setSuspend = ss;
+ return (
+ Loading...}>
+
+ Content
+
+
+ );
+ }
+
+ render(, scratch);
+ expect(scratch.innerHTML).to.equal('Content
');
+
+ act(() => setSuspend(true));
+ rerender();
+ expect(scratch.innerHTML).to.equal('Loading...
');
+
+ act(() => setSuspend(false));
+ rerender();
+ expect(scratch.innerHTML).to.equal('Content
');
+ });
+
+ it('should preserve child state across suspend/unsuspend', () => {
+ /** @type {(v: boolean) => void} */
+ let setSuspend;
+ /** @type {(v: number) => void} */
+ let setCount;
+
+ function Counter() {
+ const [count, sc] = useState(42);
+ setCount = sc;
+ return Count: {count}
;
+ }
+
+ function App() {
+ const [suspend, ss] = useState(false);
+ setSuspend = ss;
+ return (
+ Loading...}>
+
+
+
+
+ );
+ }
+
+ render(, scratch);
+ expect(scratch.innerHTML).to.equal('Count: 42
');
+
+ act(() => setCount(100));
+ rerender();
+ expect(scratch.innerHTML).to.equal('Count: 100
');
+
+ act(() => setSuspend(true));
+ rerender();
+ expect(scratch.innerHTML).to.equal('Loading...
');
+
+ act(() => setSuspend(false));
+ rerender();
+ expect(scratch.innerHTML).to.equal('Count: 100
');
+ });
+
+ it('should restore effects after unsuspend', () => {
+ /** @type {(v: boolean) => void} */
+ let setSuspend;
+
+ const effectSpy = vi.fn();
+ const cleanupSpy = vi.fn();
+ const layoutEffectSpy = vi.fn();
+ const layoutCleanupSpy = vi.fn();
+
+ function Child() {
+ useEffect(() => {
+ effectSpy();
+ return () => cleanupSpy();
+ });
+ useLayoutEffect(() => {
+ layoutEffectSpy();
+ return () => layoutCleanupSpy();
+ });
+ return Child
;
+ }
+
+ function App() {
+ const [suspend, ss] = useState(false);
+ setSuspend = ss;
+ return (
+ Loading...}>
+
+
+
+
+ );
+ }
+
+ act(() => {
+ render(, scratch);
+ });
+ expect(scratch.innerHTML).to.equal('Child
');
+ expect(effectSpy).toHaveBeenCalledTimes(1);
+ expect(layoutEffectSpy).toHaveBeenCalledTimes(1);
+
+ effectSpy.mockClear();
+ cleanupSpy.mockClear();
+ layoutEffectSpy.mockClear();
+ layoutCleanupSpy.mockClear();
+
+ act(() => setSuspend(true));
+ rerender();
+ expect(scratch.innerHTML).to.equal('Loading...
');
+
+ // Both effect type cleanups should run during suspension
+ expect(layoutCleanupSpy).toHaveBeenCalledTimes(1);
+ expect(cleanupSpy).toHaveBeenCalledTimes(1);
+
+ effectSpy.mockClear();
+ cleanupSpy.mockClear();
+ layoutEffectSpy.mockClear();
+ layoutCleanupSpy.mockClear();
+
+ act(() => setSuspend(false));
+ rerender();
+ expect(scratch.innerHTML).to.equal('Child
');
+ expect(effectSpy).toHaveBeenCalledTimes(1);
+ expect(layoutEffectSpy).toHaveBeenCalledTimes(1);
+ });
+
+ it('should handle parent re-rendering twice while children stay suspended', () => {
+ /** @type {(v: boolean) => void} */
+ let setSuspend;
+ /** @type {(v: number) => void} */
+ let setCount;
+
+ function App() {
+ const [suspend, ss] = useState(false);
+ const [count, sc] = useState(0);
+ setSuspend = ss;
+ setCount = sc;
+ return (
+ Loading...}>
+
+ Count: {count}
+
+
+ );
+ }
+
+ render(, scratch);
+ expect(scratch.innerHTML).to.equal('Count: 0
');
+
+ act(() => setSuspend(true));
+ rerender();
+ expect(scratch.innerHTML).to.equal('Loading...
');
+
+ // Parent re-renders while still suspended - children re-throw
+ act(() => setCount(1));
+ rerender();
+ expect(scratch.innerHTML).to.equal('Loading...
');
+
+ // Parent re-renders again while still suspended
+ act(() => setCount(2));
+ rerender();
+ expect(scratch.innerHTML).to.equal('Loading...
');
+
+ // Now unsuspend
+ act(() => setSuspend(false));
+ rerender();
+ expect(scratch.innerHTML).to.equal('Count: 2
');
+ });
+
+ it('should handle two consecutive suspend/unsuspend cycles', () => {
+ /** @type {(v: boolean) => void} */
+ let setSuspend;
+ /** @type {(v: number) => void} */
+ let setCount;
+
+ function Counter() {
+ const [count, sc] = useState(0);
+ setCount = sc;
+ return Count: {count}
;
+ }
+
+ function App() {
+ const [suspend, ss] = useState(false);
+ setSuspend = ss;
+ return (
+ Loading...}>
+
+
+
+
+ );
+ }
+
+ render(, scratch);
+ expect(scratch.innerHTML).to.equal('Count: 0
');
+
+ // First cycle: suspend -> unsuspend
+ act(() => setSuspend(true));
+ rerender();
+ expect(scratch.innerHTML).to.equal('Loading...
');
+
+ act(() => setSuspend(false));
+ rerender();
+ expect(scratch.innerHTML).to.equal('Count: 0
');
+
+ // Update state between cycles
+ act(() => setCount(5));
+ rerender();
+ expect(scratch.innerHTML).to.equal('Count: 5
');
+
+ // Second cycle: suspend -> unsuspend
+ act(() => setSuspend(true));
+ rerender();
+ expect(scratch.innerHTML).to.equal('Loading...
');
+
+ act(() => setSuspend(false));
+ rerender();
+ expect(scratch.innerHTML).to.equal('Count: 5
');
+ });
+
+ it('should not re-render suspended children when parent updates', () => {
+ /** @type {(v: boolean) => void} */
+ let setSuspend;
+ /** @type {(v: number) => void} */
+ let setParentCount;
+ const childRenderSpy = vi.fn();
+
+ function Child() {
+ childRenderSpy();
+ return Child
;
+ }
+
+ function App() {
+ const [suspend, ss] = useState(false);
+ const [count, sc] = useState(0);
+ setSuspend = ss;
+ setParentCount = sc;
+ return (
+
+ Parent: {count}
+ Loading...
}>
+
+
+
+
+
+ );
+ }
+
+ act(() => {
+ render(, scratch);
+ });
+ expect(childRenderSpy).toHaveBeenCalledTimes(1);
+
+ act(() => setSuspend(true));
+ rerender();
+ childRenderSpy.mockClear();
+
+ act(() => setParentCount(1));
+ rerender();
+ expect(childRenderSpy).not.toHaveBeenCalled();
+ });
+ });
});