Skip to content

Commit b4f4779

Browse files
committed
Mark range of updates that need to be rebased
To enforce that updates that are committed can never be un-committed, even during a rebase, we need to track which updates are part of the rebase. The current implementation doesn't do this properly. It has a hidden assumption that, when rebasing, the next `renderExpirationTime` will represent a later expiration time that the original one. That's usually true, but it's not *always* true: there could be another update at even higher priority. This requires two extra fields on the update queue. I really don't like that the update queue logic has gotten even more complicated. It's tempting to say that we should remove rebasing entirely, and that update queues must always be updated at the same priority. But I'm hesitant to jump to that conclusion — rebasing can be confusing in the edge cases, but it's also convenient. Enforcing single-priority queues would really hurt the ergonomics of useReducer, for example, where multiple values are centralized in a single queue. It especially hurts the ergonomics of classes, where there's only a single queue per class. So it's something to think about, but I don't think "no more rebasing" is an acceptable answer on its own.
1 parent f8adc01 commit b4f4779

File tree

2 files changed

+76
-11
lines changed

2 files changed

+76
-11
lines changed

packages/react-reconciler/src/ReactFiberHooks.js

Lines changed: 38 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -144,8 +144,12 @@ if (__DEV__) {
144144
export type Hook = {
145145
memoizedState: any,
146146

147+
// TODO: These fields are only used by the state and reducer hooks. Consider
148+
// moving them to a separate object.
147149
baseState: any,
148150
baseUpdate: Update<any, any> | null,
151+
rebaseEnd: Update<any, any> | null,
152+
rebaseTime: ExpirationTime,
149153
queue: UpdateQueue<any, any> | null,
150154

151155
next: Hook | null,
@@ -580,6 +584,8 @@ function mountWorkInProgressHook(): Hook {
580584
baseState: null,
581585
queue: null,
582586
baseUpdate: null,
587+
rebaseEnd: null,
588+
rebaseTime: NoWork,
583589
584590
next: null,
585591
};
@@ -621,6 +627,8 @@ function updateWorkInProgressHook(): Hook {
621627
baseState: currentHook.baseState,
622628
queue: currentHook.queue,
623629
baseUpdate: currentHook.baseUpdate,
630+
rebaseEnd: currentHook.rebaseEnd,
631+
rebaseTime: currentHook.rebaseTime,
624632
625633
next: null,
626634
};
@@ -735,8 +743,10 @@ function updateReducer<S, I, A>(
735743
// The last update in the entire queue
736744
const last = queue.last;
737745
// The last update that is part of the base state.
738-
const baseUpdate = hook.baseUpdate;
739746
const baseState = hook.baseState;
747+
const baseUpdate = hook.baseUpdate;
748+
const rebaseEnd = hook.rebaseEnd;
749+
const rebaseTime = hook.rebaseTime;
740750
741751
// Find the first unprocessed update.
742752
let first;
@@ -755,19 +765,35 @@ function updateReducer<S, I, A>(
755765
let newState = baseState;
756766
let newBaseState = null;
757767
let newBaseUpdate = null;
768+
let newRebaseTime = NoWork;
769+
let newRebaseEnd = null;
758770
let prevUpdate = baseUpdate;
759771
let update = first;
760-
let didSkip = false;
772+
773+
// Track whether the update is part of a rebase.
774+
// TODO: Should probably split this into two separate loops, instead of
775+
// using a boolean.
776+
let isRebasing = rebaseTime !== NoWork;
777+
761778
do {
779+
if (prevUpdate === rebaseEnd) {
780+
isRebasing = false;
781+
}
762782
const updateExpirationTime = update.expirationTime;
763-
if (updateExpirationTime < renderExpirationTime) {
783+
if (
784+
// Check if this update should be skipped
785+
updateExpirationTime < renderExpirationTime &&
786+
// If we're currently rebasing, don't skip this update if we already
787+
// committed it.
788+
(!isRebasing || updateExpirationTime < rebaseTime)
789+
) {
764790
// Priority is insufficient. Skip this update. If this is the first
765791
// skipped update, the previous update/state is the new base
766792
// update/state.
767-
if (!didSkip) {
768-
didSkip = true;
793+
if (newRebaseTime === NoWork) {
769794
newBaseUpdate = prevUpdate;
770795
newBaseState = newState;
796+
newRebaseTime = renderExpirationTime;
771797
}
772798
// Update the remaining priority in the queue.
773799
if (updateExpirationTime > remainingExpirationTime) {
@@ -787,7 +813,6 @@ function updateReducer<S, I, A>(
787813
updateExpirationTime,
788814
update.suspenseConfig,
789815
);
790-
791816
// Process this update.
792817
if (update.eagerReducer === reducer) {
793818
// If this update was processed eagerly, and its reducer matches the
@@ -802,9 +827,11 @@ function updateReducer<S, I, A>(
802827
update = update.next;
803828
} while (update !== null && update !== first);
804829

805-
if (!didSkip) {
806-
newBaseUpdate = prevUpdate;
830+
if (newRebaseTime === NoWork) {
807831
newBaseState = newState;
832+
newBaseUpdate = prevUpdate;
833+
} else {
834+
newRebaseEnd = prevUpdate;
808835
}
809836

810837
// Mark that the fiber performed work, but only if the new state is
@@ -814,8 +841,10 @@ function updateReducer<S, I, A>(
814841
}
815842

816843
hook.memoizedState = newState;
817-
hook.baseUpdate = newBaseUpdate;
818844
hook.baseState = newBaseState;
845+
hook.baseUpdate = newBaseUpdate;
846+
hook.rebaseEnd = newRebaseEnd;
847+
hook.rebaseTime = newRebaseTime;
819848

820849
queue.lastRenderedState = newState;
821850
}

packages/react-reconciler/src/ReactUpdateQueue.js

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,9 @@ export type UpdateQueue<State> = {
138138

139139
firstCapturedEffect: Update<State> | null,
140140
lastCapturedEffect: Update<State> | null,
141+
142+
rebaseTime: ExpirationTime,
143+
rebaseEnd: Update<State> | null,
141144
};
142145

143146
export const UpdateState = 0;
@@ -172,6 +175,8 @@ export function createUpdateQueue<State>(baseState: State): UpdateQueue<State> {
172175
lastEffect: null,
173176
firstCapturedEffect: null,
174177
lastCapturedEffect: null,
178+
rebaseTime: NoWork,
179+
rebaseEnd: null,
175180
};
176181
return queue;
177182
}
@@ -194,6 +199,9 @@ function cloneUpdateQueue<State>(
194199

195200
firstCapturedEffect: null,
196201
lastCapturedEffect: null,
202+
203+
rebaseTime: currentQueue.rebaseTime,
204+
rebaseEnd: currentQueue.rebaseEnd,
197205
};
198206
return queue;
199207
}
@@ -446,15 +454,36 @@ export function processUpdateQueue<State>(
446454
let newBaseState = queue.baseState;
447455
let newFirstUpdate = null;
448456
let newExpirationTime = NoWork;
457+
let newRebaseTime = NoWork;
458+
let newRebaseEnd = null;
459+
let prevUpdate = null;
449460

450461
// Iterate through the list of updates to compute the result.
451462
let update = queue.firstUpdate;
452463
let resultState = newBaseState;
464+
465+
// Track whether the update is part of a rebase.
466+
// TODO: Should probably split this into two separate loops, instead of
467+
// using a boolean.
468+
const rebaseTime = queue.rebaseTime;
469+
const rebaseEnd = queue.rebaseEnd;
470+
let isRebasing = rebaseTime !== NoWork;
471+
453472
while (update !== null) {
454473
const updateExpirationTime = update.expirationTime;
455-
if (updateExpirationTime < renderExpirationTime) {
474+
if (prevUpdate === rebaseEnd) {
475+
isRebasing = false;
476+
}
477+
if (
478+
// Check if this update should be skipped
479+
updateExpirationTime < renderExpirationTime &&
480+
// If we're currently rebasing, don't skip this update if we already
481+
// committed it.
482+
(!isRebasing || updateExpirationTime < rebaseTime)
483+
) {
456484
// This update does not have sufficient priority. Skip it.
457-
if (newFirstUpdate === null) {
485+
if (newRebaseTime === NoWork) {
486+
newRebaseTime = renderExpirationTime;
458487
// This is the first skipped update. It will be the first update in
459488
// the new list.
460489
newFirstUpdate = update;
@@ -501,13 +530,16 @@ export function processUpdateQueue<State>(
501530
}
502531
}
503532
// Continue to the next update.
533+
prevUpdate = update;
504534
update = update.next;
505535
}
506536

507537
// Separately, iterate though the list of captured updates.
508538
let newFirstCapturedUpdate = null;
509539
update = queue.firstCapturedUpdate;
510540
while (update !== null) {
541+
// TODO: Captured updates always have the current render expiration time.
542+
// Shouldn't need this priority check, because they will never be skipped.
511543
const updateExpirationTime = update.expirationTime;
512544
if (updateExpirationTime < renderExpirationTime) {
513545
// This update does not have sufficient priority. Skip it.
@@ -565,11 +597,15 @@ export function processUpdateQueue<State>(
565597
// We processed every update, without skipping. That means the new base
566598
// state is the same as the result state.
567599
newBaseState = resultState;
600+
} else {
601+
newRebaseEnd = prevUpdate;
568602
}
569603

570604
queue.baseState = newBaseState;
571605
queue.firstUpdate = newFirstUpdate;
572606
queue.firstCapturedUpdate = newFirstCapturedUpdate;
607+
queue.rebaseEnd = newRebaseEnd;
608+
queue.rebaseTime = newRebaseTime;
573609

574610
// Set the remaining expiration time to be whatever is remaining in the queue.
575611
// This should be fine because the only two other things that contribute to

0 commit comments

Comments
 (0)