Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Bugfix: Synchronous ping during render phase sometimes unwinds the stack, leading to crash #25851

Merged
merged 2 commits into from
Dec 22, 2022
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
12 changes: 10 additions & 2 deletions packages/react-reconciler/src/ReactFiberWorkLoop.js
Original file line number Diff line number Diff line change
Expand Up @@ -3366,8 +3366,16 @@ function pingSuspendedRoot(
includesOnlyRetries(workInProgressRootRenderLanes) &&
now() - globalMostRecentFallbackTime < FALLBACK_THROTTLE_MS)
) {
// Restart from the root.
prepareFreshStack(root, NoLanes);
// Force a restart from the root by unwinding the stack. Unless this is
// being called from the render phase, because that would cause a crash.
if ((executionContext & RenderContext) === NoContext) {
prepareFreshStack(root, NoLanes);
} else {
// TODO: If this does happen during the render phase, we should throw
// the special internal exception that we use to interrupt the stack for
// selective hydration. That was temporarily reverted but we once we add
// it back we can use it here.
}
} else {
// Even though we can't restart right now, we might get an
// opportunity later. So we mark this render as having a ping.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4218,4 +4218,80 @@ describe('ReactSuspenseWithNoopRenderer', () => {
});
expect(Scheduler).toHaveYielded(['Unmount Child']);
});

// @gate enableLegacyCache
it(
'regression test: pinging synchronously within the render phase ' +
'does not unwind the stack',
async () => {
// This is a regression test that reproduces a very specific scenario that
// used to cause a crash.
const thenable = {
then(resolve) {
resolve('hi');
},
status: 'pending',
};

function ImmediatelyPings() {
if (thenable.status === 'pending') {
thenable.status = 'fulfilled';
throw thenable;
}
return <Text text="Hi" />;
}

function App({showMore}) {
return (
<div>
<Suspense fallback={<Text text="Loading..." />}>
{showMore ? (
<>
<AsyncText text="Async" />
</>
) : null}
</Suspense>
{showMore ? (
<Suspense>
<ImmediatelyPings />
</Suspense>
) : null}
</div>
);
}

// Initial render. This mounts a Suspense boundary, so that in the next
// update we can trigger a "suspend with delay" scenario.
const root = ReactNoop.createRoot();
await act(async () => {
root.render(<App showMore={false} />);
});
expect(Scheduler).toHaveYielded([]);
expect(root).toMatchRenderedOutput(<div />);

// Update. This will cause two separate trees to suspend. The first tree
// will be inside an already mounted Suspense boundary, so it will trigger
// a "suspend with delay". The second tree will be a new Suspense
// boundary, but the thenable that is thrown will immediately call its
// ping listener.
//
// Before the bug was fixed, this would lead to a `prepareFreshStack` call
// that unwinds the work-in-progress stack. When that code was written, it
// was expected that pings always happen from an asynchronous task (or
// microtask). But this test shows an example where that's not the case.
//
// The fix was to check if we're in the render phase before calling
// `prepareFreshStack`.
await act(async () => {
root.render(<App showMore={true} />);
});
expect(Scheduler).toHaveYielded(['Suspend! [Async]', 'Loading...', 'Hi']);
expect(root).toMatchRenderedOutput(
<div>
<span prop="Loading..." />
<span prop="Hi" />
</div>,
);
},
);
});