Skip to content

Commit ffddb4e

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 411f2dc commit ffddb4e

File tree

5 files changed

+521
-104
lines changed

5 files changed

+521
-104
lines changed

packages/react-reconciler/src/ReactFiberHooks.js

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

2223
import ReactSharedInternals from 'shared/ReactSharedInternals';
@@ -53,11 +54,11 @@ import is from 'shared/objectIs';
5354
import {markWorkInProgressReceivedUpdate} from './ReactFiberBeginWork';
5455
import {requestCurrentSuspenseConfig} from './ReactFiberSuspenseConfig';
5556
import {
56-
UserBlockingPriority,
57-
NormalPriority,
58-
runWithPriority,
59-
getCurrentPriorityLevel,
60-
} from './SchedulerWithReactIntegration';
57+
startTransition,
58+
requestCurrentTransition,
59+
cancelPendingTransition,
60+
} from './ReactFiberTransition';
61+
import {getCurrentPriorityLevel} from './SchedulerWithReactIntegration';
6162

6263
const {ReactCurrentDispatcher, ReactCurrentBatchConfig} = ReactSharedInternals;
6364

@@ -117,14 +118,11 @@ type Update<S, A> = {
117118
type UpdateQueue<S, A> = {
118119
last: Update<S, A> | null,
119120
dispatch: (A => mixed) | null,
121+
pendingTransition: TransitionInstance | null,
120122
lastRenderedReducer: ((S, A) => S) | null,
121123
lastRenderedState: S | null,
122124
};
123125

124-
type TransitionInstance = {|
125-
pendingExpirationTime: ExpirationTime,
126-
|};
127-
128126
export type HookType =
129127
| 'useState'
130128
| 'useReducer'
@@ -667,6 +665,7 @@ function mountReducer<S, I, A>(
667665
const queue = (hook.queue = {
668666
last: null,
669667
dispatch: null,
668+
pendingTransition: null,
670669
lastRenderedReducer: reducer,
671670
lastRenderedState: (initialState: any),
672671
});
@@ -839,6 +838,7 @@ function mountState<S>(
839838
const queue = (hook.queue = {
840839
last: null,
841840
dispatch: null,
841+
pendingTransition: null,
842842
lastRenderedReducer: basicStateReducer,
843843
lastRenderedState: (initialState: any),
844844
});
@@ -1176,89 +1176,14 @@ function updateDeferredValue<T>(
11761176
return prevValue;
11771177
}
11781178
1179-
function startTransition(fiber, instance, config, callback) {
1180-
let resolvedConfig: SuspenseConfig | null =
1181-
config === undefined ? null : config;
1182-
1183-
// TODO: runWithPriority shouldn't be necessary here. React should manage its
1184-
// own concept of priority, and only consult Scheduler for updates that are
1185-
// scheduled from outside a React context.
1186-
const priorityLevel = getCurrentPriorityLevel();
1187-
runWithPriority(
1188-
priorityLevel < UserBlockingPriority ? UserBlockingPriority : priorityLevel,
1189-
() => {
1190-
const currentTime = requestCurrentTimeForUpdate();
1191-
const expirationTime = computeExpirationForFiber(
1192-
currentTime,
1193-
fiber,
1194-
null,
1195-
);
1196-
scheduleWork(fiber, expirationTime);
1197-
},
1198-
);
1199-
runWithPriority(
1200-
priorityLevel > NormalPriority ? NormalPriority : priorityLevel,
1201-
() => {
1202-
const currentTime = requestCurrentTimeForUpdate();
1203-
let expirationTime = computeExpirationForFiber(
1204-
currentTime,
1205-
fiber,
1206-
resolvedConfig,
1207-
);
1208-
// Set the expiration time at which the pending transition will finish.
1209-
// Because there's only a single transition per useTransition hook, we
1210-
// don't need a queue here; we can cheat by only tracking the most
1211-
// recently scheduled transition.
1212-
// TODO: This trick depends on transition expiration times being
1213-
// monotonically decreasing in priority, but since expiration times
1214-
// currently correspond to `timeoutMs`, that might not be true if
1215-
// `timeoutMs` changes to something smaller before the previous transition
1216-
// resolves. But this is a temporary edge case, since we're about to
1217-
// remove the correspondence between `timeoutMs` and the expiration time.
1218-
const oldPendingExpirationTime = instance.pendingExpirationTime;
1219-
while (
1220-
oldPendingExpirationTime !== NoWork &&
1221-
oldPendingExpirationTime <= expirationTime
1222-
) {
1223-
// Temporary hack to make pendingExpirationTime monotonically decreasing
1224-
if (resolvedConfig === null) {
1225-
resolvedConfig = {
1226-
timeoutMs: 5250,
1227-
};
1228-
} else {
1229-
resolvedConfig = {
1230-
timeoutMs: (resolvedConfig.timeoutMs | 0 || 5000) + 250,
1231-
busyDelayMs: resolvedConfig.busyDelayMs,
1232-
busyMinDurationMs: resolvedConfig.busyMinDurationMs,
1233-
};
1234-
}
1235-
expirationTime = computeExpirationForFiber(
1236-
currentTime,
1237-
fiber,
1238-
resolvedConfig,
1239-
);
1240-
}
1241-
instance.pendingExpirationTime = expirationTime;
1242-
1243-
scheduleWork(fiber, expirationTime);
1244-
const previousConfig = ReactCurrentBatchConfig.suspense;
1245-
ReactCurrentBatchConfig.suspense = resolvedConfig;
1246-
try {
1247-
callback();
1248-
} finally {
1249-
ReactCurrentBatchConfig.suspense = previousConfig;
1250-
}
1251-
},
1252-
);
1253-
}
1254-
12551179
function mountTransition(
12561180
config: SuspenseConfig | void | null,
12571181
): [(() => void) => void, boolean] {
12581182
const hook = mountWorkInProgressHook();
1259-
1183+
const fiber = ((currentlyRenderingFiber: any): Fiber);
12601184
const instance: TransitionInstance = {
12611185
pendingExpirationTime: NoWork,
1186+
fiber,
12621187
};
12631188
// TODO: Intentionally storing this on the queue field to avoid adding a new/
12641189
// one; `queue` should be a union.
@@ -1270,15 +1195,9 @@ function mountTransition(
12701195
// Then we don't have to recompute the callback whenever it changes. However,
12711196
// if we don't end up changing the API, we should at least optimize this
12721197
// to use the same hook instead of a separate hook just for the callback.
1273-
const start = mountCallback(
1274-
startTransition.bind(
1275-
null,
1276-
((currentlyRenderingFiber: any): Fiber),
1277-
instance,
1278-
config,
1279-
),
1280-
[config],
1281-
);
1198+
const start = mountCallback(startTransition.bind(null, instance, config), [
1199+
config,
1200+
]);
12821201
12831202
const resolvedExpirationTime = NoWork;
12841203
hook.memoizedState = {
@@ -1374,15 +1293,9 @@ function updateTransition(
13741293
resolvedExpirationTime: newResolvedExpirationTime,
13751294
};
13761295
1377-
const start = updateCallback(
1378-
startTransition.bind(
1379-
null,
1380-
((currentlyRenderingFiber: any): Fiber),
1381-
instance,
1382-
config,
1383-
),
1384-
[config],
1385-
);
1296+
const start = updateCallback(startTransition.bind(null, instance, config), [
1297+
config,
1298+
]);
13861299
13871300
return [start, newIsPending];
13881301
}
@@ -1444,6 +1357,7 @@ function dispatchAction<S, A>(
14441357
} else {
14451358
const currentTime = requestCurrentTimeForUpdate();
14461359
const suspenseConfig = requestCurrentSuspenseConfig();
1360+
const transition = requestCurrentTransition();
14471361
const expirationTime = computeExpirationForFiber(
14481362
currentTime,
14491363
fiber,
@@ -1524,6 +1438,20 @@ function dispatchAction<S, A>(
15241438
warnIfNotCurrentlyActingUpdatesInDev(fiber);
15251439
}
15261440
}
1441+
1442+
if (transition !== null) {
1443+
const prevPendingTransition = queue.pendingTransition;
1444+
if (transition !== prevPendingTransition) {
1445+
queue.pendingTransition = transition;
1446+
if (prevPendingTransition !== null) {
1447+
// There's already a pending transition on this queue. The new
1448+
// transition supersedes the old one. Turn of the `isPending` state
1449+
// of the previous transition.
1450+
cancelPendingTransition(prevPendingTransition);
1451+
}
1452+
}
1453+
}
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)