Skip to content

Commit e7fd438

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 ffddb4e commit e7fd438

File tree

5 files changed

+474
-17
lines changed

5 files changed

+474
-17
lines changed

packages/react-reconciler/src/ReactFiberHooks.js

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ import warning from 'shared/warning';
5252
import getComponentName from 'shared/getComponentName';
5353
import is from 'shared/objectIs';
5454
import {markWorkInProgressReceivedUpdate} from './ReactFiberBeginWork';
55+
import {SuspendOnTask} from './ReactFiberThrow';
5556
import {requestCurrentSuspenseConfig} from './ReactFiberSuspenseConfig';
5657
import {
5758
startTransition,
@@ -761,7 +762,10 @@ function updateReducer<S, I, A>(
761762
let prevUpdate = baseUpdate;
762763
let update = first;
763764
let didSkip = false;
765+
let lastProcessedTransitionTime = NoWork;
766+
let lastSkippedTransitionTime = NoWork;
764767
do {
768+
const suspenseConfig = update.suspenseConfig;
765769
const updateExpirationTime = update.expirationTime;
766770
if (updateExpirationTime < renderExpirationTime) {
767771
// Priority is insufficient. Skip this update. If this is the first
@@ -777,6 +781,16 @@ function updateReducer<S, I, A>(
777781
remainingExpirationTime = updateExpirationTime;
778782
markUnprocessedUpdateTime(remainingExpirationTime);
779783
}
784+
785+
if (suspenseConfig !== null) {
786+
// This update is part of a transition
787+
if (
788+
lastSkippedTransitionTime === NoWork ||
789+
lastSkippedTransitionTime > updateExpirationTime
790+
) {
791+
lastSkippedTransitionTime = updateExpirationTime;
792+
}
793+
}
780794
} else {
781795
// This update does have sufficient priority.
782796
// Mark the event time of this update as relevant to this render pass.
@@ -785,10 +799,7 @@ function updateReducer<S, I, A>(
785799
// TODO: We should skip this update if it was already committed but currently
786800
// we have no way of detecting the difference between a committed and suspended
787801
// update here.
788-
markRenderEventTimeAndConfig(
789-
updateExpirationTime,
790-
update.suspenseConfig,
791-
);
802+
markRenderEventTimeAndConfig(updateExpirationTime, suspenseConfig);
792803
793804
// Process this update.
794805
if (update.eagerReducer === reducer) {
@@ -799,12 +810,32 @@ function updateReducer<S, I, A>(
799810
const action = update.action;
800811
newState = reducer(newState, action);
801812
}
813+
814+
if (suspenseConfig !== null) {
815+
// This update is part of a transition
816+
if (
817+
lastProcessedTransitionTime === NoWork ||
818+
lastProcessedTransitionTime > updateExpirationTime
819+
) {
820+
lastProcessedTransitionTime = updateExpirationTime;
821+
}
822+
}
802823
}
803824
804825
prevUpdate = update;
805826
update = update.next;
806827
} while (update !== null && update !== first);
807828
829+
if (
830+
lastProcessedTransitionTime !== NoWork &&
831+
lastSkippedTransitionTime !== NoWork
832+
) {
833+
// There are multiple updates scheduled on this queue, but only some of
834+
// them were processed. To avoid showing an intermediate state, abort
835+
// the current render and restart at a level that includes them all.
836+
throw new SuspendOnTask(lastSkippedTransitionTime);
837+
}
838+
808839
if (!didSkip) {
809840
newBaseUpdate = prevUpdate;
810841
newBaseState = newState;

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
@@ -125,6 +125,7 @@ import {
125125
throwException,
126126
createRootErrorUpdate,
127127
createClassErrorUpdate,
128+
SuspendOnTask,
128129
} from './ReactFiberThrow';
129130
import {
130131
commitBeforeMutationLifeCycles as commitBeforeMutationEffectOnFiber,
@@ -200,13 +201,14 @@ const LegacyUnbatchedContext = /* */ 0b001000;
200201
const RenderContext = /* */ 0b010000;
201202
const CommitContext = /* */ 0b100000;
202203

203-
type RootExitStatus = 0 | 1 | 2 | 3 | 4 | 5;
204+
type RootExitStatus = 0 | 1 | 2 | 3 | 4 | 5 | 6;
204205
const RootIncomplete = 0;
205206
const RootFatalErrored = 1;
206-
const RootErrored = 2;
207-
const RootSuspended = 3;
208-
const RootSuspendedWithDelay = 4;
209-
const RootCompleted = 5;
207+
const RootSuspendedOnTask = 2;
208+
const RootErrored = 3;
209+
const RootSuspended = 4;
210+
const RootSuspendedWithDelay = 5;
211+
const RootCompleted = 6;
210212

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

700-
if (workInProgress !== null) {
702+
if (workInProgressRootExitStatus === RootSuspendedOnTask) {
703+
// Can't finish rendering at this level. Exit early and restart at the
704+
// specified time.
705+
markRootSuspendedAtTime(root, expirationTime);
706+
root.nextKnownPendingLevel = workInProgressRootRestartTime;
707+
} else if (workInProgress !== null) {
701708
// There's still work left over. Exit without committing.
702709
stopInterruptedWorkLoopTimer();
703710
} else {
@@ -738,7 +745,8 @@ function finishConcurrentRender(
738745

739746
switch (exitStatus) {
740747
case RootIncomplete:
741-
case RootFatalErrored: {
748+
case RootFatalErrored:
749+
case RootSuspendedOnTask: {
742750
invariant(false, 'Root did not complete. This is a bug in React.');
743751
}
744752
// Flow knows about invariant, so it complains if I add a break
@@ -1034,7 +1042,12 @@ function performSyncWorkOnRoot(root) {
10341042
throw fatalError;
10351043
}
10361044

1037-
if (workInProgress !== null) {
1045+
if (workInProgressRootExitStatus === RootSuspendedOnTask) {
1046+
// Can't finish rendering at this level. Exit early and restart at the
1047+
// specified time.
1048+
markRootSuspendedAtTime(root, expirationTime);
1049+
root.nextKnownPendingLevel = workInProgressRootRestartTime;
1050+
} else if (workInProgress !== null) {
10381051
// This is a sync render, so we should have finished the whole tree.
10391052
invariant(
10401053
false,
@@ -1262,6 +1275,7 @@ function prepareFreshStack(root, expirationTime) {
12621275
workInProgressRootLatestSuspenseTimeout = Sync;
12631276
workInProgressRootCanSuspendUsingConfig = null;
12641277
workInProgressRootNextUnprocessedUpdateTime = NoWork;
1278+
workInProgressRootRestartTime = NoWork;
12651279
workInProgressRootHasPendingPing = false;
12661280

12671281
if (enableSchedulerTracing) {
@@ -1282,6 +1296,20 @@ function handleError(root, thrownValue) {
12821296
resetHooks();
12831297
resetCurrentDebugFiberInDEV();
12841298

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

packages/react-reconciler/src/ReactUpdateQueue.js

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ import {
105105
markRenderEventTimeAndConfig,
106106
markUnprocessedUpdateTime,
107107
} from './ReactFiberWorkLoop';
108+
import {SuspendOnTask} from './ReactFiberThrow';
108109

109110
import invariant from 'shared/invariant';
110111
import warningWithoutStack from 'shared/warningWithoutStack';
@@ -474,12 +475,15 @@ export function processUpdateQueue<State>(
474475
let newBaseState = queue.baseState;
475476
let newFirstUpdate = null;
476477
let newExpirationTime = NoWork;
478+
let lastProcessedTransitionTime = NoWork;
479+
let lastSkippedTransitionTime = NoWork;
477480

478481
// Iterate through the list of updates to compute the result.
479482
let update = queue.firstUpdate;
480483
let resultState = newBaseState;
481484
while (update !== null) {
482485
const updateExpirationTime = update.expirationTime;
486+
const suspenseConfig = update.suspenseConfig;
483487
if (updateExpirationTime < renderExpirationTime) {
484488
// This update does not have sufficient priority. Skip it.
485489
if (newFirstUpdate === null) {
@@ -495,6 +499,16 @@ export function processUpdateQueue<State>(
495499
if (newExpirationTime < updateExpirationTime) {
496500
newExpirationTime = updateExpirationTime;
497501
}
502+
503+
if (suspenseConfig !== null) {
504+
// This update is part of a transition
505+
if (
506+
lastSkippedTransitionTime === NoWork ||
507+
lastSkippedTransitionTime > updateExpirationTime
508+
) {
509+
lastSkippedTransitionTime = updateExpirationTime;
510+
}
511+
}
498512
} else {
499513
// This update does have sufficient priority.
500514

@@ -504,7 +518,7 @@ export function processUpdateQueue<State>(
504518
// TODO: We should skip this update if it was already committed but currently
505519
// we have no way of detecting the difference between a committed and suspended
506520
// update here.
507-
markRenderEventTimeAndConfig(updateExpirationTime, update.suspenseConfig);
521+
markRenderEventTimeAndConfig(updateExpirationTime, suspenseConfig);
508522

509523
// Process it and compute a new result.
510524
resultState = getStateFromUpdate(
@@ -527,11 +541,31 @@ export function processUpdateQueue<State>(
527541
queue.lastEffect = update;
528542
}
529543
}
544+
545+
if (suspenseConfig !== null) {
546+
// This update is part of a transition
547+
if (
548+
lastProcessedTransitionTime === NoWork ||
549+
lastProcessedTransitionTime > updateExpirationTime
550+
) {
551+
lastProcessedTransitionTime = updateExpirationTime;
552+
}
553+
}
530554
}
531555
// Continue to the next update.
532556
update = update.next;
533557
}
534558

559+
if (
560+
lastProcessedTransitionTime !== NoWork &&
561+
lastSkippedTransitionTime !== NoWork
562+
) {
563+
// There are multiple updates scheduled on this queue, but only some of
564+
// them were processed. To avoid showing an intermediate state, abort
565+
// the current render and restart at a level that includes them all.
566+
throw new SuspendOnTask(lastSkippedTransitionTime);
567+
}
568+
535569
// Separately, iterate though the list of captured updates.
536570
let newFirstCapturedUpdate = null;
537571
update = queue.firstCapturedUpdate;

0 commit comments

Comments
 (0)