diff --git a/compat/src/suspense.js b/compat/src/suspense.js index 8a55b0971e..a962821e53 100644 --- a/compat/src/suspense.js +++ b/compat/src/suspense.js @@ -144,6 +144,12 @@ Suspense.prototype._childDidSuspend = function (promise, suspendingVNode) { suspendingComponent._onResolve = onResolved; + // Store and null _parentDom to prevent setState/forceUpdate from + // scheduling renders while suspended. Render would be a no-op anyway + // since renderComponent checks _parentDom, but this avoids queue churn. + const originalParentDom = suspendingComponent._parentDom; + suspendingComponent._parentDom = null; + const onSuspensionComplete = () => { if (!--c._pendingSuspensionCount) { // If the suspension was during hydration we don't need to restore the @@ -161,6 +167,8 @@ Suspense.prototype._childDidSuspend = function (promise, suspendingVNode) { let suspended; while ((suspended = c._suspenders.pop())) { + // Restore _parentDom before forceUpdate so render can proceed + suspended._parentDom = originalParentDom; suspended.forceUpdate(); } } diff --git a/compat/test/browser/suspense.test.js b/compat/test/browser/suspense.test.js index 9eaaf326fb..99f8b3bfa1 100644 --- a/compat/test/browser/suspense.test.js +++ b/compat/test/browser/suspense.test.js @@ -2543,4 +2543,114 @@ describe('suspense', () => { `
Memod effect executedeffect executed
` ); }); + + it('should not schedule renders for setState on suspended component', async () => { + let suspenderSetState; + let renderCount = 0; + + class Suspender extends Component { + constructor(props) { + super(props); + this.state = { count: 0 }; + suspenderSetState = this.setState.bind(this); + } + + render(props, state) { + renderCount++; + if (props.suspend && !props.resolved) { + throw props.promise; + } + return
Count: {state.count}
; + } + } + + let resolve; + const promise = new Promise(r => { + resolve = r; + }); + + act(() => { + render( + Loading...}> + + , + scratch + ); + }); + + rerender(); + + expect(scratch.innerHTML).to.equal('
Loading...
'); + const renderCountAfterSuspend = renderCount; + + // Call setState on the suspended component multiple times + suspenderSetState({ count: 1 }); + suspenderSetState({ count: 2 }); + suspenderSetState({ count: 3 }); + rerender(); + + // Render count should not have increased - setState should not trigger re-renders while suspended + expect(renderCount).to.equal(renderCountAfterSuspend); + expect(scratch.innerHTML).to.equal('
Loading...
'); + + // Resolve the suspension + resolve(); + await promise; + + render( + Loading...}> + + , + scratch + ); + rerender(); + + // After resolving, the state should have been buffered and applied + expect(scratch.innerHTML).to.equal('
Count: 3
'); + }); + + it('should not schedule renders for forceUpdate on suspended component', () => { + let suspenderForceUpdate; + let renderCount = 0; + + class Suspender extends Component { + constructor(props) { + super(props); + suspenderForceUpdate = this.forceUpdate.bind(this); + } + + render(props) { + renderCount++; + if (props.suspend && !props.resolved) { + throw props.promise; + } + return
Rendered {renderCount} times
; + } + } + + const promise = new Promise(() => {}); + + act(() => { + render( + Loading...}> + + , + scratch + ); + }); + + rerender(); + + expect(scratch.innerHTML).to.equal('
Loading...
'); + const renderCountAfterSuspend = renderCount; + + // Call forceUpdate on the suspended component + suspenderForceUpdate(); + suspenderForceUpdate(); + rerender(); + + // Render count should not have increased + expect(renderCount).to.equal(renderCountAfterSuspend); + expect(scratch.innerHTML).to.equal('
Loading...
'); + }); });