@samitouri / QOS-React / commits / 491aec5d61

Implement experimental_useOptimisticState (#26740)

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.

Andrew Clark committed May 1, 2023 at 13:19 UTC 491aec5d6113ce5bae7c10966bc38a4a8fc091a8
13 files changed +846 -66
packages/react-devtools-shared/src/__tests__/TimelineProfiler-test.js
+1 -20
@@ -1243,10 +1243,9 @@ describe('Timeline profiler', () => {
1243 function Example() {
1244 const setHigh = React.useState(0)[1];
1245 const setLow = React.useState(0)[1];
1246 - const startTransition = React.useTransition()[1];
1246
1247 updaterFn = () => {
1249 - startTransition(() => {
1248 + React.startTransition(() => {
1249 setLow(prevLow => prevLow + 1);
1250 });
1251 setHigh(prevHigh => prevHigh + 1);
@@ -1265,24 +1264,6 @@ describe('Timeline profiler', () => {
1264 const timelineData = stopProfilingAndGetTimelineData();
1265 expect(timelineData.schedulingEvents).toMatchInlineSnapshot(`
1266 [
1268 - {
1269 - "componentName": "Example",
1270 - "componentStack": "
1271 - in Example (at **)",
1272 - "lanes": "0b0000000000000000000000000001000",
1273 - "timestamp": 10,
1274 - "type": "schedule-state-update",
1275 - "warning": null,
1276 - },
1277 - {
1278 - "componentName": "Example",
1279 - "componentStack": "
1280 - in Example (at **)",
1281 - "lanes": "0b0000000000000000000000010000000",
1282 - "timestamp": 10,
1283 - "type": "schedule-state-update",
1284 - "warning": null,
1285 - },
1267 {
1268 "componentName": "Example",
1269 "componentStack": "
packages/react-dom/src/__tests__/ReactDOMFizzForm-test.js
+19
@@ -22,6 +22,7 @@ let React;
22 let ReactDOMServer;
23 let ReactDOMClient;
24 let useFormStatus;
25 +let useOptimisticState;
26
27 describe('ReactDOMFizzForm', () => {
28 beforeEach(() => {
@@ -30,6 +31,7 @@ describe('ReactDOMFizzForm', () => {
31 ReactDOMServer = require('react-dom/server.browser');
32 ReactDOMClient = require('react-dom/client');
33 useFormStatus = require('react-dom').experimental_useFormStatus;
34 + useOptimisticState = require('react').experimental_useOptimisticState;
35 act = require('internal-test-utils').act;
36 container = document.createElement('div');
37 document.body.appendChild(container);
@@ -453,4 +455,21 @@ describe('ReactDOMFizzForm', () => {
455 expect(deletedTitle).toBe('Hello');
456 expect(rootActionCalled).toBe(false);
457 });
458 +
459 + // @gate enableAsyncActions
460 + it('useOptimisticState returns passthrough value', async () => {
461 + function App() {
462 + const [optimisticState] = useOptimisticState('hi');
463 + return optimisticState;
464 + }
465 +
466 + const stream = await ReactDOMServer.renderToReadableStream(<App />);
467 + await readIntoContainer(stream);
468 + expect(container.textContent).toBe('hi');
469 +
470 + await act(async () => {
471 + ReactDOMClient.hydrateRoot(container, <App />);
472 + });
473 + expect(container.textContent).toBe('hi');
474 + });
475 });
packages/react-reconciler/src/ReactFiberHooks.js
+358 -44
@@ -149,11 +149,13 @@ import type {ThenableState} from './ReactFiberThenable';
149 import type {BatchConfigTransition} from './ReactFiberTracingMarkerComponent';
150 import {requestAsyncActionContext} from './ReactFiberAsyncAction';
151 import {HostTransitionContext} from './ReactFiberHostContext';
152 +import {requestTransitionLane} from './ReactFiberRootScheduler';
153
154 const {ReactCurrentDispatcher, ReactCurrentBatchConfig} = ReactSharedInternals;
155
156 export type Update<S, A> = {
157 lane: Lane,
158 + revertLane: Lane,
159 action: A,
160 hasEagerState: boolean,
161 eagerState: S | null,
@@ -1136,6 +1138,14 @@ function updateReducer<S, I, A>(
1138 init?: I => S,
1139 ): [S, Dispatch<A>] {
1140 const hook = updateWorkInProgressHook();
1141 + return updateReducerImpl(hook, ((currentHook: any): Hook), reducer);
1142 +}
1143 +
1144 +function updateReducerImpl<S, A>(
1145 + hook: Hook,
1146 + current: Hook,
1147 + reducer: (S, A) => S,
1148 +): [S, Dispatch<A>] {
1149 const queue = hook.queue;
1150
1151 if (queue === null) {
@@ -1146,10 +1156,8 @@ function updateReducer<S, I, A>(
1156
1157 queue.lastRenderedReducer = reducer;
1158
1149 - const current: Hook = (currentHook: any);
1150 -
1159 // The last rebase update that is NOT part of the base state.
1152 - let baseQueue = current.baseQueue;
1160 + let baseQueue = hook.baseQueue;
1161
1162 // The last pending update that hasn't been processed yet.
1163 const pendingQueue = queue.pending;
@@ -1180,7 +1188,7 @@ function updateReducer<S, I, A>(
1188 if (baseQueue !== null) {
1189 // We have a queue to process.
1190 const first = baseQueue.next;
1183 - let newState = current.baseState;
1191 + let newState = hook.baseState;
1192
1193 let newBaseState = null;
1194 let newBaseQueueFirst = null;
@@ -1206,6 +1214,7 @@ function updateReducer<S, I, A>(
1214 // update/state.
1215 const clone: Update<S, A> = {
1216 lane: updateLane,
1217 + revertLane: update.revertLane,
1218 action: update.action,
1219 hasEagerState: update.hasEagerState,
1220 eagerState: update.eagerState,
@@ -1228,18 +1237,68 @@ function updateReducer<S, I, A>(
1237 } else {
1238 // This update does have sufficient priority.
1239
1231 - if (newBaseQueueLast !== null) {
1232 - const clone: Update<S, A> = {
1233 - // This update is going to be committed so we never want uncommit
1234 - // it. Using NoLane works because 0 is a subset of all bitmasks, so
1235 - // this will never be skipped by the check above.
1236 - lane: NoLane,
1237 - action: update.action,
1238 - hasEagerState: update.hasEagerState,
1239 - eagerState: update.eagerState,
1240 - next: (null: any),
1241 - };
1242 - newBaseQueueLast = newBaseQueueLast.next = clone;
1240 + // Check if this is an optimistic update.
1241 + const revertLane = update.revertLane;
1242 + if (!enableAsyncActions || revertLane === NoLane) {
1243 + // This is not an optimistic update, and we're going to apply it now.
1244 + // But, if there were earlier updates that were skipped, we need to
1245 + // leave this update in the queue so it can be rebased later.
1246 + if (newBaseQueueLast !== null) {
1247 + const clone: Update<S, A> = {
1248 + // This update is going to be committed so we never want uncommit
1249 + // it. Using NoLane works because 0 is a subset of all bitmasks, so
1250 + // this will never be skipped by the check above.
1251 + lane: NoLane,
1252 + revertLane: NoLane,
1253 + action: update.action,
1254 + hasEagerState: update.hasEagerState,
1255 + eagerState: update.eagerState,
1256 + next: (null: any),
1257 + };
1258 + newBaseQueueLast = newBaseQueueLast.next = clone;
1259 + }
1260 + } else {
1261 + // This is an optimistic update. If the "revert" priority is
1262 + // sufficient, don't apply the update. Otherwise, apply the update,
1263 + // but leave it in the queue so it can be either reverted or
1264 + // rebased in a subsequent render.
1265 + if (isSubsetOfLanes(renderLanes, revertLane)) {
1266 + // The transition that this optimistic update is associated with
1267 + // has finished. Pretend the update doesn't exist by skipping
1268 + // over it.
1269 + update = update.next;
1270 + continue;
1271 + } else {
1272 + const clone: Update<S, A> = {
1273 + // Once we commit an optimistic update, we shouldn't uncommit it
1274 + // until the transition it is associated with has finished
1275 + // (represented by revertLane). Using NoLane here works because 0
1276 + // is a subset of all bitmasks, so this will never be skipped by
1277 + // the check above.
1278 + lane: NoLane,
1279 + // Reuse the same revertLane so we know when the transition
1280 + // has finished.
1281 + revertLane: update.revertLane,
1282 + action: update.action,
1283 + hasEagerState: update.hasEagerState,
1284 + eagerState: update.eagerState,
1285 + next: (null: any),
1286 + };
1287 + if (newBaseQueueLast === null) {
1288 + newBaseQueueFirst = newBaseQueueLast = clone;
1289 + newBaseState = newState;
1290 + } else {
1291 + newBaseQueueLast = newBaseQueueLast.next = clone;
1292 + }
1293 + // Update the remaining priority in the queue.
1294 + // TODO: Don't need to accumulate this. Instead, we can remove
1295 + // renderLanes from the original lanes.
1296 + currentlyRenderingFiber.lanes = mergeLanes(
1297 + currentlyRenderingFiber.lanes,
1298 + revertLane,
1299 + );
1300 + markSkippedUpdateLanes(revertLane);
1301 + }
1302 }
1303
1304 // Process this update.
@@ -1884,9 +1943,7 @@ function forceStoreRerender(fiber: Fiber) {
1943 }
1944 }
1945
1887 -function mountState<S>(
1888 - initialState: (() => S) | S,
1889 -): [S, Dispatch<BasicStateAction<S>>] {
1946 +function mountStateImpl<S>(initialState: (() => S) | S): Hook {
1947 const hook = mountWorkInProgressHook();
1948 if (typeof initialState === 'function') {
1949 // $FlowFixMe[incompatible-use]: Flow doesn't like mixed types
@@ -1901,21 +1958,106 @@ function mountState<S>(
1958 lastRenderedState: (initialState: any),
1959 };
1960 hook.queue = queue;
1904 - const dispatch: Dispatch<BasicStateAction<S>> = (queue.dispatch =
1905 - (dispatchSetState.bind(null, currentlyRenderingFiber, queue): any));
1961 + return hook;
1962 +}
1963 +
1964 +function mountState<S>(
1965 + initialState: (() => S) | S,
1966 +): [S, Dispatch<BasicStateAction<S>>] {
1967 + const hook = mountStateImpl(initialState);
1968 + const queue = hook.queue;
1969 + const dispatch: Dispatch<BasicStateAction<S>> = (dispatchSetState.bind(
1970 + null,
1971 + currentlyRenderingFiber,
1972 + queue,
1973 + ): any);
1974 + queue.dispatch = dispatch;
1975 return [hook.memoizedState, dispatch];
1976 }
1977
1978 function updateState<S>(
1979 initialState: (() => S) | S,
1980 ): [S, Dispatch<BasicStateAction<S>>] {
1912 - return updateReducer(basicStateReducer, (initialState: any));
1981 + return updateReducer(basicStateReducer, initialState);
1982 }
1983
1984 function rerenderState<S>(
1985 initialState: (() => S) | S,
1986 ): [S, Dispatch<BasicStateAction<S>>] {
1918 - return rerenderReducer(basicStateReducer, (initialState: any));
1987 + return rerenderReducer(basicStateReducer, initialState);
1988 +}
1989 +
1990 +function mountOptimisticState<S, A>(
1991 + passthrough: S,
1992 + reducer: ?(S, A) => S,
1993 +): [S, (A) => void] {
1994 + const hook = mountWorkInProgressHook();
1995 + hook.memoizedState = hook.baseState = passthrough;
1996 + const queue: UpdateQueue<S, A> = {
1997 + pending: null,
1998 + lanes: NoLanes,
1999 + dispatch: null,
2000 + // Optimistic state does not use the eager update optimization.
2001 + lastRenderedReducer: null,
2002 + lastRenderedState: null,
2003 + };
2004 + hook.queue = queue;
2005 + // This is different than the normal setState function.
2006 + const dispatch: A => void = (dispatchOptimisticSetState.bind(
2007 + null,
2008 + currentlyRenderingFiber,
2009 + true,
2010 + queue,
2011 + ): any);
2012 + queue.dispatch = dispatch;
2013 + return [passthrough, dispatch];
2014 +}
2015 +
2016 +function updateOptimisticState<S, A>(
2017 + passthrough: S,
2018 + reducer: ?(S, A) => S,
2019 +): [S, (A) => void] {
2020 + const hook = updateWorkInProgressHook();
2021 +
2022 + // Optimistic updates are always rebased on top of the latest value passed in
2023 + // as an argument. It's called a passthrough because if there are no pending
2024 + // updates, it will be returned as-is.
2025 + //
2026 + // Reset the base state and memoized state to the passthrough. Future
2027 + // updates will be applied on top of this.
2028 + hook.baseState = hook.memoizedState = passthrough;
2029 +
2030 + // If a reducer is not provided, default to the same one used by useState.
2031 + const resolvedReducer: (S, A) => S =
2032 + typeof reducer === 'function' ? reducer : (basicStateReducer: any);
2033 +
2034 + return updateReducerImpl(hook, ((currentHook: any): Hook), resolvedReducer);
2035 +}
2036 +
2037 +function rerenderOptimisticState<S, A>(
2038 + passthrough: S,
2039 + reducer: ?(S, A) => S,
2040 +): [S, (A) => void] {
2041 + // Unlike useState, useOptimisticState doesn't support render phase updates.
2042 + // Also unlike useState, we need to replay all pending updates again in case
2043 + // the passthrough value changed.
2044 + //
2045 + // So instead of a forked re-render implementation that knows how to handle
2046 + // render phase udpates, we can use the same implementation as during a
2047 + // regular mount or update.
2048 +
2049 + if (currentHook !== null) {
2050 + // This is an update. Process the update queue.
2051 + return updateOptimisticState(passthrough, reducer);
2052 + }
2053 +
2054 + // This is a mount. No updates to process.
2055 + const hook = updateWorkInProgressHook();
2056 + // Reset the base state and memoized state to the passthrough. Future
2057 + // updates will be applied on top of this.
2058 + hook.baseState = hook.memoizedState = passthrough;
2059 + const dispatch = hook.queue.dispatch;
2060 + return [passthrough, dispatch];
2061 }
2062
2063 function pushEffect(
@@ -2445,9 +2587,10 @@ function updateDeferredValueImpl<T>(hook: Hook, prevValue: T, value: T): T {
2587 }
2588
2589 function startTransition<S>(
2590 + fiber: Fiber,
2591 + queue: UpdateQueue<S | Thenable<S>, BasicStateAction<S | Thenable<S>>>,
2592 pendingState: S,
2593 finishedState: S,
2450 - setPending: (Thenable<S> | S) => void,
2594 callback: () => mixed,
2595 options?: StartTransitionOptions,
2596 ): void {
@@ -2457,8 +2600,20 @@ function startTransition<S>(
2600 );
2601
2602 const prevTransition = ReactCurrentBatchConfig.transition;
2460 - ReactCurrentBatchConfig.transition = null;
2461 - setPending(pendingState);
2603 +
2604 + if (enableAsyncActions) {
2605 + // We don't really need to use an optimistic update here, because we
2606 + // schedule a second "revert" update below (which we use to suspend the
2607 + // transition until the async action scope has finished). But we'll use an
2608 + // optimistic update anyway to make it less likely the behavior accidentally
2609 + // diverges; for example, both an optimistic update and this one should
2610 + // share the same lane.
2611 + dispatchOptimisticSetState(fiber, false, queue, pendingState);
2612 + } else {
2613 + ReactCurrentBatchConfig.transition = null;
2614 + dispatchSetState(fiber, queue, pendingState);
2615 + }
2616 +
2617 const currentTransition = (ReactCurrentBatchConfig.transition =
2618 ({}: BatchConfigTransition));
2619
@@ -2485,10 +2640,10 @@ function startTransition<S>(
2640 returnValue,
2641 finishedState,
2642 );
2488 - setPending(maybeThenable);
2643 + dispatchSetState(fiber, queue, maybeThenable);
2644 } else {
2645 // Async actions are not enabled.
2491 - setPending(finishedState);
2646 + dispatchSetState(fiber, queue, finishedState);
2647 callback();
2648 }
2649 } catch (error) {
@@ -2501,7 +2656,7 @@ function startTransition<S>(
2656 status: 'rejected',
2657 reason: error,
2658 };
2504 - setPending(rejectedThenable);
2659 + dispatchSetState(fiber, queue, rejectedThenable);
2660 } else {
2661 // The error rethrowing behavior is only enabled when the async actions
2662 // feature is on, even for sync actions.
@@ -2553,7 +2708,10 @@ export function startHostTransition<F>(
2708 );
2709 }
2710
2556 - let setPending;
2711 + let queue: UpdateQueue<
2712 + Thenable<TransitionStatus> | TransitionStatus,
2713 + BasicStateAction<Thenable<TransitionStatus> | TransitionStatus>,
2714 + >;
2715 if (formFiber.memoizedState === null) {
2716 // Upgrade this host component fiber to be stateful. We're going to pretend
2717 // it was stateful all along so we can reuse most of the implementation
@@ -2561,28 +2719,28 @@ export function startHostTransition<F>(
2719 //
2720 // Create the state hook used by TransitionAwareHostComponent. This is
2721 // essentially an inlined version of mountState.
2564 - const queue: UpdateQueue<
2565 - Thenable<TransitionStatus> | TransitionStatus,
2722 + const newQueue: UpdateQueue<
2723 Thenable<TransitionStatus> | TransitionStatus,
2724 + BasicStateAction<Thenable<TransitionStatus> | TransitionStatus>,
2725 > = {
2726 pending: null,
2727 lanes: NoLanes,
2570 - dispatch: null,
2728 + // We're going to cheat and intentionally not create a bound dispatch
2729 + // method, because we can call it directly in startTransition.
2730 + dispatch: (null: any),
2731 lastRenderedReducer: basicStateReducer,
2732 lastRenderedState: NoPendingHostTransition,
2733 };
2734 + queue = newQueue;
2735 +
2736 const stateHook: Hook = {
2737 memoizedState: NoPendingHostTransition,
2738 baseState: NoPendingHostTransition,
2739 baseQueue: null,
2578 - queue: queue,
2740 + queue: newQueue,
2741 next: null,
2742 };
2743
2582 - const dispatch: (Thenable<TransitionStatus> | TransitionStatus) => void =
2583 - (dispatchSetState.bind(null, formFiber, queue): any);
2584 - setPending = queue.dispatch = dispatch;
2585 -
2744 // Add the state hook to both fiber alternates. The idea is that the fiber
2745 // had this hook all along.
2746 formFiber.memoizedState = stateHook;
@@ -2593,15 +2751,14 @@ export function startHostTransition<F>(
2751 } else {
2752 // This fiber was already upgraded to be stateful.
2753 const stateHook: Hook = formFiber.memoizedState;
2596 - const dispatch: (Thenable<TransitionStatus> | TransitionStatus) => void =
2597 - stateHook.queue.dispatch;
2598 - setPending = dispatch;
2754 + queue = stateHook.queue;
2755 }
2756
2757 startTransition(
2758 + formFiber,
2759 + queue,
2760 pendingState,
2761 NoPendingHostTransition,
2604 - setPending,
2762 // TODO: We can avoid this extra wrapper, somehow. Figure out layering
2763 // once more of this function is implemented.
2764 () => callback(formData),
@@ -2612,9 +2769,15 @@ function mountTransition(): [
2769 boolean,
2770 (callback: () => void, options?: StartTransitionOptions) => void,
2771 ] {
2615 - const [, setPending] = mountState((false: Thenable<boolean> | boolean));
2772 + const stateHook = mountStateImpl((false: Thenable<boolean> | boolean));
2773 // The `start` method never changes.
2617 - const start = startTransition.bind(null, true, false, setPending);
2774 + const start = startTransition.bind(
2775 + null,
2776 + currentlyRenderingFiber,
2777 + stateHook.queue,
2778 + true,
2779 + false,
2780 + );
2781 const hook = mountWorkInProgressHook();
2782 hook.memoizedState = start;
2783 return [false, start];
@@ -2785,6 +2948,7 @@ function dispatchReducerAction<S, A>(
2948
2949 const update: Update<S, A> = {
2950 lane,
2951 + revertLane: NoLane,
2952 action,
2953 hasEagerState: false,
2954 eagerState: null,
@@ -2823,6 +2987,7 @@ function dispatchSetState<S, A>(
2987
2988 const update: Update<S, A> = {
2989 lane,
2990 + revertLane: NoLane,
2991 action,
2992 hasEagerState: false,
2993 eagerState: null,
@@ -2886,6 +3051,58 @@ function dispatchSetState<S, A>(
3051 markUpdateInDevTools(fiber, lane, action);
3052 }
3053
3054 +function dispatchOptimisticSetState<S, A>(
3055 + fiber: Fiber,
3056 + throwIfDuringRender: boolean,
3057 + queue: UpdateQueue<S, A>,
3058 + action: A,
3059 +): void {
3060 + const update: Update<S, A> = {
3061 + // An optimistic update commits synchronously.
3062 + lane: SyncLane,
3063 + // After committing, the optimistic update is "reverted" using the same
3064 + // lane as the transition it's associated with.
3065 + //
3066 + // TODO: Warn if there's no transition/action associated with this
3067 + // optimistic update.
3068 + revertLane: requestTransitionLane(),
3069 + action,
3070 + hasEagerState: false,
3071 + eagerState: null,
3072 + next: (null: any),
3073 + };
3074 +
3075 + if (isRenderPhaseUpdate(fiber)) {
3076 + // When calling startTransition during render, this warns instead of
3077 + // throwing because throwing would be a breaking change. setOptimisticState
3078 + // is a new API so it's OK to throw.
3079 + if (throwIfDuringRender) {
3080 + throw new Error('Cannot update optimistic state while rendering.');
3081 + } else {
3082 + // startTransition was called during render. We don't need to do anything
3083 + // besides warn here because the render phase update would be overidden by
3084 + // the second update, anyway. We can remove this branch and make it throw
3085 + // in a future release.
3086 + if (__DEV__) {
3087 + console.error('Cannot call startTransition while rendering.');
3088 + }
3089 + }
3090 + } else {
3091 + const root = enqueueConcurrentHookUpdate(fiber, queue, update, SyncLane);
3092 + if (root !== null) {
3093 + // NOTE: The optimistic update implementation assumes that the transition
3094 + // will never be attempted before the optimistic update. This currently
3095 + // holds because the optimistic update is always synchronous. If we ever
3096 + // change that, we'll need to account for this.
3097 + scheduleUpdateOnFiber(root, fiber, SyncLane);
3098 + // Optimistic updates are always synchronous, so we don't need to call
3099 + // entangleTransitionUpdate here.
3100 + }
3101 + }
3102 +
3103 + markUpdateInDevTools(fiber, SyncLane, action);
3104 +}
3105 +
3106 function isRenderPhaseUpdate(fiber: Fiber): boolean {
3107 const alternate = fiber.alternate;
3108 return (
@@ -2989,6 +3206,10 @@ if (enableFormActions && enableAsyncActions) {
3206 (ContextOnlyDispatcher: Dispatcher).useHostTransitionStatus =
3207 throwInvalidHookError;
3208 }
3209 +if (enableAsyncActions) {
3210 + (ContextOnlyDispatcher: Dispatcher).useOptimisticState =
3211 + throwInvalidHookError;
3212 +}
3213
3214 const HooksDispatcherOnMount: Dispatcher = {
3215 readContext,
@@ -3024,6 +3245,11 @@ if (enableFormActions && enableAsyncActions) {
3245 (HooksDispatcherOnMount: Dispatcher).useHostTransitionStatus =
3246 useHostTransitionStatus;
3247 }
3248 +if (enableAsyncActions) {
3249 + (HooksDispatcherOnMount: Dispatcher).useOptimisticState =
3250 + mountOptimisticState;
3251 +}
3252 +
3253 const HooksDispatcherOnUpdate: Dispatcher = {
3254 readContext,
3255
@@ -3058,6 +3284,10 @@ if (enableFormActions && enableAsyncActions) {
3284 (HooksDispatcherOnUpdate: Dispatcher).useHostTransitionStatus =
3285 useHostTransitionStatus;
3286 }
3287 +if (enableAsyncActions) {
3288 + (HooksDispatcherOnUpdate: Dispatcher).useOptimisticState =
3289 + updateOptimisticState;
3290 +}
3291
3292 const HooksDispatcherOnRerender: Dispatcher = {
3293 readContext,
@@ -3093,6 +3323,10 @@ if (enableFormActions && enableAsyncActions) {
3323 (HooksDispatcherOnRerender: Dispatcher).useHostTransitionStatus =
3324 useHostTransitionStatus;
3325 }
3326 +if (enableAsyncActions) {
3327 + (HooksDispatcherOnRerender: Dispatcher).useOptimisticState =
3328 + rerenderOptimisticState;
3329 +}
3330
3331 let HooksDispatcherOnMountInDEV: Dispatcher | null = null;
3332 let HooksDispatcherOnMountWithHookTypesInDEV: Dispatcher | null = null;
@@ -3283,6 +3517,17 @@ if (__DEV__) {
3517 (HooksDispatcherOnMountInDEV: Dispatcher).useHostTransitionStatus =
3518 useHostTransitionStatus;
3519 }
3520 + if (enableAsyncActions) {
3521 + (HooksDispatcherOnMountInDEV: Dispatcher).useOptimisticState =
3522 + function useOptimisticState<S, A>(
3523 + passthrough: S,
3524 + reducer: ?(S, A) => S,
3525 + ): [S, (A) => void] {
3526 + currentHookNameInDev = 'useOptimisticState';
3527 + mountHookTypesDev();
3528 + return mountOptimisticState(passthrough, reducer);
3529 + };
3530 + }
3531
3532 HooksDispatcherOnMountWithHookTypesInDEV = {
3533 readContext<T>(context: ReactContext<T>): T {
@@ -3441,6 +3686,17 @@ if (__DEV__) {
3686 (HooksDispatcherOnMountWithHookTypesInDEV: Dispatcher).useHostTransitionStatus =
3687 useHostTransitionStatus;
3688 }
3689 + if (enableAsyncActions) {
3690 + (HooksDispatcherOnMountWithHookTypesInDEV: Dispatcher).useOptimisticState =
3691 + function useOptimisticState<S, A>(
3692 + passthrough: S,
3693 + reducer: ?(S, A) => S,
3694 + ): [S, (A) => void] {
3695 + currentHookNameInDev = 'useOptimisticState';
3696 + updateHookTypesDev();
3697 + return mountOptimisticState(passthrough, reducer);
3698 + };
3699 + }
3700
3701 HooksDispatcherOnUpdateInDEV = {
3702 readContext<T>(context: ReactContext<T>): T {
@@ -3601,6 +3857,17 @@ if (__DEV__) {
3857 (HooksDispatcherOnUpdateInDEV: Dispatcher).useHostTransitionStatus =
3858 useHostTransitionStatus;
3859 }
3860 + if (enableAsyncActions) {
3861 + (HooksDispatcherOnUpdateInDEV: Dispatcher).useOptimisticState =
3862 + function useOptimisticState<S, A>(
3863 + passthrough: S,
3864 + reducer: ?(S, A) => S,
3865 + ): [S, (A) => void] {
3866 + currentHookNameInDev = 'useOptimisticState';
3867 + updateHookTypesDev();
3868 + return updateOptimisticState(passthrough, reducer);
3869 + };
3870 + }
3871
3872 HooksDispatcherOnRerenderInDEV = {
3873 readContext<T>(context: ReactContext<T>): T {
@@ -3761,6 +4028,17 @@ if (__DEV__) {
4028 (HooksDispatcherOnRerenderInDEV: Dispatcher).useHostTransitionStatus =
4029 useHostTransitionStatus;
4030 }
4031 + if (enableAsyncActions) {
4032 + (HooksDispatcherOnRerenderInDEV: Dispatcher).useOptimisticState =
4033 + function useOptimisticState<S, A>(
4034 + passthrough: S,
4035 + reducer: ?(S, A) => S,
4036 + ): [S, (A) => void] {
4037 + currentHookNameInDev = 'useOptimisticState';
4038 + updateHookTypesDev();
4039 + return rerenderOptimisticState(passthrough, reducer);
4040 + };
4041 + }
4042
4043 InvalidNestedHooksDispatcherOnMountInDEV = {
4044 readContext<T>(context: ReactContext<T>): T {
@@ -3943,6 +4221,18 @@ if (__DEV__) {
4221 (InvalidNestedHooksDispatcherOnMountInDEV: Dispatcher).useHostTransitionStatus =
4222 useHostTransitionStatus;
4223 }
4224 + if (enableAsyncActions) {
4225 + (InvalidNestedHooksDispatcherOnMountInDEV: Dispatcher).useOptimisticState =
4226 + function useOptimisticState<S, A>(
4227 + passthrough: S,
4228 + reducer: ?(S, A) => S,
4229 + ): [S, (A) => void] {
4230 + currentHookNameInDev = 'useOptimisticState';
4231 + warnInvalidHookAccess();
4232 + mountHookTypesDev();
4233 + return mountOptimisticState(passthrough, reducer);
4234 + };
4235 + }
4236
4237 InvalidNestedHooksDispatcherOnUpdateInDEV = {
4238 readContext<T>(context: ReactContext<T>): T {
@@ -4128,6 +4418,18 @@ if (__DEV__) {
4418 (InvalidNestedHooksDispatcherOnUpdateInDEV: Dispatcher).useHostTransitionStatus =
4419 useHostTransitionStatus;
4420 }
4421 + if (enableAsyncActions) {
4422 + (InvalidNestedHooksDispatcherOnUpdateInDEV: Dispatcher).useOptimisticState =
4423 + function useOptimisticState<S, A>(
4424 + passthrough: S,
4425 + reducer: ?(S, A) => S,
4426 + ): [S, (A) => void] {
4427 + currentHookNameInDev = 'useOptimisticState';
4428 + warnInvalidHookAccess();
4429 + updateHookTypesDev();
4430 + return updateOptimisticState(passthrough, reducer);
4431 + };
4432 + }
4433
4434 InvalidNestedHooksDispatcherOnRerenderInDEV = {
4435 readContext<T>(context: ReactContext<T>): T {
@@ -4313,4 +4615,16 @@ if (__DEV__) {
4615 (InvalidNestedHooksDispatcherOnRerenderInDEV: Dispatcher).useHostTransitionStatus =
4616 useHostTransitionStatus;
4617 }
4618 + if (enableAsyncActions) {
4619 + (InvalidNestedHooksDispatcherOnRerenderInDEV: Dispatcher).useOptimisticState =
4620 + function useOptimisticState<S, A>(
4621 + passthrough: S,
4622 + reducer: ?(S, A) => S,
4623 + ): [S, (A) => void] {
4624 + currentHookNameInDev = 'useOptimisticState';
4625 + warnInvalidHookAccess();
4626 + updateHookTypesDev();
4627 + return rerenderOptimisticState(passthrough, reducer);
4628 + };
4629 + }
4630 }
packages/react-reconciler/src/ReactInternalTypes.js
+6 -1
@@ -57,7 +57,8 @@ export type HookType =
57 | 'useMutableSource'
58 | 'useSyncExternalStore'
59 | 'useId'
60 - | 'useCacheRefresh';
60 + | 'useCacheRefresh'
61 + | 'useOptimisticState';
62
63 export type ContextDependency<T> = {
64 context: ReactContext<T>,
@@ -423,6 +424,10 @@ export type Dispatcher = {
424 useCacheRefresh?: () => <T>(?() => T, ?T) => void,
425 useMemoCache?: (size: number) => Array<any>,
426 useHostTransitionStatus?: () => TransitionStatus,
427 + useOptimisticState?: <S, A>(
428 + passthrough: S,
429 + reducer: ?(S, A) => S,
430 + ) => [S, (A) => void],
431 };
432
433 export type CacheDispatcher = {
packages/react-reconciler/src/__tests__/ReactAsyncActions-test.js
+430
@@ -5,6 +5,7 @@ let act;
5 let assertLog;
6 let useTransition;
7 let useState;
8 +let useOptimisticState;
9 let textCache;
10
11 describe('ReactAsyncActions', () => {
@@ -18,6 +19,7 @@ describe('ReactAsyncActions', () => {
19 assertLog = require('internal-test-utils').assertLog;
20 useTransition = React.useTransition;
21 useState = React.useState;
22 + useOptimisticState = React.experimental_useOptimisticState;
23
24 textCache = new Map();
25 });
@@ -644,4 +646,432 @@ describe('ReactAsyncActions', () => {
646 </>,
647 );
648 });
649 +
650 + // @gate enableAsyncActions
651 + test('useOptimisticState can be used to implement a pending state', async () => {
652 + const startTransition = React.startTransition;
653 +
654 + let setIsPending;
655 + function App({text}) {
656 + const [isPending, _setIsPending] = useOptimisticState(false);
657 + setIsPending = _setIsPending;
658 + return (
659 + <>
660 + <Text text={'Pending: ' + isPending} />
661 + <AsyncText text={text} />
662 + </>
663 + );
664 + }
665 +
666 + // Initial render
667 + const root = ReactNoop.createRoot();
668 + resolveText('A');
669 + await act(() => root.render(<App text="A" />));
670 + assertLog(['Pending: false', 'A']);
671 + expect(root).toMatchRenderedOutput('Pending: falseA');
672 +
673 + // Start a transition
674 + await act(() =>
675 + startTransition(() => {
676 + setIsPending(true);
677 + root.render(<App text="B" />);
678 + }),
679 + );
680 + assertLog([
681 + // Render the pending state immediately
682 + 'Pending: true',
683 + 'A',
684 +
685 + // Then attempt to render the transition. The pending state will be
686 + // automatically reverted.
687 + 'Pending: false',
688 + 'Suspend! [B]',
689 + ]);
690 +
691 + // Resolve the transition
692 + await act(() => resolveText('B'));
693 + assertLog([
694 + // Render the pending state immediately
695 + 'Pending: false',
696 + 'B',
697 + ]);
698 + });
699 +
700 + // @gate enableAsyncActions
701 + test('useOptimisticState rebases pending updates on top of passthrough value', async () => {
702 + let serverCart = ['A'];
703 +
704 + async function submitNewItem(item) {
705 + await getText('Adding item ' + item);
706 + serverCart = [...serverCart, item];
707 + React.startTransition(() => {
708 + root.render(<App cart={serverCart} />);
709 + });
710 + }
711 +
712 + let addItemToCart;
713 + function App({cart}) {
714 + const [isPending, startTransition] = useTransition();
715 +
716 + const savedCartSize = cart.length;
717 + const [optimisticCartSize, setOptimisticCartSize] =
718 + useOptimisticState(savedCartSize);
719 +
720 + addItemToCart = item => {
721 + startTransition(async () => {
722 + setOptimisticCartSize(n => n + 1);
723 + await submitNewItem(item);
724 + });
725 + };
726 +
727 + return (
728 + <>
729 + <div>
730 + <Text text={'Pending: ' + isPending} />
731 + </div>
732 + <div>
733 + <Text text={'Items in cart: ' + optimisticCartSize} />
734 + </div>
735 + <ul>
736 + {cart.map(item => (
737 + <li key={item}>
738 + <Text text={'Item ' + item} />
739 + </li>
740 + ))}
741 + </ul>
742 + </>
743 + );
744 + }
745 +
746 + // Initial render
747 + const root = ReactNoop.createRoot();
748 + await act(() => root.render(<App cart={serverCart} />));
749 + assertLog(['Pending: false', 'Items in cart: 1', 'Item A']);
750 + expect(root).toMatchRenderedOutput(
751 + <>
752 + <div>Pending: false</div>
753 + <div>Items in cart: 1</div>
754 + <ul>
755 + <li>Item A</li>
756 + </ul>
757 + </>,
758 + );
759 +
760 + // The cart size is incremented even though B hasn't been added yet.
761 + await act(() => addItemToCart('B'));
762 + assertLog(['Pending: true', 'Items in cart: 2', 'Item A']);
763 + expect(root).toMatchRenderedOutput(
764 + <>
765 + <div>Pending: true</div>
766 + <div>Items in cart: 2</div>
767 + <ul>
768 + <li>Item A</li>
769 + </ul>
770 + </>,
771 + );
772 +
773 + // While B is still pending, another item gets added to the cart
774 + // out-of-band.
775 + serverCart = [...serverCart, 'C'];
776 + // NOTE: This is a synchronous update only because we don't yet support
777 + // parallel transitions; all transitions are entangled together. Once we add
778 + // support for parallel transitions, we can update this test.
779 + ReactNoop.flushSync(() => root.render(<App cart={serverCart} />));
780 + assertLog([
781 + 'Pending: true',
782 + // Note that the optimistic cart size is still correct, because the
783 + // pending update was rebased on top new value.
784 + 'Items in cart: 3',
785 + 'Item A',
786 + 'Item C',
787 + ]);
788 + expect(root).toMatchRenderedOutput(
789 + <>
790 + <div>Pending: true</div>
791 + <div>Items in cart: 3</div>
792 + <ul>
793 + <li>Item A</li>
794 + <li>Item C</li>
795 + </ul>
796 + </>,
797 + );
798 +
799 + // Finish loading B. The optimistic state is reverted.
800 + await act(() => resolveText('Adding item B'));
801 + assertLog([
802 + 'Pending: false',
803 + 'Items in cart: 3',
804 + 'Item A',
805 + 'Item C',
806 + 'Item B',
807 + ]);
808 + expect(root).toMatchRenderedOutput(
809 + <>
810 + <div>Pending: false</div>
811 + <div>Items in cart: 3</div>
812 + <ul>
813 + <li>Item A</li>
814 + <li>Item C</li>
815 + <li>Item B</li>
816 + </ul>
817 + </>,
818 + );
819 + });
820 +
821 + // @gate enableAsyncActions
822 + test('useOptimisticState accepts a custom reducer', async () => {
823 + let serverCart = ['A'];
824 +
825 + async function submitNewItem(item) {
826 + await getText('Adding item ' + item);
827 + serverCart = [...serverCart, item];
828 + React.startTransition(() => {
829 + root.render(<App cart={serverCart} />);
830 + });
831 + }
832 +
833 + let addItemToCart;
834 + function App({cart}) {
835 + const [isPending, startTransition] = useTransition();
836 +
837 + const savedCartSize = cart.length;
838 + const [optimisticCartSize, addToOptimisticCart] = useOptimisticState(
839 + savedCartSize,
840 + (prevSize, newItem) => {
841 + Scheduler.log('Increment optimistic cart size for ' + newItem);
842 + return prevSize + 1;
843 + },
844 + );
845 +
846 + addItemToCart = item => {
847 + startTransition(async () => {
848 + addToOptimisticCart(item);
849 + await submitNewItem(item);
850 + });
851 + };
852 +
853 + return (
854 + <>
855 + <div>
856 + <Text text={'Pending: ' + isPending} />
857 + </div>
858 + <div>
859 + <Text text={'Items in cart: ' + optimisticCartSize} />
860 + </div>
861 + <ul>
862 + {cart.map(item => (
863 + <li key={item}>
864 + <Text text={'Item ' + item} />
865 + </li>
866 + ))}
867 + </ul>
868 + </>
869 + );
870 + }
871 +
872 + // Initial render
873 + const root = ReactNoop.createRoot();
874 + await act(() => root.render(<App cart={serverCart} />));
875 + assertLog(['Pending: false', 'Items in cart: 1', 'Item A']);
876 + expect(root).toMatchRenderedOutput(
877 + <>
878 + <div>Pending: false</div>
879 + <div>Items in cart: 1</div>
880 + <ul>
881 + <li>Item A</li>
882 + </ul>
883 + </>,
884 + );
885 +
886 + // The cart size is incremented even though B hasn't been added yet.
887 + await act(() => addItemToCart('B'));
888 + assertLog([
889 + 'Increment optimistic cart size for B',
890 + 'Pending: true',
891 + 'Items in cart: 2',
892 + 'Item A',
893 + ]);
894 + expect(root).toMatchRenderedOutput(
895 + <>
896 + <div>Pending: true</div>
897 + <div>Items in cart: 2</div>
898 + <ul>
899 + <li>Item A</li>
900 + </ul>
901 + </>,
902 + );
903 +
904 + // While B is still pending, another item gets added to the cart
905 + // out-of-band.
906 + serverCart = [...serverCart, 'C'];
907 + // NOTE: This is a synchronous update only because we don't yet support
908 + // parallel transitions; all transitions are entangled together. Once we add
909 + // support for parallel transitions, we can update this test.
910 + ReactNoop.flushSync(() => root.render(<App cart={serverCart} />));
911 + assertLog([
912 + 'Increment optimistic cart size for B',
913 + 'Pending: true',
914 + // Note that the optimistic cart size is still correct, because the
915 + // pending update was rebased on top new value.
916 + 'Items in cart: 3',
917 + 'Item A',
918 + 'Item C',
919 + ]);
920 + expect(root).toMatchRenderedOutput(
921 + <>
922 + <div>Pending: true</div>
923 + <div>Items in cart: 3</div>
924 + <ul>
925 + <li>Item A</li>
926 + <li>Item C</li>
927 + </ul>
928 + </>,
929 + );
930 +
931 + // Finish loading B. The optimistic state is reverted.
932 + await act(() => resolveText('Adding item B'));
933 + assertLog([
934 + 'Pending: false',
935 + 'Items in cart: 3',
936 + 'Item A',
937 + 'Item C',
938 + 'Item B',
939 + ]);
940 + expect(root).toMatchRenderedOutput(
941 + <>
942 + <div>Pending: false</div>
943 + <div>Items in cart: 3</div>
944 + <ul>
945 + <li>Item A</li>
946 + <li>Item C</li>
947 + <li>Item B</li>
948 + </ul>
949 + </>,
950 + );
951 + });
952 +
953 + // @gate enableAsyncActions
954 + test('useOptimisticState rebases if the passthrough is updated during a render phase update', async () => {
955 + // This is kind of an esoteric case where it's hard to come up with a
956 + // realistic real-world scenario but it should still work.
957 + let increment;
958 + let setCount;
959 + function App() {
960 + const [isPending, startTransition] = useTransition(2);
961 + const [count, _setCount] = useState(0);
962 + setCount = _setCount;
963 +
964 + const [optimisticCount, setOptimisticCount] = useOptimisticState(
965 + count,
966 + prev => {
967 + Scheduler.log('Increment optimistic count');
968 + return prev + 1;
969 + },
970 + );
971 +
972 + if (count === 1) {
973 + Scheduler.log('Render phase update count from 1 to 2');
974 + setCount(2);
975 + }
976 +
977 + increment = () =>
978 + startTransition(async () => {
979 + setOptimisticCount(n => n + 1);
980 + await getText('Wait to increment');
981 + React.startTransition(() => setCount(n => n + 1));
982 + });
983 +
984 + return (
985 + <>
986 + <div>
987 + <Text text={'Count: ' + count} />
988 + </div>
989 + {isPending ? (
990 + <div>
991 + <Text text={'Optimistic count: ' + optimisticCount} />
992 + </div>
993 + ) : null}
994 + </>
995 + );
996 + }
997 +
998 + const root = ReactNoop.createRoot();
999 + await act(() => root.render(<App />));
1000 + assertLog(['Count: 0']);
1001 + expect(root).toMatchRenderedOutput(<div>Count: 0</div>);
1002 +
1003 + await act(() => increment());
1004 + assertLog([
1005 + 'Increment optimistic count',
1006 + 'Count: 0',
1007 + 'Optimistic count: 1',
1008 + ]);
1009 + expect(root).toMatchRenderedOutput(
1010 + <>
1011 + <div>Count: 0</div>
1012 + <div>Optimistic count: 1</div>
1013 + </>,
1014 + );
1015 +
1016 + await act(() => setCount(1));
1017 + assertLog([
1018 + 'Increment optimistic count',
1019 + 'Render phase update count from 1 to 2',
1020 + // The optimistic update is rebased on top of the new passthrough value.
1021 + 'Increment optimistic count',
1022 + 'Count: 2',
1023 + 'Optimistic count: 3',
1024 + ]);
1025 + expect(root).toMatchRenderedOutput(
1026 + <>
1027 + <div>Count: 2</div>
1028 + <div>Optimistic count: 3</div>
1029 + </>,
1030 + );
1031 +
1032 + // Finish the action
1033 + await act(() => resolveText('Wait to increment'));
1034 + assertLog(['Count: 3']);
1035 + expect(root).toMatchRenderedOutput(<div>Count: 3</div>);
1036 + });
1037 +
1038 + // @gate enableAsyncActions
1039 + test('useOptimisticState rebases if the passthrough is updated during a render phase update (initial mount)', async () => {
1040 + // This is kind of an esoteric case where it's hard to come up with a
1041 + // realistic real-world scenario but it should still work.
1042 + function App() {
1043 + const [count, setCount] = useState(0);
1044 + const [optimisticCount] = useOptimisticState(count);
1045 +
1046 + if (count === 0) {
1047 + Scheduler.log('Render phase update count from 1 to 2');
1048 + setCount(1);
1049 + }
1050 +
1051 + return (
1052 + <>
1053 + <div>
1054 + <Text text={'Count: ' + count} />
1055 + </div>
1056 + <div>
1057 + <Text text={'Optimistic count: ' + optimisticCount} />
1058 + </div>
1059 + </>
1060 + );
1061 + }
1062 +
1063 + const root = ReactNoop.createRoot();
1064 + await act(() => root.render(<App />));
1065 + assertLog([
1066 + 'Render phase update count from 1 to 2',
1067 + 'Count: 1',
1068 + 'Optimistic count: 1',
1069 + ]);
1070 + expect(root).toMatchRenderedOutput(
1071 + <>
1072 + <div>Count: 1</div>
1073 + <div>Optimistic count: 1</div>
1074 + </>,
1075 + );
1076 + });
1077 });
packages/react-server/src/ReactFizzHooks.js
+15
@@ -553,6 +553,18 @@ function useHostTransitionStatus(): TransitionStatus {
553 return NotPendingTransition;
554 }
555
556 +function unsupportedSetOptimisticState() {
557 + throw new Error('Cannot update optimistic state while rendering.');
558 +}
559 +
560 +function useOptimisticState<S, A>(
561 + passthrough: S,
562 + reducer: ?(S, A) => S,
563 +): [S, (A) => void] {
564 + resolveCurrentlyRenderingComponent();
565 + return [passthrough, unsupportedSetOptimisticState];
566 +}
567 +
568 function useId(): string {
569 const task: Task = (currentlyRenderingTask: any);
570 const treeId = getTreeId(task.treeContext);
@@ -652,6 +664,9 @@ if (enableUseMemoCacheHook) {
664 if (enableFormActions && enableAsyncActions) {
665 HooksDispatcher.useHostTransitionStatus = useHostTransitionStatus;
666 }
667 +if (enableAsyncActions) {
668 + HooksDispatcher.useOptimisticState = useOptimisticState;
669 +}
670
671 export let currentResponseState: null | ResponseState = (null: any);
672 export function setCurrentResponseState(
packages/react/index.classic.fb.js
+1
@@ -59,6 +59,7 @@ export {
59 useMemo,
60 useMutableSource,
61 useMutableSource as unstable_useMutableSource,
62 + experimental_useOptimisticState,
63 useReducer,
64 useRef,
65 useState,
packages/react/index.experimental.js
+1
@@ -49,6 +49,7 @@ export {
49 useInsertionEffect,
50 useLayoutEffect,
51 useMemo,
52 + experimental_useOptimisticState,
53 useReducer,
54 useRef,
55 useState,
packages/react/index.js
+1
@@ -77,6 +77,7 @@ export {
77 useLayoutEffect,
78 useMemo,
79 useMutableSource,
80 + experimental_useOptimisticState,
81 useSyncExternalStore,
82 useReducer,
83 useRef,
packages/react/index.modern.fb.js
+1
@@ -57,6 +57,7 @@ export {
57 useMemo,
58 useMutableSource,
59 useMutableSource as unstable_useMutableSource,
60 + experimental_useOptimisticState,
61 useReducer,
62 useRef,
63 useState,
packages/react/src/React.js
+2
@@ -59,6 +59,7 @@ import {
59 useCacheRefresh,
60 use,
61 useMemoCache,
62 + useOptimisticState,
63 } from './ReactHooks';
64 import {
65 createElementWithValidation,
@@ -112,6 +113,7 @@ export {
113 useLayoutEffect,
114 useMemo,
115 useMutableSource,
116 + useOptimisticState as experimental_useOptimisticState,
117 useSyncExternalStore,
118 useReducer,
119 useRef,
packages/react/src/ReactHooks.js
+9
@@ -241,3 +241,12 @@ export function useEffectEvent<Args, F: (...Array<Args>) => mixed>(
241 // $FlowFixMe[not-a-function] This is unstable, thus optional
242 return dispatcher.useEffectEvent(callback);
243 }
244 +
245 +export function useOptimisticState<S, A>(
246 + passthrough: S,
247 + reducer: ?(S, A) => S,
248 +): [S, (A) => void] {
249 + const dispatcher = resolveDispatcher();
250 + // $FlowFixMe[not-a-function] This is unstable, thus optional
251 + return dispatcher.useOptimisticState(passthrough, reducer);
252 +}
scripts/error-codes/codes.json
+2 -1
@@ -463,5 +463,6 @@
463 "475": "Internal React Error: suspendedState null when it was expected to exists. Please report this as a React bug.",
464 "476": "Expected the form instance to be a HostComponent. This is a bug in React.",
465 "477": "React Internal Error: processHintChunk is not implemented for Native-Relay. The fact that this method was called means there is a bug in React.",
466 - "478": "Thenable should have already resolved. This is a bug in React."
466 + "478": "Thenable should have already resolved. This is a bug in React.",
467 + "479": "Cannot update optimistic state while rendering."
468 }