From eb55f90db39a6f77f61bf80d65b67ad874a64909 Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Wed, 7 Dec 2022 19:17:50 -0500 Subject: [PATCH 1/2] Bug: A sync ping in render phase unwinds the stack I found this bug when working on a different task. `pingSuspendedRoot` sometimes calls `prepareFreshStack` to interupt the work-in-progress tree and force a restart from the root. The idea is that if the current render is already in a state where it be blocked from committing, and there's new data that could unblock it, we might as well restart from the beginning. The problem is that this is only safe to do if `pingSuspendedRoot` is called from a non-React task, like an event handler or a microtask. While this is usually the case, it's entirely possible for a thenable to resolve (i.e. to call `pingSuspendedRoot`) synchronously while the render phase is already executing. If that happens, and work loop attempts to unwind the stack, it causes the render phase to crash. This commit adds a regression test that reproduces one of these scenarios. --- .../ReactSuspenseWithNoopRenderer-test.js | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.js b/packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.js index 5b4c9345443bf..bb4ea103ff124 100644 --- a/packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.js +++ b/packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.js @@ -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 ; + } + + function App({showMore}) { + return ( +
+ }> + {showMore ? ( + <> + + + ) : null} + + {showMore ? ( + + + + ) : null} +
+ ); + } + + // 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(); + }); + expect(Scheduler).toHaveYielded([]); + expect(root).toMatchRenderedOutput(
); + + // 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(); + }); + expect(Scheduler).toHaveYielded(['Suspend! [Async]', 'Loading...', 'Hi']); + expect(root).toMatchRenderedOutput( +
+ + +
, + ); + }, + ); }); From 9659788c8b5b1bf209a779fd84c60501aa275c40 Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Wed, 7 Dec 2022 19:28:49 -0500 Subject: [PATCH 2/2] Fix regression test added in previous commit --- packages/react-reconciler/src/ReactFiberWorkLoop.js | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.js b/packages/react-reconciler/src/ReactFiberWorkLoop.js index 0c86cbb324020..d2e815ffde133 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.js @@ -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.