Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 18282f8

Browse files
authored
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.
1 parent 540bab0 commit 18282f8

File tree

3 files changed

+48
-8
lines changed

3 files changed

+48
-8
lines changed

packages/react-reconciler/src/ReactFiberHooks.js

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -711,6 +711,9 @@ function renderWithHooksAgain<Props, SecondArg>(
711711
//
712712
// Keep rendering in a loop for as long as render phase updates continue to
713713
// be scheduled. Use a counter to prevent infinite loops.
714+
715+
currentlyRenderingFiber = workInProgress;
716+
714717
let numberOfReRenders: number = 0;
715718
let children;
716719
do {
@@ -826,13 +829,14 @@ export function resetHooksAfterThrow(): void {
826829
//
827830
// It should only reset things like the current dispatcher, to prevent hooks
828831
// from being called outside of a component.
832+
currentlyRenderingFiber = (null: any);
829833

830834
// We can assume the previous dispatcher is always this one, since we set it
831835
// at the beginning of the render phase and there's no re-entrance.
832836
ReactCurrentDispatcher.current = ContextOnlyDispatcher;
833837
}
834838

835-
export function resetHooksOnUnwind(): void {
839+
export function resetHooksOnUnwind(workInProgress: Fiber): void {
836840
if (didScheduleRenderPhaseUpdate) {
837841
// There were render phase updates. These are only valid for this render
838842
// phase, which we are now aborting. Remove the updates from the queues so
@@ -842,7 +846,7 @@ export function resetHooksOnUnwind(): void {
842846
// Only reset the updates from the queue if it has a clone. If it does
843847
// not have a clone, that means it wasn't processed, and the updates were
844848
// scheduled before we entered the render phase.
845-
let hook: Hook | null = currentlyRenderingFiber.memoizedState;
849+
let hook: Hook | null = workInProgress.memoizedState;
846850
while (hook !== null) {
847851
const queue = hook.queue;
848852
if (queue !== null) {

packages/react-reconciler/src/ReactFiberWorkLoop.js

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1504,7 +1504,7 @@ function resetWorkInProgressStack() {
15041504
} else {
15051505
// Work-in-progress is in suspended state. Reset the work loop and unwind
15061506
// both the suspended fiber and all its parents.
1507-
resetSuspendedWorkLoopOnUnwind();
1507+
resetSuspendedWorkLoopOnUnwind(workInProgress);
15081508
interruptedWork = workInProgress;
15091509
}
15101510
while (interruptedWork !== null) {
@@ -1563,10 +1563,10 @@ function prepareFreshStack(root: FiberRoot, lanes: Lanes): Fiber {
15631563
return rootWorkInProgress;
15641564
}
15651565

1566-
function resetSuspendedWorkLoopOnUnwind() {
1566+
function resetSuspendedWorkLoopOnUnwind(fiber: Fiber) {
15671567
// Reset module-level state that was set during the render phase.
15681568
resetContextDependencies();
1569-
resetHooksOnUnwind();
1569+
resetHooksOnUnwind(fiber);
15701570
resetChildReconcilerOnUnwind();
15711571
}
15721572

@@ -2337,7 +2337,7 @@ function replaySuspendedUnitOfWork(unitOfWork: Fiber): void {
23372337
// is to reuse uncached promises, but we happen to know that the only
23382338
// promises that a host component might suspend on are definitely cached
23392339
// because they are controlled by us. So don't bother.
2340-
resetHooksOnUnwind();
2340+
resetHooksOnUnwind(unitOfWork);
23412341
// Fallthrough to the next branch.
23422342
}
23432343
default: {
@@ -2383,7 +2383,7 @@ function throwAndUnwindWorkLoop(unitOfWork: Fiber, thrownValue: mixed) {
23832383
//
23842384
// Return to the normal work loop. This will unwind the stack, and potentially
23852385
// result in showing a fallback.
2386-
resetSuspendedWorkLoopOnUnwind();
2386+
resetSuspendedWorkLoopOnUnwind(unitOfWork);
23872387

23882388
const returnFiber = unitOfWork.return;
23892389
if (returnFiber === null || workInProgressRoot === null) {
@@ -3744,7 +3744,7 @@ if (__DEV__ && replayFailedUnitOfWorkWithInvokeGuardedCallback) {
37443744
// same fiber again.
37453745

37463746
// Unwind the failed stack frame
3747-
resetSuspendedWorkLoopOnUnwind();
3747+
resetSuspendedWorkLoopOnUnwind(unitOfWork);
37483748
unwindInterruptedWork(current, unitOfWork, workInProgressRootRenderLanes);
37493749

37503750
// Restore the original properties of the fiber.

packages/react-reconciler/src/__tests__/ReactUse-test.js

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1580,4 +1580,40 @@ describe('ReactUse', () => {
15801580
</>,
15811581
);
15821582
});
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+
});
15831619
});

0 commit comments

Comments
 (0)