Skip to content

Commit b384dd1

Browse files
committed
Discard render phase updates if component throws
When aborting a render, we also need to throw out render phase updates. Remove the updates from the queues so they do not persist to the next render. We already did a single pass through the whole list of hooks, so we know that any pending updates must have been dispatched during the render phase. The ones that were dispatched before we started rendering were already transferred to the current hook's queue.
1 parent 6388040 commit b384dd1

File tree

4 files changed

+71
-9
lines changed

4 files changed

+71
-9
lines changed

packages/react-reconciler/src/ReactFiberBeginWork.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ import {
136136
calculateChangedBits,
137137
scheduleWorkOnParentPath,
138138
} from './ReactFiberNewContext';
139-
import {resetHooks, renderWithHooks, bailoutHooks} from './ReactFiberHooks';
139+
import {renderWithHooks, bailoutHooks} from './ReactFiberHooks';
140140
import {stopProfilerTimerIfRunning} from './ReactProfilerTimer';
141141
import {
142142
getMaskedContext,
@@ -1319,7 +1319,8 @@ function mountIndeterminateComponent(
13191319
workInProgress.tag = ClassComponent;
13201320

13211321
// Throw out any hooks that were used.
1322-
resetHooks();
1322+
workInProgress.memoizedState = null;
1323+
workInProgress.updateQueue = null;
13231324

13241325
// Push context providers early to prevent context stack mismatches.
13251326
// During mounting we don't know the child context yet as the instance doesn't exist.

packages/react-reconciler/src/ReactFiberHooks.js

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -504,14 +504,31 @@ export function bailoutHooks(
504504
}
505505
}
506506

507-
export function resetHooks(): void {
507+
export function resetHooksAfterThrow(): void {
508508
// We can assume the previous dispatcher is always this one, since we set it
509509
// at the beginning of the render phase and there's no re-entrancy.
510510
ReactCurrentDispatcher.current = ContextOnlyDispatcher;
511511

512-
// This is used to reset the state of this module when a component throws.
513-
// It's also called inside mountIndeterminateComponent if we determine the
514-
// component is a module-style component.
512+
if (didScheduleRenderPhaseUpdate) {
513+
const current = (currentlyRenderingFiber: any).alternate;
514+
if (current !== null) {
515+
// There were render phase updates. These are only valid for this render
516+
// pass, which we are now aborting. Remove the updates from the queues so
517+
// they do not persist to the next render. We already did a single pass
518+
// through the whole list of hooks, so we know that any pending updates
519+
// must have been dispatched during the render phase. The ones that were
520+
// dispatched before we started rendering were already transferred to the
521+
// current hook's queue.
522+
let hook: Hook | null = current.memoizedState;
523+
while (hook !== null) {
524+
const queue = hook.queue;
525+
if (queue !== null) {
526+
queue.pending = null;
527+
}
528+
hook = hook.next;
529+
}
530+
}
531+
}
515532

516533
renderExpirationTime = NoWork;
517534
currentlyRenderingFiber = (null: any);

packages/react-reconciler/src/ReactFiberWorkLoop.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ import {
140140
} from './ReactFiberCommitWork';
141141
import {enqueueUpdate} from './ReactUpdateQueue';
142142
import {resetContextDependencies} from './ReactFiberNewContext';
143-
import {resetHooks, ContextOnlyDispatcher} from './ReactFiberHooks';
143+
import {resetHooksAfterThrow, ContextOnlyDispatcher} from './ReactFiberHooks';
144144
import {createCapturedValue} from './ReactCapturedValue';
145145

146146
import {
@@ -1281,7 +1281,7 @@ function handleError(root, thrownValue) {
12811281
try {
12821282
// Reset module-level state that was set during the render phase.
12831283
resetContextDependencies();
1284-
resetHooks();
1284+
resetHooksAfterThrow();
12851285
resetCurrentDebugFiberInDEV();
12861286

12871287
if (workInProgress === null || workInProgress.return === null) {
@@ -2635,7 +2635,7 @@ if (__DEV__ && replayFailedUnitOfWorkWithInvokeGuardedCallback) {
26352635
// Keep this code in sync with handleError; any changes here must have
26362636
// corresponding changes there.
26372637
resetContextDependencies();
2638-
resetHooks();
2638+
resetHooksAfterThrow();
26392639
// Don't reset current debug fiber, since we're about to work on the
26402640
// same fiber again.
26412641

packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.internal.js

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -542,6 +542,50 @@ describe('ReactHooksWithNoopRenderer', () => {
542542
]);
543543
expect(ReactNoop.getChildren()).toEqual([span(22)]);
544544
});
545+
546+
it('discards render phase updates if something suspends', () => {
547+
const thenable = {then() {}};
548+
function Foo({signal}) {
549+
return (
550+
<Suspense fallback="Loading...">
551+
<Bar signal={signal} />
552+
</Suspense>
553+
);
554+
}
555+
556+
function Bar({signal: newSignal}) {
557+
let [counter, setCounter] = useState(0);
558+
let [signal, setSignal] = useState(true);
559+
560+
// Increment a counter every time the signal changes
561+
if (signal !== newSignal) {
562+
setCounter(c => c + 1);
563+
setSignal(newSignal);
564+
if (counter === 0) {
565+
// We're suspending during a render that includes render phase
566+
// updates. Those updates should not persist to the next render.
567+
Scheduler.unstable_yieldValue('Suspend!');
568+
throw thenable;
569+
}
570+
}
571+
572+
return <Text text={counter} />;
573+
}
574+
575+
const root = ReactNoop.createRoot();
576+
root.render(<Foo signal={true} />);
577+
578+
expect(Scheduler).toFlushAndYield([0]);
579+
expect(root).toMatchRenderedOutput(<span prop={0} />);
580+
581+
root.render(<Foo signal={false} />);
582+
expect(Scheduler).toFlushAndYield(['Suspend!']);
583+
expect(root).toMatchRenderedOutput(<span prop={0} />);
584+
585+
// Rendering again should suspend again.
586+
root.render(<Foo signal={false} />);
587+
expect(Scheduler).toFlushAndYield(['Suspend!']);
588+
});
545589
});
546590

547591
describe('useReducer', () => {

0 commit comments

Comments
 (0)