Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions compat/src/suspense.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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();
}
}
Expand Down
110 changes: 110 additions & 0 deletions compat/test/browser/suspense.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2543,4 +2543,114 @@ describe('suspense', () => {
`<div><span>Memod effect executed</span><span>effect executed</span></div>`
);
});

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 <div>Count: {state.count}</div>;
}
}

let resolve;
const promise = new Promise(r => {
resolve = r;
});

act(() => {
render(
<Suspense fallback={<div>Loading...</div>}>
<Suspender suspend={true} resolved={false} promise={promise} />
</Suspense>,
scratch
);
});

rerender();

expect(scratch.innerHTML).to.equal('<div>Loading...</div>');
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('<div>Loading...</div>');

// Resolve the suspension
resolve();
await promise;

render(
<Suspense fallback={<div>Loading...</div>}>
<Suspender suspend={false} resolved={true} promise={promise} />
</Suspense>,
scratch
);
rerender();

// After resolving, the state should have been buffered and applied
expect(scratch.innerHTML).to.equal('<div>Count: 3</div>');
});

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 <div>Rendered {renderCount} times</div>;
}
}

const promise = new Promise(() => {});

act(() => {
render(
<Suspense fallback={<div>Loading...</div>}>
<Suspender suspend={true} resolved={false} promise={promise} />
</Suspense>,
scratch
);
});

rerender();

expect(scratch.innerHTML).to.equal('<div>Loading...</div>');
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('<div>Loading...</div>');
});
});
Loading