Skip to content

Commit 0c591ba

Browse files
committed
Only most recent transition is pending, per queue
When multiple transitions update the same queue, only the most recent one should be considered pending. Example: If I switch tabs multiple times, only the last tab I click should display a pending state (e.g. an inline spinner).
1 parent bf65faa commit 0c591ba

File tree

5 files changed

+518
-105
lines changed

5 files changed

+518
-105
lines changed

packages/react-reconciler/src/ReactFiberHooks.js

Lines changed: 32 additions & 104 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import type {Fiber} from './ReactFiber';
1616
import type {ExpirationTime} from './ReactFiberExpirationTime';
1717
import type {HookEffectTag} from './ReactHookEffectTags';
1818
import type {SuspenseConfig} from './ReactFiberSuspenseConfig';
19+
import type {TransitionInstance} from './ReactFiberTransition';
1920
import type {ReactPriorityLevel} from './SchedulerWithReactIntegration';
2021

2122
import ReactSharedInternals from 'shared/ReactSharedInternals';
@@ -51,11 +52,11 @@ import is from 'shared/objectIs';
5152
import {markWorkInProgressReceivedUpdate} from './ReactFiberBeginWork';
5253
import {requestCurrentSuspenseConfig} from './ReactFiberSuspenseConfig';
5354
import {
54-
UserBlockingPriority,
55-
NormalPriority,
56-
runWithPriority,
57-
getCurrentPriorityLevel,
58-
} from './SchedulerWithReactIntegration';
55+
startTransition,
56+
requestCurrentTransition,
57+
cancelPendingTransition,
58+
} from './ReactFiberTransition';
59+
import {getCurrentPriorityLevel} from './SchedulerWithReactIntegration';
5960

6061
const {ReactCurrentDispatcher, ReactCurrentBatchConfig} = ReactSharedInternals;
6162

@@ -114,14 +115,11 @@ type Update<S, A> = {|
114115
type UpdateQueue<S, A> = {|
115116
pending: Update<S, A> | null,
116117
dispatch: (A => mixed) | null,
118+
pendingTransition: TransitionInstance | null,
117119
lastRenderedReducer: ((S, A) => S) | null,
118120
lastRenderedState: S | null,
119121
|};
120122

121-
type TransitionInstance = {|
122-
pendingExpirationTime: ExpirationTime,
123-
|};
124-
125123
export type HookType =
126124
| 'useState'
127125
| 'useReducer'
@@ -646,6 +644,7 @@ function mountReducer<S, I, A>(
646644
const queue = (hook.queue = {
647645
pending: null,
648646
dispatch: null,
647+
pendingTransition: null,
649648
lastRenderedReducer: reducer,
650649
lastRenderedState: (initialState: any),
651650
});
@@ -853,6 +852,7 @@ function mountState<S>(
853852
const queue = (hook.queue = {
854853
pending: null,
855854
dispatch: null,
855+
pendingTransition: null,
856856
lastRenderedReducer: basicStateReducer,
857857
lastRenderedState: (initialState: any),
858858
});
@@ -1208,89 +1208,14 @@ function rerenderDeferredValue<T>(
12081208
return prevValue;
12091209
}
12101210

1211-
function startTransition(fiber, instance, config, callback) {
1212-
let resolvedConfig: SuspenseConfig | null =
1213-
config === undefined ? null : config;
1214-
1215-
// TODO: runWithPriority shouldn't be necessary here. React should manage its
1216-
// own concept of priority, and only consult Scheduler for updates that are
1217-
// scheduled from outside a React context.
1218-
const priorityLevel = getCurrentPriorityLevel();
1219-
runWithPriority(
1220-
priorityLevel < UserBlockingPriority ? UserBlockingPriority : priorityLevel,
1221-
() => {
1222-
const currentTime = requestCurrentTimeForUpdate();
1223-
const expirationTime = computeExpirationForFiber(
1224-
currentTime,
1225-
fiber,
1226-
null,
1227-
);
1228-
scheduleWork(fiber, expirationTime);
1229-
},
1230-
);
1231-
runWithPriority(
1232-
priorityLevel > NormalPriority ? NormalPriority : priorityLevel,
1233-
() => {
1234-
const currentTime = requestCurrentTimeForUpdate();
1235-
let expirationTime = computeExpirationForFiber(
1236-
currentTime,
1237-
fiber,
1238-
resolvedConfig,
1239-
);
1240-
// Set the expiration time at which the pending transition will finish.
1241-
// Because there's only a single transition per useTransition hook, we
1242-
// don't need a queue here; we can cheat by only tracking the most
1243-
// recently scheduled transition.
1244-
// TODO: This trick depends on transition expiration times being
1245-
// monotonically decreasing in priority, but since expiration times
1246-
// currently correspond to `timeoutMs`, that might not be true if
1247-
// `timeoutMs` changes to something smaller before the previous transition
1248-
// resolves. But this is a temporary edge case, since we're about to
1249-
// remove the correspondence between `timeoutMs` and the expiration time.
1250-
const oldPendingExpirationTime = instance.pendingExpirationTime;
1251-
while (
1252-
oldPendingExpirationTime !== NoWork &&
1253-
oldPendingExpirationTime <= expirationTime
1254-
) {
1255-
// Temporary hack to make pendingExpirationTime monotonically decreasing
1256-
if (resolvedConfig === null) {
1257-
resolvedConfig = {
1258-
timeoutMs: 5250,
1259-
};
1260-
} else {
1261-
resolvedConfig = {
1262-
timeoutMs: (resolvedConfig.timeoutMs | 0 || 5000) + 250,
1263-
busyDelayMs: resolvedConfig.busyDelayMs,
1264-
busyMinDurationMs: resolvedConfig.busyMinDurationMs,
1265-
};
1266-
}
1267-
expirationTime = computeExpirationForFiber(
1268-
currentTime,
1269-
fiber,
1270-
resolvedConfig,
1271-
);
1272-
}
1273-
instance.pendingExpirationTime = expirationTime;
1274-
1275-
scheduleWork(fiber, expirationTime);
1276-
const previousConfig = ReactCurrentBatchConfig.suspense;
1277-
ReactCurrentBatchConfig.suspense = resolvedConfig;
1278-
try {
1279-
callback();
1280-
} finally {
1281-
ReactCurrentBatchConfig.suspense = previousConfig;
1282-
}
1283-
},
1284-
);
1285-
}
1286-
12871211
function mountTransition(
12881212
config: SuspenseConfig | void | null,
12891213
): [(() => void) => void, boolean] {
12901214
const hook = mountWorkInProgressHook();
1291-
1215+
const fiber = ((currentlyRenderingFiber: any): Fiber);
12921216
const instance: TransitionInstance = {
12931217
pendingExpirationTime: NoWork,
1218+
fiber,
12941219
};
12951220
// TODO: Intentionally storing this on the queue field to avoid adding a new/
12961221
// one; `queue` should be a union.
@@ -1302,15 +1227,9 @@ function mountTransition(
13021227
// Then we don't have to recompute the callback whenever it changes. However,
13031228
// if we don't end up changing the API, we should at least optimize this
13041229
// to use the same hook instead of a separate hook just for the callback.
1305-
const start = mountCallback(
1306-
startTransition.bind(
1307-
null,
1308-
((currentlyRenderingFiber: any): Fiber),
1309-
instance,
1310-
config,
1311-
),
1312-
[config],
1313-
);
1230+
const start = mountCallback(startTransition.bind(null, instance, config), [
1231+
config,
1232+
]);
13141233

13151234
const resolvedExpirationTime = NoWork;
13161235
hook.memoizedState = {
@@ -1406,15 +1325,9 @@ function updateTransition(
14061325
resolvedExpirationTime: newResolvedExpirationTime,
14071326
};
14081327

1409-
const start = updateCallback(
1410-
startTransition.bind(
1411-
null,
1412-
((currentlyRenderingFiber: any): Fiber),
1413-
instance,
1414-
config,
1415-
),
1416-
[config],
1417-
);
1328+
const start = updateCallback(startTransition.bind(null, instance, config), [
1329+
config,
1330+
]);
14181331

14191332
return [start, newIsPending];
14201333
}
@@ -1436,6 +1349,7 @@ function dispatchAction<S, A>(
14361349

14371350
const currentTime = requestCurrentTimeForUpdate();
14381351
const suspenseConfig = requestCurrentSuspenseConfig();
1352+
const transition = requestCurrentTransition();
14391353
const expirationTime = computeExpirationForFiber(
14401354
currentTime,
14411355
fiber,
@@ -1466,6 +1380,19 @@ function dispatchAction<S, A>(
14661380
}
14671381
queue.pending = update;
14681382

1383+
if (transition !== null) {
1384+
const prevPendingTransition = queue.pendingTransition;
1385+
if (transition !== prevPendingTransition) {
1386+
queue.pendingTransition = transition;
1387+
if (prevPendingTransition !== null) {
1388+
// There's already a pending transition on this queue. The new
1389+
// transition supersedes the old one. Turn of the `isPending` state
1390+
// of the previous transition.
1391+
cancelPendingTransition(prevPendingTransition);
1392+
}
1393+
}
1394+
}
1395+
14691396
const alternate = fiber.alternate;
14701397
if (
14711398
fiber === currentlyRenderingFiber ||
@@ -1524,6 +1451,7 @@ function dispatchAction<S, A>(
15241451
warnIfNotCurrentlyActingUpdatesInDev(fiber);
15251452
}
15261453
}
1454+
15271455
scheduleWork(fiber, expirationTime);
15281456
}
15291457
}

packages/react-reconciler/src/ReactFiberSuspenseConfig.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
import ReactSharedInternals from 'shared/ReactSharedInternals';
1111

12+
// TODO: Remove React.unstable_withSuspenseConfig and move this to the renderer
1213
const {ReactCurrentBatchConfig} = ReactSharedInternals;
1314

1415
export type SuspenseConfig = {|
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
/**
2+
* Copyright (c) Facebook, Inc. and its affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
* @flow
8+
*/
9+
10+
import type {Fiber} from './ReactFiber';
11+
import type {ExpirationTime} from './ReactFiberExpirationTime';
12+
import type {SuspenseConfig} from './ReactFiberSuspenseConfig';
13+
14+
import ReactSharedInternals from 'shared/ReactSharedInternals';
15+
16+
import {
17+
UserBlockingPriority,
18+
NormalPriority,
19+
runWithPriority,
20+
getCurrentPriorityLevel,
21+
} from './SchedulerWithReactIntegration';
22+
import {
23+
scheduleUpdateOnFiber,
24+
computeExpirationForFiber,
25+
requestCurrentTimeForUpdate,
26+
} from './ReactFiberWorkLoop';
27+
import {NoWork} from './ReactFiberExpirationTime';
28+
29+
const {ReactCurrentBatchConfig} = ReactSharedInternals;
30+
31+
export type TransitionInstance = {|
32+
pendingExpirationTime: ExpirationTime,
33+
fiber: Fiber,
34+
|};
35+
36+
// Inside `startTransition`, this is the transition instance that corresponds to
37+
// the `useTransition` hook.
38+
let currentTransition: TransitionInstance | null = null;
39+
40+
// Inside `startTransition`, this is the expiration time of the update that
41+
// turns on `isPending`. We also use it to turn off the `isPending` of previous
42+
// transitions, if they exists.
43+
let userBlockingExpirationTime = NoWork;
44+
45+
export function requestCurrentTransition(): TransitionInstance | null {
46+
return currentTransition;
47+
}
48+
49+
export function startTransition(
50+
transitionInstance: TransitionInstance,
51+
config: SuspenseConfig | null | void,
52+
callback: () => void,
53+
) {
54+
const fiber = transitionInstance.fiber;
55+
56+
let resolvedConfig: SuspenseConfig | null =
57+
config === undefined ? null : config;
58+
59+
// TODO: runWithPriority shouldn't be necessary here. React should manage its
60+
// own concept of priority, and only consult Scheduler for updates that are
61+
// scheduled from outside a React context.
62+
const priorityLevel = getCurrentPriorityLevel();
63+
runWithPriority(
64+
priorityLevel < UserBlockingPriority ? UserBlockingPriority : priorityLevel,
65+
() => {
66+
const currentTime = requestCurrentTimeForUpdate();
67+
userBlockingExpirationTime = computeExpirationForFiber(
68+
currentTime,
69+
fiber,
70+
null,
71+
);
72+
scheduleUpdateOnFiber(fiber, userBlockingExpirationTime);
73+
},
74+
);
75+
runWithPriority(
76+
priorityLevel > NormalPriority ? NormalPriority : priorityLevel,
77+
() => {
78+
const currentTime = requestCurrentTimeForUpdate();
79+
let expirationTime = computeExpirationForFiber(
80+
currentTime,
81+
fiber,
82+
resolvedConfig,
83+
);
84+
// Set the expiration time at which the pending transition will finish.
85+
// Because there's only a single transition per useTransition hook, we
86+
// don't need a queue here; we can cheat by only tracking the most
87+
// recently scheduled transition.
88+
// TODO: This trick depends on transition expiration times being
89+
// monotonically decreasing in priority, but since expiration times
90+
// currently correspond to `timeoutMs`, that might not be true if
91+
// `timeoutMs` changes to something smaller before the previous transition
92+
// resolves. But this is a temporary edge case, since we're about to
93+
// remove the correspondence between `timeoutMs` and the expiration time.
94+
const oldPendingExpirationTime = transitionInstance.pendingExpirationTime;
95+
while (
96+
oldPendingExpirationTime !== NoWork &&
97+
oldPendingExpirationTime <= expirationTime
98+
) {
99+
// Temporary hack to make pendingExpirationTime monotonically decreasing
100+
if (resolvedConfig === null) {
101+
resolvedConfig = {
102+
timeoutMs: 5250,
103+
};
104+
} else {
105+
resolvedConfig = {
106+
timeoutMs: (resolvedConfig.timeoutMs | 0 || 5000) + 250,
107+
busyDelayMs: resolvedConfig.busyDelayMs,
108+
busyMinDurationMs: resolvedConfig.busyMinDurationMs,
109+
};
110+
}
111+
expirationTime = computeExpirationForFiber(
112+
currentTime,
113+
fiber,
114+
resolvedConfig,
115+
);
116+
}
117+
transitionInstance.pendingExpirationTime = expirationTime;
118+
119+
scheduleUpdateOnFiber(fiber, expirationTime);
120+
const previousConfig = ReactCurrentBatchConfig.suspense;
121+
const previousTransition = currentTransition;
122+
ReactCurrentBatchConfig.suspense = resolvedConfig;
123+
currentTransition = transitionInstance;
124+
try {
125+
callback();
126+
} finally {
127+
ReactCurrentBatchConfig.suspense = previousConfig;
128+
currentTransition = previousTransition;
129+
userBlockingExpirationTime = NoWork;
130+
}
131+
},
132+
);
133+
}
134+
135+
export function cancelPendingTransition(prevTransition: TransitionInstance) {
136+
// Turn off the `isPending` state of the previous transition, at the same
137+
// priority we use to turn on the `isPending` state of the current transition.
138+
prevTransition.pendingExpirationTime = NoWork;
139+
scheduleUpdateOnFiber(prevTransition.fiber, userBlockingExpirationTime);
140+
}

0 commit comments

Comments
 (0)