Skip to content

Commit 5ae1864

Browse files
committed
Implement experimental_useOptimisticState
This adds an experimental hook tentatively called useOptimisticState. (The actual name needs some bikeshedding.) The headline feature is that you can use it to implement optimistic updates. If you set some optimistic state during a transition/action, the state will be automatically reverted once the transition completes. Another feature is that the optimistic updates will be continually rebased on top of the latest state. It's easiest to explain with examples; we'll publish documentation as the API gets closer to stabilizing. See tests for now. Technically the use cases for this hook are broader than just optimistic updates; you could use it implement any sort of "pending" state, such as the ones exposed by useTransition and useFormStatus. But we expect people will most often reach for this hook to implement the optimistic update pattern; simpler cases are covered by those other hooks.
1 parent 51c61c7 commit 5ae1864

File tree

3 files changed

+623
-45
lines changed

3 files changed

+623
-45
lines changed

packages/react-reconciler/src/ReactFiberHooks.js

Lines changed: 198 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -149,11 +149,13 @@ import type {ThenableState} from './ReactFiberThenable';
149149
import type {BatchConfigTransition} from './ReactFiberTracingMarkerComponent';
150150
import {requestAsyncActionContext} from './ReactFiberAsyncAction';
151151
import {HostTransitionContext} from './ReactFiberHostContext';
152+
import {requestTransitionLane} from './ReactFiberRootScheduler';
152153

153154
const {ReactCurrentDispatcher, ReactCurrentBatchConfig} = ReactSharedInternals;
154155

155156
export type Update<S, A> = {
156157
lane: Lane,
158+
revertLane: Lane,
157159
action: A,
158160
hasEagerState: boolean,
159161
eagerState: S | null,
@@ -1132,6 +1134,14 @@ function updateReducer<S, I, A>(
11321134
init?: I => S,
11331135
): [S, Dispatch<A>] {
11341136
const hook = updateWorkInProgressHook();
1137+
return updateReducerImpl(hook, ((currentHook: any): Hook), reducer);
1138+
}
1139+
1140+
function updateReducerImpl<S, A>(
1141+
hook: Hook,
1142+
current: Hook,
1143+
reducer: (S, A) => S,
1144+
): [S, Dispatch<A>] {
11351145
const queue = hook.queue;
11361146

11371147
if (queue === null) {
@@ -1142,10 +1152,8 @@ function updateReducer<S, I, A>(
11421152

11431153
queue.lastRenderedReducer = reducer;
11441154

1145-
const current: Hook = (currentHook: any);
1146-
11471155
// The last rebase update that is NOT part of the base state.
1148-
let baseQueue = current.baseQueue;
1156+
let baseQueue = hook.baseQueue;
11491157

11501158
// The last pending update that hasn't been processed yet.
11511159
const pendingQueue = queue.pending;
@@ -1176,7 +1184,7 @@ function updateReducer<S, I, A>(
11761184
if (baseQueue !== null) {
11771185
// We have a queue to process.
11781186
const first = baseQueue.next;
1179-
let newState = current.baseState;
1187+
let newState = hook.baseState;
11801188

11811189
let newBaseState = null;
11821190
let newBaseQueueFirst = null;
@@ -1202,6 +1210,7 @@ function updateReducer<S, I, A>(
12021210
// update/state.
12031211
const clone: Update<S, A> = {
12041212
lane: updateLane,
1213+
revertLane: update.revertLane,
12051214
action: update.action,
12061215
hasEagerState: update.hasEagerState,
12071216
eagerState: update.eagerState,
@@ -1224,18 +1233,68 @@ function updateReducer<S, I, A>(
12241233
} else {
12251234
// This update does have sufficient priority.
12261235

1227-
if (newBaseQueueLast !== null) {
1228-
const clone: Update<S, A> = {
1229-
// This update is going to be committed so we never want uncommit
1230-
// it. Using NoLane works because 0 is a subset of all bitmasks, so
1231-
// this will never be skipped by the check above.
1232-
lane: NoLane,
1233-
action: update.action,
1234-
hasEagerState: update.hasEagerState,
1235-
eagerState: update.eagerState,
1236-
next: (null: any),
1237-
};
1238-
newBaseQueueLast = newBaseQueueLast.next = clone;
1236+
// Check if this is an optimistic update.
1237+
const revertLane = update.revertLane;
1238+
if (revertLane === NoLane) {
1239+
// This is not an optimistic update, and we're going to apply it now.
1240+
// But, if there were earlier updates that were skipped, we need to
1241+
// leave this update in the queue so it can be rebased later.
1242+
if (newBaseQueueLast !== null) {
1243+
const clone: Update<S, A> = {
1244+
// This update is going to be committed so we never want uncommit
1245+
// it. Using NoLane works because 0 is a subset of all bitmasks, so
1246+
// this will never be skipped by the check above.
1247+
lane: NoLane,
1248+
revertLane: NoLane,
1249+
action: update.action,
1250+
hasEagerState: update.hasEagerState,
1251+
eagerState: update.eagerState,
1252+
next: (null: any),
1253+
};
1254+
newBaseQueueLast = newBaseQueueLast.next = clone;
1255+
}
1256+
} else {
1257+
// This is an optimistic update. If the "revert" priority is
1258+
// sufficient, don't apply the update. Otherwise, apply the update,
1259+
// but leave it in the queue so it can be either reverted or
1260+
// rebased in a subsequent render.
1261+
if (isSubsetOfLanes(renderLanes, revertLane)) {
1262+
// The transition that this optimistic update is associated with
1263+
// has finished. Pretend the update doesn't exist by skipping
1264+
// over it.
1265+
update = update.next;
1266+
continue;
1267+
} else {
1268+
const clone: Update<S, A> = {
1269+
// Once we commit an optimistic update, we shouldn't uncommit it
1270+
// until the transition it is associated with has finished
1271+
// (represented by revertLane). Using NoLane here works because 0
1272+
// is a subset of all bitmasks, so this will never be skipped by
1273+
// the check above.
1274+
lane: NoLane,
1275+
// Reuse the same revertLane so we know when the transition
1276+
// has finished.
1277+
revertLane: update.revertLane,
1278+
action: update.action,
1279+
hasEagerState: update.hasEagerState,
1280+
eagerState: update.eagerState,
1281+
next: (null: any),
1282+
};
1283+
if (newBaseQueueLast === null) {
1284+
newBaseQueueFirst = newBaseQueueLast = clone;
1285+
newBaseState = newState;
1286+
} else {
1287+
newBaseQueueLast = newBaseQueueLast.next = clone;
1288+
}
1289+
// Update the remaining priority in the queue.
1290+
// TODO: Don't need to accumulate this. Instead, we can remove
1291+
// renderLanes from the original lanes.
1292+
currentlyRenderingFiber.lanes = mergeLanes(
1293+
currentlyRenderingFiber.lanes,
1294+
revertLane,
1295+
);
1296+
markSkippedUpdateLanes(revertLane);
1297+
}
12391298
}
12401299

12411300
// Process this update.
@@ -1895,56 +1954,106 @@ function mountStateImpl<S>(initialState: (() => S) | S): Hook {
18951954
lastRenderedState: (initialState: any),
18961955
};
18971956
hook.queue = queue;
1898-
const dispatch: Dispatch<BasicStateAction<S>> = (dispatchSetState.bind(
1899-
null,
1900-
currentlyRenderingFiber,
1901-
queue,
1902-
): any);
1903-
queue.dispatch = dispatch;
19041957
return hook;
19051958
}
19061959

19071960
function mountState<S>(
19081961
initialState: (() => S) | S,
19091962
): [S, Dispatch<BasicStateAction<S>>] {
19101963
const hook = mountStateImpl(initialState);
1911-
return [hook.memoizedState, hook.queue.dispatch];
1964+
const queue = hook.queue;
1965+
const dispatch: Dispatch<BasicStateAction<S>> = (dispatchSetState.bind(
1966+
null,
1967+
currentlyRenderingFiber,
1968+
queue,
1969+
): any);
1970+
queue.dispatch = dispatch;
1971+
return [hook.memoizedState, dispatch];
19121972
}
19131973

19141974
function updateState<S>(
19151975
initialState: (() => S) | S,
19161976
): [S, Dispatch<BasicStateAction<S>>] {
1917-
return updateReducer(basicStateReducer, (initialState: any));
1977+
return updateReducer(basicStateReducer, initialState);
19181978
}
19191979

19201980
function rerenderState<S>(
19211981
initialState: (() => S) | S,
19221982
): [S, Dispatch<BasicStateAction<S>>] {
1923-
return rerenderReducer(basicStateReducer, (initialState: any));
1983+
return rerenderReducer(basicStateReducer, initialState);
19241984
}
19251985

19261986
function mountOptimisticState<S, A>(
19271987
passthrough: S,
19281988
reducer: ?(S, A) => S,
19291989
): [S, (A) => void] {
1930-
// $FlowFixMe - TODO: Actual implementation
1931-
return mountState(passthrough);
1990+
const hook = mountWorkInProgressHook();
1991+
hook.memoizedState = hook.baseState = passthrough;
1992+
const queue: UpdateQueue<S, A> = {
1993+
pending: null,
1994+
lanes: NoLanes,
1995+
dispatch: null,
1996+
// Optimistic state does not use the eager update optimization.
1997+
lastRenderedReducer: null,
1998+
lastRenderedState: null,
1999+
};
2000+
hook.queue = queue;
2001+
// This is different than the normal setState function.
2002+
const dispatch: A => void = (dispatchOptimisticSetState.bind(
2003+
null,
2004+
currentlyRenderingFiber,
2005+
true,
2006+
queue,
2007+
): any);
2008+
queue.dispatch = dispatch;
2009+
return [passthrough, dispatch];
19322010
}
19332011

19342012
function updateOptimisticState<S, A>(
19352013
passthrough: S,
19362014
reducer: ?(S, A) => S,
19372015
): [S, (A) => void] {
1938-
// $FlowFixMe - TODO: Actual implementation
1939-
return updateState(passthrough);
2016+
const hook = updateWorkInProgressHook();
2017+
2018+
// Optimistic updates are always rebased on top of the latest value passed in
2019+
// as an argument. It's called a passthrough because if there are no pending
2020+
// updates, it will be returned as-is.
2021+
//
2022+
// Reset the base state and memoized state to the passthrough. Future
2023+
// updates will be applied on top of this.
2024+
hook.baseState = hook.memoizedState = passthrough;
2025+
2026+
// If a reducer is not provided, default to the same one used by useState.
2027+
const resolvedReducer: (S, A) => S =
2028+
typeof reducer === 'function' ? reducer : (basicStateReducer: any);
2029+
2030+
return updateReducerImpl(hook, ((currentHook: any): Hook), resolvedReducer);
19402031
}
19412032

19422033
function rerenderOptimisticState<S, A>(
19432034
passthrough: S,
19442035
reducer: ?(S, A) => S,
19452036
): [S, (A) => void] {
1946-
// $FlowFixMe - TODO: Actual implementation
1947-
return rerenderState(passthrough);
2037+
// Unlike useState, useOptimisticState doesn't support render phase updates.
2038+
// Also unlike useState, we need to replay all pending updates again in case
2039+
// the passthrough value changed.
2040+
//
2041+
// So instead of a forked re-render implementation that knows how to handle
2042+
// render phase udpates, we can use the same implementation as during a
2043+
// regular mount or update.
2044+
2045+
if (currentHook !== null) {
2046+
// This is an update. Process the update queue.
2047+
return updateOptimisticState(passthrough, reducer);
2048+
}
2049+
2050+
// This is a mount. No updates to process.
2051+
const hook = updateWorkInProgressHook();
2052+
// Reset the base state and memoized state to the passthrough. Future
2053+
// updates will be applied on top of this.
2054+
hook.baseState = hook.memoizedState = passthrough;
2055+
const dispatch = hook.queue.dispatch;
2056+
return [passthrough, dispatch];
19482057
}
19492058

19502059
function pushEffect(
@@ -2486,9 +2595,15 @@ function startTransition<S>(
24862595
higherEventPriority(previousPriority, ContinuousEventPriority),
24872596
);
24882597

2598+
// We don't really need to use an optimistic update here, because we schedule
2599+
// a second "revert" update below (which we use to suspend the transition
2600+
// until the async action scope has finished). But we'll use an optimistic
2601+
// update anyway to make it less likely the behavior accidentally diverges;
2602+
// for example, both an optimistic update and this one should share the
2603+
// same lane.
2604+
dispatchOptimisticSetState(fiber, false, queue, pendingState);
2605+
24892606
const prevTransition = ReactCurrentBatchConfig.transition;
2490-
ReactCurrentBatchConfig.transition = null;
2491-
dispatchSetState(fiber, queue, pendingState);
24922607
const currentTransition = (ReactCurrentBatchConfig.transition =
24932608
({}: BatchConfigTransition));
24942609

@@ -2823,6 +2938,7 @@ function dispatchReducerAction<S, A>(
28232938

28242939
const update: Update<S, A> = {
28252940
lane,
2941+
revertLane: NoLane,
28262942
action,
28272943
hasEagerState: false,
28282944
eagerState: null,
@@ -2861,6 +2977,7 @@ function dispatchSetState<S, A>(
28612977

28622978
const update: Update<S, A> = {
28632979
lane,
2980+
revertLane: NoLane,
28642981
action,
28652982
hasEagerState: false,
28662983
eagerState: null,
@@ -2924,6 +3041,54 @@ function dispatchSetState<S, A>(
29243041
markUpdateInDevTools(fiber, lane, action);
29253042
}
29263043

3044+
function dispatchOptimisticSetState<S, A>(
3045+
fiber: Fiber,
3046+
throwIfDuringRender: boolean,
3047+
queue: UpdateQueue<S, A>,
3048+
action: A,
3049+
): void {
3050+
const update: Update<S, A> = {
3051+
// An optimistic update commits synchronously.
3052+
lane: SyncLane,
3053+
// After committing, the optimistic update is "reverted" using the same
3054+
// lane as the transition it's associated with.
3055+
//
3056+
// TODO: Warn if there's no transition/action associated with this
3057+
// optimistic update.
3058+
revertLane: requestTransitionLane(),
3059+
action,
3060+
hasEagerState: false,
3061+
eagerState: null,
3062+
next: (null: any),
3063+
};
3064+
3065+
if (isRenderPhaseUpdate(fiber)) {
3066+
// When calling startTransition during render, this warns instead of
3067+
// throwing because throwing would be a breaking change. setOptimisticState
3068+
// is a new API so it's OK to throw.
3069+
if (throwIfDuringRender) {
3070+
throw new Error('Cannot update optimistic state while rendering.');
3071+
} else {
3072+
// startTransition was called during render. We don't need to do anything
3073+
// besides warn here because the render phase update would be overidden by
3074+
// the second update, anyway. We can remove this branch and make it throw
3075+
// in a future release.
3076+
if (__DEV__) {
3077+
console.error('Cannot call startTransition state while rendering.');
3078+
}
3079+
}
3080+
} else {
3081+
const root = enqueueConcurrentHookUpdate(fiber, queue, update, SyncLane);
3082+
if (root !== null) {
3083+
scheduleUpdateOnFiber(root, fiber, SyncLane);
3084+
// Optimistic updates are always synchronous, so we don't need to call
3085+
// entangleTransitionUpdate here.
3086+
}
3087+
}
3088+
3089+
markUpdateInDevTools(fiber, SyncLane, action);
3090+
}
3091+
29273092
function isRenderPhaseUpdate(fiber: Fiber): boolean {
29283093
const alternate = fiber.alternate;
29293094
return (

0 commit comments

Comments
 (0)