Skip to content

Commit 19a5e82

Browse files
committed
Only show most recent transition, per queue
When multiple transitions update the same queue, only the most recent one should be allowed to finish. Do not display intermediate states. For example, if you click on multiple tabs in quick succession, we should not switch to any tab that isn't the last one you clicked.
1 parent 8df96dc commit 19a5e82

12 files changed

+491
-19
lines changed

packages/react-reconciler/src/ReactFiberHooks.js

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import type {SuspenseConfig} from './ReactFiberSuspenseConfig';
1919
import type {TransitionInstance} from './ReactFiberTransition';
2020
import type {ReactPriorityLevel} from './SchedulerWithReactIntegration';
2121

22+
import {preventIntermediateStates} from 'shared/ReactFeatureFlags';
2223
import ReactSharedInternals from 'shared/ReactSharedInternals';
2324

2425
import {NoWork, Sync} from './ReactFiberExpirationTime';
@@ -51,6 +52,7 @@ import warning from 'shared/warning';
5152
import getComponentName from 'shared/getComponentName';
5253
import is from 'shared/objectIs';
5354
import {markWorkInProgressReceivedUpdate} from './ReactFiberBeginWork';
55+
import {SuspendOnTask} from './ReactFiberThrow';
5456
import {requestCurrentSuspenseConfig} from './ReactFiberSuspenseConfig';
5557
import {
5658
startTransition,
@@ -744,7 +746,10 @@ function updateReducer<S, I, A>(
744746
let newBaseQueueFirst = null;
745747
let newBaseQueueLast = null;
746748
let update = first;
749+
let lastProcessedTransitionTime = NoWork;
750+
let lastSkippedTransitionTime = NoWork;
747751
do {
752+
const suspenseConfig = update.suspenseConfig;
748753
const updateExpirationTime = update.expirationTime;
749754
if (updateExpirationTime < renderExpirationTime) {
750755
// Priority is insufficient. Skip this update. If this is the first
@@ -769,6 +774,16 @@ function updateReducer<S, I, A>(
769774
currentlyRenderingFiber.expirationTime = updateExpirationTime;
770775
markUnprocessedUpdateTime(updateExpirationTime);
771776
}
777+
778+
if (suspenseConfig !== null) {
779+
// This update is part of a transition
780+
if (
781+
lastSkippedTransitionTime === NoWork ||
782+
lastSkippedTransitionTime > updateExpirationTime
783+
) {
784+
lastSkippedTransitionTime = updateExpirationTime;
785+
}
786+
}
772787
} else {
773788
// This update does have sufficient priority.
774789
@@ -790,10 +805,7 @@ function updateReducer<S, I, A>(
790805
// TODO: We should skip this update if it was already committed but currently
791806
// we have no way of detecting the difference between a committed and suspended
792807
// update here.
793-
markRenderEventTimeAndConfig(
794-
updateExpirationTime,
795-
update.suspenseConfig,
796-
);
808+
markRenderEventTimeAndConfig(updateExpirationTime, suspenseConfig);
797809
798810
// Process this update.
799811
if (update.eagerReducer === reducer) {
@@ -804,10 +816,31 @@ function updateReducer<S, I, A>(
804816
const action = update.action;
805817
newState = reducer(newState, action);
806818
}
819+
820+
if (suspenseConfig !== null) {
821+
// This update is part of a transition
822+
if (
823+
lastProcessedTransitionTime === NoWork ||
824+
lastProcessedTransitionTime > updateExpirationTime
825+
) {
826+
lastProcessedTransitionTime = updateExpirationTime;
827+
}
828+
}
807829
}
808830
update = update.next;
809831
} while (update !== null && update !== first);
810832
833+
if (
834+
preventIntermediateStates &&
835+
lastProcessedTransitionTime !== NoWork &&
836+
lastSkippedTransitionTime !== NoWork
837+
) {
838+
// There are multiple updates scheduled on this queue, but only some of
839+
// them were processed. To avoid showing an intermediate state, abort
840+
// the current render and restart at a level that includes them all.
841+
throw new SuspendOnTask(lastSkippedTransitionTime);
842+
}
843+
811844
if (newBaseQueueLast === null) {
812845
newBaseState = newState;
813846
} else {

packages/react-reconciler/src/ReactFiberThrow.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,12 @@ import {Sync} from './ReactFiberExpirationTime';
6262

6363
const PossiblyWeakMap = typeof WeakMap === 'function' ? WeakMap : Map;
6464

65+
// Throw an object with this type to abort the current render and restart at
66+
// a different level.
67+
export function SuspendOnTask(expirationTime: ExpirationTime) {
68+
this.retryTime = expirationTime;
69+
}
70+
6571
function createRootErrorUpdate(
6672
fiber: Fiber,
6773
errorInfo: CapturedValue<mixed>,

packages/react-reconciler/src/ReactFiberWorkLoop.js

Lines changed: 42 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ import {
127127
throwException,
128128
createRootErrorUpdate,
129129
createClassErrorUpdate,
130+
SuspendOnTask,
130131
} from './ReactFiberThrow';
131132
import {
132133
commitBeforeMutationLifeCycles as commitBeforeMutationEffectOnFiber,
@@ -202,13 +203,14 @@ const LegacyUnbatchedContext = /* */ 0b001000;
202203
const RenderContext = /* */ 0b010000;
203204
const CommitContext = /* */ 0b100000;
204205

205-
type RootExitStatus = 0 | 1 | 2 | 3 | 4 | 5;
206+
type RootExitStatus = 0 | 1 | 2 | 3 | 4 | 5 | 6;
206207
const RootIncomplete = 0;
207208
const RootFatalErrored = 1;
208-
const RootErrored = 2;
209-
const RootSuspended = 3;
210-
const RootSuspendedWithDelay = 4;
211-
const RootCompleted = 5;
209+
const RootSuspendedOnTask = 2;
210+
const RootErrored = 3;
211+
const RootSuspended = 4;
212+
const RootSuspendedWithDelay = 5;
213+
const RootCompleted = 6;
212214

213215
export type Thenable = {
214216
then(resolve: () => mixed, reject?: () => mixed): Thenable | void,
@@ -239,7 +241,7 @@ let workInProgressRootCanSuspendUsingConfig: null | SuspenseConfig = null;
239241
// The work left over by components that were visited during this render. Only
240242
// includes unprocessed updates, not work in bailed out children.
241243
let workInProgressRootNextUnprocessedUpdateTime: ExpirationTime = NoWork;
242-
244+
let workInProgressRootRestartTime: ExpirationTime = NoWork;
243245
// If we're pinged while rendering we don't always restart immediately.
244246
// This flag determines if it might be worthwhile to restart if an opportunity
245247
// happens latere.
@@ -709,7 +711,12 @@ function performConcurrentWorkOnRoot(root, didTimeout) {
709711
throw fatalError;
710712
}
711713

712-
if (workInProgress !== null) {
714+
if (workInProgressRootExitStatus === RootSuspendedOnTask) {
715+
// Can't finish rendering at this level. Exit early and restart at the
716+
// specified time.
717+
markRootSuspendedAtTime(root, expirationTime);
718+
root.nextKnownPendingLevel = workInProgressRootRestartTime;
719+
} else if (workInProgress !== null) {
713720
// There's still work left over. Exit without committing.
714721
stopInterruptedWorkLoopTimer();
715722
} else {
@@ -750,7 +757,8 @@ function finishConcurrentRender(
750757

751758
switch (exitStatus) {
752759
case RootIncomplete:
753-
case RootFatalErrored: {
760+
case RootFatalErrored:
761+
case RootSuspendedOnTask: {
754762
invariant(false, 'Root did not complete. This is a bug in React.');
755763
}
756764
// Flow knows about invariant, so it complains if I add a break
@@ -1037,7 +1045,12 @@ function performSyncWorkOnRoot(root) {
10371045
throw fatalError;
10381046
}
10391047

1040-
if (workInProgress !== null) {
1048+
if (workInProgressRootExitStatus === RootSuspendedOnTask) {
1049+
// Can't finish rendering at this level. Exit early and restart at the
1050+
// specified time.
1051+
markRootSuspendedAtTime(root, expirationTime);
1052+
root.nextKnownPendingLevel = workInProgressRootRestartTime;
1053+
} else if (workInProgress !== null) {
10411054
// This is a sync render, so we should have finished the whole tree.
10421055
invariant(
10431056
false,
@@ -1265,6 +1278,7 @@ function prepareFreshStack(root, expirationTime) {
12651278
workInProgressRootLatestSuspenseTimeout = Sync;
12661279
workInProgressRootCanSuspendUsingConfig = null;
12671280
workInProgressRootNextUnprocessedUpdateTime = NoWork;
1281+
workInProgressRootRestartTime = NoWork;
12681282
workInProgressRootHasPendingPing = false;
12691283

12701284
if (enableSchedulerTracing) {
@@ -1285,6 +1299,20 @@ function handleError(root, thrownValue) {
12851299
resetHooks();
12861300
resetCurrentDebugFiberInDEV();
12871301

1302+
// Check if this is a SuspendOnTask exception. This is the one type of
1303+
// exception that is allowed to happen at the root.
1304+
// TODO: I think instanceof is OK here? A brand check seems unnecessary
1305+
// since this is always thrown by the renderer and not across realms
1306+
// or packages.
1307+
if (thrownValue instanceof SuspendOnTask) {
1308+
// Can't finish rendering at this level. Exit early and restart at
1309+
// the specified time.
1310+
workInProgressRootExitStatus = RootSuspendedOnTask;
1311+
workInProgressRootRestartTime = thrownValue.retryTime;
1312+
workInProgress = null;
1313+
return;
1314+
}
1315+
12881316
if (workInProgress === null || workInProgress.return === null) {
12891317
// Expected to be working on a non-root fiber. This is a fatal error
12901318
// because there's no ancestor that can handle it; the root is
@@ -2624,15 +2652,17 @@ if (__DEV__ && replayFailedUnitOfWorkWithInvokeGuardedCallback) {
26242652
try {
26252653
return originalBeginWork(current, unitOfWork, expirationTime);
26262654
} catch (originalError) {
2655+
// Filter out special exception types
26272656
if (
26282657
originalError !== null &&
26292658
typeof originalError === 'object' &&
2630-
typeof originalError.then === 'function'
2659+
// Promise
2660+
(typeof originalError.then === 'function' ||
2661+
// SuspendOnTask exception
2662+
originalError instanceof SuspendOnTask)
26312663
) {
2632-
// Don't replay promises. Treat everything else like an error.
26332664
throw originalError;
26342665
}
2635-
26362666
// Keep this code in sync with handleError; any changes here must have
26372667
// corresponding changes there.
26382668
resetContextDependencies();

packages/react-reconciler/src/ReactUpdateQueue.js

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -97,13 +97,17 @@ import {
9797
} from './ReactFiberNewContext';
9898
import {Callback, ShouldCapture, DidCapture} from 'shared/ReactSideEffectTags';
9999

100-
import {debugRenderPhaseSideEffectsForStrictMode} from 'shared/ReactFeatureFlags';
100+
import {
101+
debugRenderPhaseSideEffectsForStrictMode,
102+
preventIntermediateStates,
103+
} from 'shared/ReactFeatureFlags';
101104

102105
import {StrictMode} from './ReactTypeOfMode';
103106
import {
104107
markRenderEventTimeAndConfig,
105108
markUnprocessedUpdateTime,
106109
} from './ReactFiberWorkLoop';
110+
import {SuspendOnTask} from './ReactFiberThrow';
107111

108112
import invariant from 'shared/invariant';
109113
import warningWithoutStack from 'shared/warningWithoutStack';
@@ -414,15 +418,18 @@ export function processUpdateQueue<State>(
414418

415419
if (first !== null) {
416420
let update = first;
421+
let lastProcessedTransitionTime = NoWork;
422+
let lastSkippedTransitionTime = NoWork;
417423
do {
418424
const updateExpirationTime = update.expirationTime;
425+
const suspenseConfig = update.suspenseConfig;
419426
if (updateExpirationTime < renderExpirationTime) {
420427
// Priority is insufficient. Skip this update. If this is the first
421428
// skipped update, the previous update/state is the new base
422429
// update/state.
423430
const clone: Update<State> = {
424-
expirationTime: update.expirationTime,
425-
suspenseConfig: update.suspenseConfig,
431+
expirationTime: updateExpirationTime,
432+
suspenseConfig,
426433

427434
tag: update.tag,
428435
payload: update.payload,
@@ -440,6 +447,16 @@ export function processUpdateQueue<State>(
440447
if (updateExpirationTime > newExpirationTime) {
441448
newExpirationTime = updateExpirationTime;
442449
}
450+
451+
if (suspenseConfig !== null) {
452+
// This update is part of a transition
453+
if (
454+
lastSkippedTransitionTime === NoWork ||
455+
lastSkippedTransitionTime > updateExpirationTime
456+
) {
457+
lastSkippedTransitionTime = updateExpirationTime;
458+
}
459+
}
443460
} else {
444461
// This update does have sufficient priority.
445462

@@ -488,6 +505,17 @@ export function processUpdateQueue<State>(
488505
}
489506
}
490507
}
508+
509+
if (suspenseConfig !== null) {
510+
// This update is part of a transition
511+
if (
512+
lastProcessedTransitionTime === NoWork ||
513+
lastProcessedTransitionTime > updateExpirationTime
514+
) {
515+
lastProcessedTransitionTime = updateExpirationTime;
516+
}
517+
}
518+
491519
update = update.next;
492520
if (update === null || update === first) {
493521
pendingQueue = queue.shared.pending;
@@ -503,6 +531,17 @@ export function processUpdateQueue<State>(
503531
}
504532
}
505533
} while (true);
534+
535+
if (
536+
preventIntermediateStates &&
537+
lastProcessedTransitionTime !== NoWork &&
538+
lastSkippedTransitionTime !== NoWork
539+
) {
540+
// There are multiple updates scheduled on this queue, but only some of
541+
// them were processed. To avoid showing an intermediate state, abort
542+
// the current render and restart at a level that includes them all.
543+
throw new SuspendOnTask(lastSkippedTransitionTime);
544+
}
506545
}
507546

508547
if (newBaseQueueLast === null) {

0 commit comments

Comments
 (0)