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(`

hi

`); + // Parent was already mounted so hook state is preserved (state + // stays true) and the resolved Lazy component renders. + expect(scratch.innerHTML).to.eql( + '

hi

Hello from LazyComp
' + ); }); }); @@ -273,7 +277,10 @@ describe('suspense', () => { rerender(); expect(effectSpy).toHaveBeenCalledOnce(); expect(layoutEffectSpy).toHaveBeenCalledOnce(); - expect(scratch.innerHTML).to.eql(`

hi

`); + // Parent state preserved: state=true so renders children branch + expect(scratch.innerHTML).to.eql( + '
Hello from LazyComp
' + ); }); }); @@ -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(); + }); + }); });