Skip to content

Commit 2510ef2

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 cd002c6 commit 2510ef2

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';
@@ -50,6 +51,7 @@ import invariant from 'shared/invariant';
5051
import getComponentName from 'shared/getComponentName';
5152
import is from 'shared/objectIs';
5253
import {markWorkInProgressReceivedUpdate} from './ReactFiberBeginWork';
54+
import {SuspendOnTask} from './ReactFiberThrow';
5355
import {requestCurrentSuspenseConfig} from './ReactFiberSuspenseConfig';
5456
import {
5557
startTransition,
@@ -700,7 +702,10 @@ function updateReducer<S, I, A>(
700702
let newBaseQueueFirst = null;
701703
let newBaseQueueLast = null;
702704
let update = first;
705+
let lastProcessedTransitionTime = NoWork;
706+
let lastSkippedTransitionTime = NoWork;
703707
do {
708+
const suspenseConfig = update.suspenseConfig;
704709
const updateExpirationTime = update.expirationTime;
705710
if (updateExpirationTime < renderExpirationTime) {
706711
// Priority is insufficient. Skip this update. If this is the first
@@ -725,6 +730,16 @@ function updateReducer<S, I, A>(
725730
currentlyRenderingFiber.expirationTime = updateExpirationTime;
726731
markUnprocessedUpdateTime(updateExpirationTime);
727732
}
733+
734+
if (suspenseConfig !== null) {
735+
// This update is part of a transition
736+
if (
737+
lastSkippedTransitionTime === NoWork ||
738+
lastSkippedTransitionTime > updateExpirationTime
739+
) {
740+
lastSkippedTransitionTime = updateExpirationTime;
741+
}
742+
}
728743
} else {
729744
// This update does have sufficient priority.
730745

@@ -746,10 +761,7 @@ function updateReducer<S, I, A>(
746761
// TODO: We should skip this update if it was already committed but currently
747762
// we have no way of detecting the difference between a committed and suspended
748763
// update here.
749-
markRenderEventTimeAndConfig(
750-
updateExpirationTime,
751-
update.suspenseConfig,
752-
);
764+
markRenderEventTimeAndConfig(updateExpirationTime, suspenseConfig);
753765

754766
// Process this update.
755767
if (update.eagerReducer === reducer) {
@@ -760,10 +772,31 @@ function updateReducer<S, I, A>(
760772
const action = update.action;
761773
newState = reducer(newState, action);
762774
}
775+
776+
if (suspenseConfig !== null) {
777+
// This update is part of a transition
778+
if (
779+
lastProcessedTransitionTime === NoWork ||
780+
lastProcessedTransitionTime > updateExpirationTime
781+
) {
782+
lastProcessedTransitionTime = updateExpirationTime;
783+
}
784+
}
763785
}
764786
update = update.next;
765787
} while (update !== null && update !== first);
766788

789+
if (
790+
preventIntermediateStates &&
791+
lastProcessedTransitionTime !== NoWork &&
792+
lastSkippedTransitionTime !== NoWork
793+
) {
794+
// There are multiple updates scheduled on this queue, but only some of
795+
// them were processed. To avoid showing an intermediate state, abort
796+
// the current render and restart at a level that includes them all.
797+
throw new SuspendOnTask(lastSkippedTransitionTime);
798+
}
799+
767800
if (newBaseQueueLast === null) {
768801
newBaseState = newState;
769802
} else {

packages/react-reconciler/src/ReactFiberThrow.js

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

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

64+
// Throw an object with this type to abort the current render and restart at
65+
// a different level.
66+
export function SuspendOnTask(expirationTime: ExpirationTime) {
67+
this.retryTime = expirationTime;
68+
}
69+
6470
function createRootErrorUpdate(
6571
fiber: Fiber,
6672
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,
@@ -201,13 +202,14 @@ const LegacyUnbatchedContext = /* */ 0b001000;
201202
const RenderContext = /* */ 0b010000;
202203
const CommitContext = /* */ 0b100000;
203204

204-
type RootExitStatus = 0 | 1 | 2 | 3 | 4 | 5;
205+
type RootExitStatus = 0 | 1 | 2 | 3 | 4 | 5 | 6;
205206
const RootIncomplete = 0;
206207
const RootFatalErrored = 1;
207-
const RootErrored = 2;
208-
const RootSuspended = 3;
209-
const RootSuspendedWithDelay = 4;
210-
const RootCompleted = 5;
208+
const RootSuspendedOnTask = 2;
209+
const RootErrored = 3;
210+
const RootSuspended = 4;
211+
const RootSuspendedWithDelay = 5;
212+
const RootCompleted = 6;
211213

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

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

750757
switch (exitStatus) {
751758
case RootIncomplete:
752-
case RootFatalErrored: {
759+
case RootFatalErrored:
760+
case RootSuspendedOnTask: {
753761
invariant(false, 'Root did not complete. This is a bug in React.');
754762
}
755763
// Flow knows about invariant, so it complains if I add a break
@@ -1036,7 +1044,12 @@ function performSyncWorkOnRoot(root) {
10361044
throw fatalError;
10371045
}
10381046

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

12691283
if (enableSchedulerTracing) {
@@ -1284,6 +1298,20 @@ function handleError(root, thrownValue) {
12841298
resetHooksAfterThrow();
12851299
resetCurrentDebugFiberInDEV();
12861300

1301+
// Check if this is a SuspendOnTask exception. This is the one type of
1302+
// exception that is allowed to happen at the root.
1303+
// TODO: I think instanceof is OK here? A brand check seems unnecessary
1304+
// since this is always thrown by the renderer and not across realms
1305+
// or packages.
1306+
if (thrownValue instanceof SuspendOnTask) {
1307+
// Can't finish rendering at this level. Exit early and restart at
1308+
// the specified time.
1309+
workInProgressRootExitStatus = RootSuspendedOnTask;
1310+
workInProgressRootRestartTime = thrownValue.retryTime;
1311+
workInProgress = null;
1312+
return;
1313+
}
1314+
12871315
if (workInProgress === null || workInProgress.return === null) {
12881316
// Expected to be working on a non-root fiber. This is a fatal error
12891317
// 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 {getCurrentPriorityLevel} from './SchedulerWithReactIntegration';
@@ -413,15 +417,18 @@ export function processUpdateQueue<State>(
413417

414418
if (first !== null) {
415419
let update = first;
420+
let lastProcessedTransitionTime = NoWork;
421+
let lastSkippedTransitionTime = NoWork;
416422
do {
417423
const updateExpirationTime = update.expirationTime;
424+
const suspenseConfig = update.suspenseConfig;
418425
if (updateExpirationTime < renderExpirationTime) {
419426
// Priority is insufficient. Skip this update. If this is the first
420427
// skipped update, the previous update/state is the new base
421428
// update/state.
422429
const clone: Update<State> = {
423-
expirationTime: update.expirationTime,
424-
suspenseConfig: update.suspenseConfig,
430+
expirationTime: updateExpirationTime,
431+
suspenseConfig,
425432

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

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

507546
if (newBaseQueueLast === null) {

0 commit comments

Comments
 (0)