@samitouri / QOS-React-2 / commits / 18282f881d

Fix: Update while suspended fails to interrupt (#26739)

This fixes a bug with `use` where if you update a component that's currently suspended, React will sometimes mistake it for a render phase update. This happens because we don't reset `currentlyRenderingFiber` until the suspended is unwound. And with `use`, that can happen asynchronously, most commonly when the work loop is suspended during a transition. The fix is to make sure `currentlyRenderingFiber` is only set when we're in the middle of rendering, which used to be true until `use` was introduced. More specifically this means clearing `currentlyRenderingFiber` when something throws and setting it again when we resume work. In many cases, this bug will fail "gracefully" because the update is still added to the queue; it's not dropped completely. It's also somewhat rare because it has to be the exact same component that's currently suspended. But it's still a bug. I wrote a regression test that shows a sync update failing to interrupt a suspended component.

Andrew Clark committed Apr 28, 2023 at 15:35 UTC 18282f881dae106ebf6240aa52c8c02fe7c8d6f2
3 files changed +48 -8
packages/react-reconciler/src/ReactFiberHooks.js
+6 -2
@@ -711,6 +711,9 @@ function renderWithHooksAgain<Props, SecondArg>(
711 //
712 // Keep rendering in a loop for as long as render phase updates continue to
713 // be scheduled. Use a counter to prevent infinite loops.
714 +
715 + currentlyRenderingFiber = workInProgress;
716 +
717 let numberOfReRenders: number = 0;
718 let children;
719 do {
@@ -826,13 +829,14 @@ export function resetHooksAfterThrow(): void {
829 //
830 // It should only reset things like the current dispatcher, to prevent hooks
831 // from being called outside of a component.
832 + currentlyRenderingFiber = (null: any);
833
834 // We can assume the previous dispatcher is always this one, since we set it
835 // at the beginning of the render phase and there's no re-entrance.
836 ReactCurrentDispatcher.current = ContextOnlyDispatcher;
837 }
838
835 -export function resetHooksOnUnwind(): void {
839 +export function resetHooksOnUnwind(workInProgress: Fiber): void {
840 if (didScheduleRenderPhaseUpdate) {
841 // There were render phase updates. These are only valid for this render
842 // phase, which we are now aborting. Remove the updates from the queues so
@@ -842,7 +846,7 @@ export function resetHooksOnUnwind(): void {
846 // Only reset the updates from the queue if it has a clone. If it does
847 // not have a clone, that means it wasn't processed, and the updates were
848 // scheduled before we entered the render phase.
845 - let hook: Hook | null = currentlyRenderingFiber.memoizedState;
849 + let hook: Hook | null = workInProgress.memoizedState;
850 while (hook !== null) {
851 const queue = hook.queue;
852 if (queue !== null) {
packages/react-reconciler/src/ReactFiberWorkLoop.js
+6 -6
@@ -1504,7 +1504,7 @@ function resetWorkInProgressStack() {
1504 } else {
1505 // Work-in-progress is in suspended state. Reset the work loop and unwind
1506 // both the suspended fiber and all its parents.
1507 - resetSuspendedWorkLoopOnUnwind();
1507 + resetSuspendedWorkLoopOnUnwind(workInProgress);
1508 interruptedWork = workInProgress;
1509 }
1510 while (interruptedWork !== null) {
@@ -1563,10 +1563,10 @@ function prepareFreshStack(root: FiberRoot, lanes: Lanes): Fiber {
1563 return rootWorkInProgress;
1564 }
1565
1566 -function resetSuspendedWorkLoopOnUnwind() {
1566 +function resetSuspendedWorkLoopOnUnwind(fiber: Fiber) {
1567 // Reset module-level state that was set during the render phase.
1568 resetContextDependencies();
1569 - resetHooksOnUnwind();
1569 + resetHooksOnUnwind(fiber);
1570 resetChildReconcilerOnUnwind();
1571 }
1572
@@ -2337,7 +2337,7 @@ function replaySuspendedUnitOfWork(unitOfWork: Fiber): void {
2337 // is to reuse uncached promises, but we happen to know that the only
2338 // promises that a host component might suspend on are definitely cached
2339 // because they are controlled by us. So don't bother.
2340 - resetHooksOnUnwind();
2340 + resetHooksOnUnwind(unitOfWork);
2341 // Fallthrough to the next branch.
2342 }
2343 default: {
@@ -2383,7 +2383,7 @@ function throwAndUnwindWorkLoop(unitOfWork: Fiber, thrownValue: mixed) {
2383 //
2384 // Return to the normal work loop. This will unwind the stack, and potentially
2385 // result in showing a fallback.
2386 - resetSuspendedWorkLoopOnUnwind();
2386 + resetSuspendedWorkLoopOnUnwind(unitOfWork);
2387
2388 const returnFiber = unitOfWork.return;
2389 if (returnFiber === null || workInProgressRoot === null) {
@@ -3744,7 +3744,7 @@ if (__DEV__ && replayFailedUnitOfWorkWithInvokeGuardedCallback) {
3744 // same fiber again.
3745
3746 // Unwind the failed stack frame
3747 - resetSuspendedWorkLoopOnUnwind();
3747 + resetSuspendedWorkLoopOnUnwind(unitOfWork);
3748 unwindInterruptedWork(current, unitOfWork, workInProgressRootRenderLanes);
3749
3750 // Restore the original properties of the fiber.
packages/react-reconciler/src/__tests__/ReactUse-test.js
+36
@@ -1580,4 +1580,40 @@ describe('ReactUse', () => {
1580 </>,
1581 );
1582 });
1583 +
1584 + test('regression test: updates while component is suspended should not be mistaken for render phase updates', async () => {
1585 + const getCachedAsyncText = cache(getAsyncText);
1586 +
1587 + let setState;
1588 + function App() {
1589 + const [state, _setState] = useState('A');
1590 + setState = _setState;
1591 + return <Text text={use(getCachedAsyncText(state))} />;
1592 + }
1593 +
1594 + // Initial render
1595 + const root = ReactNoop.createRoot();
1596 + await act(() => root.render(<App />));
1597 + assertLog(['Async text requested [A]']);
1598 + expect(root).toMatchRenderedOutput(null);
1599 + await act(() => resolveTextRequests('A'));
1600 + assertLog(['A']);
1601 + expect(root).toMatchRenderedOutput('A');
1602 +
1603 + // Update to B. This will suspend.
1604 + await act(() => startTransition(() => setState('B')));
1605 + assertLog(['Async text requested [B]']);
1606 + expect(root).toMatchRenderedOutput('A');
1607 +
1608 + // While B is suspended, update to C. This should immediately interrupt
1609 + // the render for B. In the regression, this update was mistakenly treated
1610 + // as a render phase update.
1611 + ReactNoop.flushSync(() => setState('C'));
1612 + assertLog(['Async text requested [C]']);
1613 +
1614 + // Finish rendering.
1615 + await act(() => resolveTextRequests('C'));
1616 + assertLog(['C']);
1617 + expect(root).toMatchRenderedOutput('C');
1618 + });
1619 });