Skip to content

Commit 1bcf7d2

Browse files
committed
Bugfix: Render phase update leads to dropped work
Render phase updates should not affect the `fiber.expirationTime` field. We don't have to set anything on the fiber because we're going to process the render phase update immediately. We also shouldn't reset the `expirationTime` field in between render passes because it represents the remaining work left in the update queues. During the re-render, the updates that were skipped in the original pass are not processed again. I think my original motivation for using this field for render phase updates was so I didn't have to add another module level variable.
1 parent 2def7b3 commit 1bcf7d2

File tree

2 files changed

+46
-7
lines changed

2 files changed

+46
-7
lines changed

packages/react-reconciler/src/ReactFiberHooks.js

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,11 @@ let workInProgressHook: Hook | null = null;
230230
// finished evaluating this component. This is an optimization so we know
231231
// whether we need to clear render phase updates after a throw.
232232
let didScheduleRenderPhaseUpdate: boolean = false;
233+
// Where an update was scheduled only during the current render pass. This
234+
// gets reset after each attempt.
235+
// TODO: Maybe there's some way to consolidate this with
236+
// `didScheduleRenderPhaseUpdate`. Or with `numberOfReRenders`.
237+
let didScheduleRenderPhaseUpdateDuringThisPass: boolean = false;
233238

234239
const RE_RENDER_LIMIT = 25;
235240

@@ -455,13 +460,12 @@ export function renderWithHooks<Props, SecondArg>(
455460
let children = Component(props, secondArg);
456461

457462
// Check if there was a render phase update
458-
if (workInProgress.expirationTime === renderExpirationTime) {
463+
if (didScheduleRenderPhaseUpdateDuringThisPass) {
459464
// Keep rendering in a loop for as long as render phase updates continue to
460465
// be scheduled. Use a counter to prevent infinite loops.
461466
let numberOfReRenders: number = 0;
462467
do {
463-
workInProgress.expirationTime = NoWork;
464-
468+
didScheduleRenderPhaseUpdateDuringThisPass = false;
465469
invariant(
466470
numberOfReRenders < RE_RENDER_LIMIT,
467471
'Too many re-renders. React limits the number of renders to prevent ' +
@@ -491,7 +495,7 @@ export function renderWithHooks<Props, SecondArg>(
491495
: HooksDispatcherOnRerender;
492496

493497
children = Component(props, secondArg);
494-
} while (workInProgress.expirationTime === renderExpirationTime);
498+
} while (didScheduleRenderPhaseUpdateDuringThisPass);
495499
}
496500

497501
// We can assume the previous dispatcher is always this one, since we set it
@@ -564,6 +568,7 @@ export function resetHooksAfterThrow(): void {
564568
}
565569
hook = hook.next;
566570
}
571+
didScheduleRenderPhaseUpdate = false;
567572
}
568573

569574
renderExpirationTime = NoWork;
@@ -581,7 +586,7 @@ export function resetHooksAfterThrow(): void {
581586
isUpdatingOpaqueValueInRenderPhase = false;
582587
}
583588

584-
didScheduleRenderPhaseUpdate = false;
589+
didScheduleRenderPhaseUpdateDuringThisPass = false;
585590
}
586591

587592
function mountWorkInProgressHook(): Hook {
@@ -1726,9 +1731,8 @@ function dispatchAction<S, A>(
17261731
// This is a render phase update. Stash it in a lazily-created map of
17271732
// queue -> linked list of updates. After this render pass, we'll restart
17281733
// and apply the stashed updates on top of the work-in-progress hook.
1729-
didScheduleRenderPhaseUpdate = true;
1734+
didScheduleRenderPhaseUpdateDuringThisPass = didScheduleRenderPhaseUpdate = true;
17301735
update.expirationTime = renderExpirationTime;
1731-
currentlyRenderingFiber.expirationTime = renderExpirationTime;
17321736
} else {
17331737
if (
17341738
fiber.expirationTime === NoWork &&

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

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -750,6 +750,41 @@ describe('ReactHooksWithNoopRenderer', () => {
750750
expect(root).toMatchRenderedOutput(<span prop="B:0" />);
751751
});
752752

753+
it('regression: render phase updates cause lower pri work to be dropped', async () => {
754+
let setRow;
755+
function ScrollView() {
756+
const [row, _setRow] = useState(10);
757+
setRow = _setRow;
758+
759+
const [scrollDirection, setScrollDirection] = useState('Up');
760+
const [prevRow, setPrevRow] = useState(null);
761+
762+
if (prevRow !== row) {
763+
setScrollDirection(prevRow !== null && row > prevRow ? 'Down' : 'Up');
764+
setPrevRow(row);
765+
}
766+
767+
return <Text text={scrollDirection} />;
768+
}
769+
770+
const root = ReactNoop.createRoot();
771+
772+
await act(async () => {
773+
root.render(<ScrollView row={10} />);
774+
});
775+
expect(Scheduler).toHaveYielded(['Up']);
776+
expect(root).toMatchRenderedOutput(<span prop="Up" />);
777+
778+
await act(async () => {
779+
ReactNoop.discreteUpdates(() => {
780+
setRow(5);
781+
});
782+
setRow(20);
783+
});
784+
expect(Scheduler).toHaveYielded(['Up', 'Down']);
785+
expect(root).toMatchRenderedOutput(<span prop="Down" />);
786+
});
787+
753788
// TODO: This should probably warn
754789
it.experimental('calling startTransition inside render phase', async () => {
755790
let startTransition;

0 commit comments

Comments
 (0)