From 491aec5d6113ce5bae7c10966bc38a4a8fc091a8 Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Mon, 1 May 2023 13:19:20 -0400 Subject: [PATCH] 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. --- .../src/__tests__/TimelineProfiler-test.js | 21 +- .../src/__tests__/ReactDOMFizzForm-test.js | 19 + .../react-reconciler/src/ReactFiberHooks.js | 402 ++++++++++++++-- .../src/ReactInternalTypes.js | 7 +- .../src/__tests__/ReactAsyncActions-test.js | 430 ++++++++++++++++++ packages/react-server/src/ReactFizzHooks.js | 15 + packages/react/index.classic.fb.js | 1 + packages/react/index.experimental.js | 1 + packages/react/index.js | 1 + packages/react/index.modern.fb.js | 1 + packages/react/src/React.js | 2 + packages/react/src/ReactHooks.js | 9 + scripts/error-codes/codes.json | 3 +- 13 files changed, 846 insertions(+), 66 deletions(-) diff --git a/packages/react-devtools-shared/src/__tests__/TimelineProfiler-test.js b/packages/react-devtools-shared/src/__tests__/TimelineProfiler-test.js index 32e90276825e4..cd9081d8b95b2 100644 --- a/packages/react-devtools-shared/src/__tests__/TimelineProfiler-test.js +++ b/packages/react-devtools-shared/src/__tests__/TimelineProfiler-test.js @@ -1243,10 +1243,9 @@ describe('Timeline profiler', () => { function Example() { const setHigh = React.useState(0)[1]; const setLow = React.useState(0)[1]; - const startTransition = React.useTransition()[1]; updaterFn = () => { - startTransition(() => { + React.startTransition(() => { setLow(prevLow => prevLow + 1); }); setHigh(prevHigh => prevHigh + 1); @@ -1265,24 +1264,6 @@ describe('Timeline profiler', () => { const timelineData = stopProfilingAndGetTimelineData(); expect(timelineData.schedulingEvents).toMatchInlineSnapshot(` [ - { - "componentName": "Example", - "componentStack": " - in Example (at **)", - "lanes": "0b0000000000000000000000000001000", - "timestamp": 10, - "type": "schedule-state-update", - "warning": null, - }, - { - "componentName": "Example", - "componentStack": " - in Example (at **)", - "lanes": "0b0000000000000000000000010000000", - "timestamp": 10, - "type": "schedule-state-update", - "warning": null, - }, { "componentName": "Example", "componentStack": " diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzForm-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzForm-test.js index efd151995d08a..5d3f3ea004cb2 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzForm-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzForm-test.js @@ -22,6 +22,7 @@ let React; let ReactDOMServer; let ReactDOMClient; let useFormStatus; +let useOptimisticState; describe('ReactDOMFizzForm', () => { beforeEach(() => { @@ -30,6 +31,7 @@ describe('ReactDOMFizzForm', () => { ReactDOMServer = require('react-dom/server.browser'); ReactDOMClient = require('react-dom/client'); useFormStatus = require('react-dom').experimental_useFormStatus; + useOptimisticState = require('react').experimental_useOptimisticState; act = require('internal-test-utils').act; container = document.createElement('div'); document.body.appendChild(container); @@ -453,4 +455,21 @@ describe('ReactDOMFizzForm', () => { expect(deletedTitle).toBe('Hello'); expect(rootActionCalled).toBe(false); }); + + // @gate enableAsyncActions + it('useOptimisticState returns passthrough value', async () => { + function App() { + const [optimisticState] = useOptimisticState('hi'); + return optimisticState; + } + + const stream = await ReactDOMServer.renderToReadableStream(); + await readIntoContainer(stream); + expect(container.textContent).toBe('hi'); + + await act(async () => { + ReactDOMClient.hydrateRoot(container, ); + }); + expect(container.textContent).toBe('hi'); + }); }); diff --git a/packages/react-reconciler/src/ReactFiberHooks.js b/packages/react-reconciler/src/ReactFiberHooks.js index dd239a2ca162d..63ce2a8e655b5 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.js +++ b/packages/react-reconciler/src/ReactFiberHooks.js @@ -149,11 +149,13 @@ import type {ThenableState} from './ReactFiberThenable'; import type {BatchConfigTransition} from './ReactFiberTracingMarkerComponent'; import {requestAsyncActionContext} from './ReactFiberAsyncAction'; import {HostTransitionContext} from './ReactFiberHostContext'; +import {requestTransitionLane} from './ReactFiberRootScheduler'; const {ReactCurrentDispatcher, ReactCurrentBatchConfig} = ReactSharedInternals; export type Update = { lane: Lane, + revertLane: Lane, action: A, hasEagerState: boolean, eagerState: S | null, @@ -1136,6 +1138,14 @@ function updateReducer( init?: I => S, ): [S, Dispatch] { const hook = updateWorkInProgressHook(); + return updateReducerImpl(hook, ((currentHook: any): Hook), reducer); +} + +function updateReducerImpl( + hook: Hook, + current: Hook, + reducer: (S, A) => S, +): [S, Dispatch] { const queue = hook.queue; if (queue === null) { @@ -1146,10 +1156,8 @@ function updateReducer( queue.lastRenderedReducer = reducer; - const current: Hook = (currentHook: any); - // The last rebase update that is NOT part of the base state. - let baseQueue = current.baseQueue; + let baseQueue = hook.baseQueue; // The last pending update that hasn't been processed yet. const pendingQueue = queue.pending; @@ -1180,7 +1188,7 @@ function updateReducer( if (baseQueue !== null) { // We have a queue to process. const first = baseQueue.next; - let newState = current.baseState; + let newState = hook.baseState; let newBaseState = null; let newBaseQueueFirst = null; @@ -1206,6 +1214,7 @@ function updateReducer( // update/state. const clone: Update = { lane: updateLane, + revertLane: update.revertLane, action: update.action, hasEagerState: update.hasEagerState, eagerState: update.eagerState, @@ -1228,18 +1237,68 @@ function updateReducer( } else { // This update does have sufficient priority. - if (newBaseQueueLast !== null) { - const clone: Update = { - // This update is going to be committed so we never want uncommit - // it. Using NoLane works because 0 is a subset of all bitmasks, so - // this will never be skipped by the check above. - lane: NoLane, - action: update.action, - hasEagerState: update.hasEagerState, - eagerState: update.eagerState, - next: (null: any), - }; - newBaseQueueLast = newBaseQueueLast.next = clone; + // Check if this is an optimistic update. + const revertLane = update.revertLane; + if (!enableAsyncActions || revertLane === NoLane) { + // This is not an optimistic update, and we're going to apply it now. + // But, if there were earlier updates that were skipped, we need to + // leave this update in the queue so it can be rebased later. + if (newBaseQueueLast !== null) { + const clone: Update = { + // This update is going to be committed so we never want uncommit + // it. Using NoLane works because 0 is a subset of all bitmasks, so + // this will never be skipped by the check above. + lane: NoLane, + revertLane: NoLane, + action: update.action, + hasEagerState: update.hasEagerState, + eagerState: update.eagerState, + next: (null: any), + }; + newBaseQueueLast = newBaseQueueLast.next = clone; + } + } else { + // This is an optimistic update. If the "revert" priority is + // sufficient, don't apply the update. Otherwise, apply the update, + // but leave it in the queue so it can be either reverted or + // rebased in a subsequent render. + if (isSubsetOfLanes(renderLanes, revertLane)) { + // The transition that this optimistic update is associated with + // has finished. Pretend the update doesn't exist by skipping + // over it. + update = update.next; + continue; + } else { + const clone: Update = { + // Once we commit an optimistic update, we shouldn't uncommit it + // until the transition it is associated with has finished + // (represented by revertLane). Using NoLane here works because 0 + // is a subset of all bitmasks, so this will never be skipped by + // the check above. + lane: NoLane, + // Reuse the same revertLane so we know when the transition + // has finished. + revertLane: update.revertLane, + action: update.action, + hasEagerState: update.hasEagerState, + eagerState: update.eagerState, + next: (null: any), + }; + if (newBaseQueueLast === null) { + newBaseQueueFirst = newBaseQueueLast = clone; + newBaseState = newState; + } else { + newBaseQueueLast = newBaseQueueLast.next = clone; + } + // Update the remaining priority in the queue. + // TODO: Don't need to accumulate this. Instead, we can remove + // renderLanes from the original lanes. + currentlyRenderingFiber.lanes = mergeLanes( + currentlyRenderingFiber.lanes, + revertLane, + ); + markSkippedUpdateLanes(revertLane); + } } // Process this update. @@ -1884,9 +1943,7 @@ function forceStoreRerender(fiber: Fiber) { } } -function mountState( - initialState: (() => S) | S, -): [S, Dispatch>] { +function mountStateImpl(initialState: (() => S) | S): Hook { const hook = mountWorkInProgressHook(); if (typeof initialState === 'function') { // $FlowFixMe[incompatible-use]: Flow doesn't like mixed types @@ -1901,21 +1958,106 @@ function mountState( lastRenderedState: (initialState: any), }; hook.queue = queue; - const dispatch: Dispatch> = (queue.dispatch = - (dispatchSetState.bind(null, currentlyRenderingFiber, queue): any)); + return hook; +} + +function mountState( + initialState: (() => S) | S, +): [S, Dispatch>] { + const hook = mountStateImpl(initialState); + const queue = hook.queue; + const dispatch: Dispatch> = (dispatchSetState.bind( + null, + currentlyRenderingFiber, + queue, + ): any); + queue.dispatch = dispatch; return [hook.memoizedState, dispatch]; } function updateState( initialState: (() => S) | S, ): [S, Dispatch>] { - return updateReducer(basicStateReducer, (initialState: any)); + return updateReducer(basicStateReducer, initialState); } function rerenderState( initialState: (() => S) | S, ): [S, Dispatch>] { - return rerenderReducer(basicStateReducer, (initialState: any)); + return rerenderReducer(basicStateReducer, initialState); +} + +function mountOptimisticState( + passthrough: S, + reducer: ?(S, A) => S, +): [S, (A) => void] { + const hook = mountWorkInProgressHook(); + hook.memoizedState = hook.baseState = passthrough; + const queue: UpdateQueue = { + pending: null, + lanes: NoLanes, + dispatch: null, + // Optimistic state does not use the eager update optimization. + lastRenderedReducer: null, + lastRenderedState: null, + }; + hook.queue = queue; + // This is different than the normal setState function. + const dispatch: A => void = (dispatchOptimisticSetState.bind( + null, + currentlyRenderingFiber, + true, + queue, + ): any); + queue.dispatch = dispatch; + return [passthrough, dispatch]; +} + +function updateOptimisticState( + passthrough: S, + reducer: ?(S, A) => S, +): [S, (A) => void] { + const hook = updateWorkInProgressHook(); + + // Optimistic updates are always rebased on top of the latest value passed in + // as an argument. It's called a passthrough because if there are no pending + // updates, it will be returned as-is. + // + // Reset the base state and memoized state to the passthrough. Future + // updates will be applied on top of this. + hook.baseState = hook.memoizedState = passthrough; + + // If a reducer is not provided, default to the same one used by useState. + const resolvedReducer: (S, A) => S = + typeof reducer === 'function' ? reducer : (basicStateReducer: any); + + return updateReducerImpl(hook, ((currentHook: any): Hook), resolvedReducer); +} + +function rerenderOptimisticState( + passthrough: S, + reducer: ?(S, A) => S, +): [S, (A) => void] { + // Unlike useState, useOptimisticState doesn't support render phase updates. + // Also unlike useState, we need to replay all pending updates again in case + // the passthrough value changed. + // + // So instead of a forked re-render implementation that knows how to handle + // render phase udpates, we can use the same implementation as during a + // regular mount or update. + + if (currentHook !== null) { + // This is an update. Process the update queue. + return updateOptimisticState(passthrough, reducer); + } + + // This is a mount. No updates to process. + const hook = updateWorkInProgressHook(); + // Reset the base state and memoized state to the passthrough. Future + // updates will be applied on top of this. + hook.baseState = hook.memoizedState = passthrough; + const dispatch = hook.queue.dispatch; + return [passthrough, dispatch]; } function pushEffect( @@ -2445,9 +2587,10 @@ function updateDeferredValueImpl(hook: Hook, prevValue: T, value: T): T { } function startTransition( + fiber: Fiber, + queue: UpdateQueue, BasicStateAction>>, pendingState: S, finishedState: S, - setPending: (Thenable | S) => void, callback: () => mixed, options?: StartTransitionOptions, ): void { @@ -2457,8 +2600,20 @@ function startTransition( ); const prevTransition = ReactCurrentBatchConfig.transition; - ReactCurrentBatchConfig.transition = null; - setPending(pendingState); + + if (enableAsyncActions) { + // We don't really need to use an optimistic update here, because we + // schedule a second "revert" update below (which we use to suspend the + // transition until the async action scope has finished). But we'll use an + // optimistic update anyway to make it less likely the behavior accidentally + // diverges; for example, both an optimistic update and this one should + // share the same lane. + dispatchOptimisticSetState(fiber, false, queue, pendingState); + } else { + ReactCurrentBatchConfig.transition = null; + dispatchSetState(fiber, queue, pendingState); + } + const currentTransition = (ReactCurrentBatchConfig.transition = ({}: BatchConfigTransition)); @@ -2485,10 +2640,10 @@ function startTransition( returnValue, finishedState, ); - setPending(maybeThenable); + dispatchSetState(fiber, queue, maybeThenable); } else { // Async actions are not enabled. - setPending(finishedState); + dispatchSetState(fiber, queue, finishedState); callback(); } } catch (error) { @@ -2501,7 +2656,7 @@ function startTransition( status: 'rejected', reason: error, }; - setPending(rejectedThenable); + dispatchSetState(fiber, queue, rejectedThenable); } else { // The error rethrowing behavior is only enabled when the async actions // feature is on, even for sync actions. @@ -2553,7 +2708,10 @@ export function startHostTransition( ); } - let setPending; + let queue: UpdateQueue< + Thenable | TransitionStatus, + BasicStateAction | TransitionStatus>, + >; if (formFiber.memoizedState === null) { // Upgrade this host component fiber to be stateful. We're going to pretend // it was stateful all along so we can reuse most of the implementation @@ -2561,28 +2719,28 @@ export function startHostTransition( // // Create the state hook used by TransitionAwareHostComponent. This is // essentially an inlined version of mountState. - const queue: UpdateQueue< - Thenable | TransitionStatus, + const newQueue: UpdateQueue< Thenable | TransitionStatus, + BasicStateAction | TransitionStatus>, > = { pending: null, lanes: NoLanes, - dispatch: null, + // We're going to cheat and intentionally not create a bound dispatch + // method, because we can call it directly in startTransition. + dispatch: (null: any), lastRenderedReducer: basicStateReducer, lastRenderedState: NoPendingHostTransition, }; + queue = newQueue; + const stateHook: Hook = { memoizedState: NoPendingHostTransition, baseState: NoPendingHostTransition, baseQueue: null, - queue: queue, + queue: newQueue, next: null, }; - const dispatch: (Thenable | TransitionStatus) => void = - (dispatchSetState.bind(null, formFiber, queue): any); - setPending = queue.dispatch = dispatch; - // Add the state hook to both fiber alternates. The idea is that the fiber // had this hook all along. formFiber.memoizedState = stateHook; @@ -2593,15 +2751,14 @@ export function startHostTransition( } else { // This fiber was already upgraded to be stateful. const stateHook: Hook = formFiber.memoizedState; - const dispatch: (Thenable | TransitionStatus) => void = - stateHook.queue.dispatch; - setPending = dispatch; + queue = stateHook.queue; } startTransition( + formFiber, + queue, pendingState, NoPendingHostTransition, - setPending, // TODO: We can avoid this extra wrapper, somehow. Figure out layering // once more of this function is implemented. () => callback(formData), @@ -2612,9 +2769,15 @@ function mountTransition(): [ boolean, (callback: () => void, options?: StartTransitionOptions) => void, ] { - const [, setPending] = mountState((false: Thenable | boolean)); + const stateHook = mountStateImpl((false: Thenable | boolean)); // The `start` method never changes. - const start = startTransition.bind(null, true, false, setPending); + const start = startTransition.bind( + null, + currentlyRenderingFiber, + stateHook.queue, + true, + false, + ); const hook = mountWorkInProgressHook(); hook.memoizedState = start; return [false, start]; @@ -2785,6 +2948,7 @@ function dispatchReducerAction( const update: Update = { lane, + revertLane: NoLane, action, hasEagerState: false, eagerState: null, @@ -2823,6 +2987,7 @@ function dispatchSetState( const update: Update = { lane, + revertLane: NoLane, action, hasEagerState: false, eagerState: null, @@ -2886,6 +3051,58 @@ function dispatchSetState( markUpdateInDevTools(fiber, lane, action); } +function dispatchOptimisticSetState( + fiber: Fiber, + throwIfDuringRender: boolean, + queue: UpdateQueue, + action: A, +): void { + const update: Update = { + // An optimistic update commits synchronously. + lane: SyncLane, + // After committing, the optimistic update is "reverted" using the same + // lane as the transition it's associated with. + // + // TODO: Warn if there's no transition/action associated with this + // optimistic update. + revertLane: requestTransitionLane(), + action, + hasEagerState: false, + eagerState: null, + next: (null: any), + }; + + if (isRenderPhaseUpdate(fiber)) { + // When calling startTransition during render, this warns instead of + // throwing because throwing would be a breaking change. setOptimisticState + // is a new API so it's OK to throw. + if (throwIfDuringRender) { + throw new Error('Cannot update optimistic state while rendering.'); + } else { + // startTransition was called during render. We don't need to do anything + // besides warn here because the render phase update would be overidden by + // the second update, anyway. We can remove this branch and make it throw + // in a future release. + if (__DEV__) { + console.error('Cannot call startTransition while rendering.'); + } + } + } else { + const root = enqueueConcurrentHookUpdate(fiber, queue, update, SyncLane); + if (root !== null) { + // NOTE: The optimistic update implementation assumes that the transition + // will never be attempted before the optimistic update. This currently + // holds because the optimistic update is always synchronous. If we ever + // change that, we'll need to account for this. + scheduleUpdateOnFiber(root, fiber, SyncLane); + // Optimistic updates are always synchronous, so we don't need to call + // entangleTransitionUpdate here. + } + } + + markUpdateInDevTools(fiber, SyncLane, action); +} + function isRenderPhaseUpdate(fiber: Fiber): boolean { const alternate = fiber.alternate; return ( @@ -2989,6 +3206,10 @@ if (enableFormActions && enableAsyncActions) { (ContextOnlyDispatcher: Dispatcher).useHostTransitionStatus = throwInvalidHookError; } +if (enableAsyncActions) { + (ContextOnlyDispatcher: Dispatcher).useOptimisticState = + throwInvalidHookError; +} const HooksDispatcherOnMount: Dispatcher = { readContext, @@ -3024,6 +3245,11 @@ if (enableFormActions && enableAsyncActions) { (HooksDispatcherOnMount: Dispatcher).useHostTransitionStatus = useHostTransitionStatus; } +if (enableAsyncActions) { + (HooksDispatcherOnMount: Dispatcher).useOptimisticState = + mountOptimisticState; +} + const HooksDispatcherOnUpdate: Dispatcher = { readContext, @@ -3058,6 +3284,10 @@ if (enableFormActions && enableAsyncActions) { (HooksDispatcherOnUpdate: Dispatcher).useHostTransitionStatus = useHostTransitionStatus; } +if (enableAsyncActions) { + (HooksDispatcherOnUpdate: Dispatcher).useOptimisticState = + updateOptimisticState; +} const HooksDispatcherOnRerender: Dispatcher = { readContext, @@ -3093,6 +3323,10 @@ if (enableFormActions && enableAsyncActions) { (HooksDispatcherOnRerender: Dispatcher).useHostTransitionStatus = useHostTransitionStatus; } +if (enableAsyncActions) { + (HooksDispatcherOnRerender: Dispatcher).useOptimisticState = + rerenderOptimisticState; +} let HooksDispatcherOnMountInDEV: Dispatcher | null = null; let HooksDispatcherOnMountWithHookTypesInDEV: Dispatcher | null = null; @@ -3283,6 +3517,17 @@ if (__DEV__) { (HooksDispatcherOnMountInDEV: Dispatcher).useHostTransitionStatus = useHostTransitionStatus; } + if (enableAsyncActions) { + (HooksDispatcherOnMountInDEV: Dispatcher).useOptimisticState = + function useOptimisticState( + passthrough: S, + reducer: ?(S, A) => S, + ): [S, (A) => void] { + currentHookNameInDev = 'useOptimisticState'; + mountHookTypesDev(); + return mountOptimisticState(passthrough, reducer); + }; + } HooksDispatcherOnMountWithHookTypesInDEV = { readContext(context: ReactContext): T { @@ -3441,6 +3686,17 @@ if (__DEV__) { (HooksDispatcherOnMountWithHookTypesInDEV: Dispatcher).useHostTransitionStatus = useHostTransitionStatus; } + if (enableAsyncActions) { + (HooksDispatcherOnMountWithHookTypesInDEV: Dispatcher).useOptimisticState = + function useOptimisticState( + passthrough: S, + reducer: ?(S, A) => S, + ): [S, (A) => void] { + currentHookNameInDev = 'useOptimisticState'; + updateHookTypesDev(); + return mountOptimisticState(passthrough, reducer); + }; + } HooksDispatcherOnUpdateInDEV = { readContext(context: ReactContext): T { @@ -3601,6 +3857,17 @@ if (__DEV__) { (HooksDispatcherOnUpdateInDEV: Dispatcher).useHostTransitionStatus = useHostTransitionStatus; } + if (enableAsyncActions) { + (HooksDispatcherOnUpdateInDEV: Dispatcher).useOptimisticState = + function useOptimisticState( + passthrough: S, + reducer: ?(S, A) => S, + ): [S, (A) => void] { + currentHookNameInDev = 'useOptimisticState'; + updateHookTypesDev(); + return updateOptimisticState(passthrough, reducer); + }; + } HooksDispatcherOnRerenderInDEV = { readContext(context: ReactContext): T { @@ -3761,6 +4028,17 @@ if (__DEV__) { (HooksDispatcherOnRerenderInDEV: Dispatcher).useHostTransitionStatus = useHostTransitionStatus; } + if (enableAsyncActions) { + (HooksDispatcherOnRerenderInDEV: Dispatcher).useOptimisticState = + function useOptimisticState( + passthrough: S, + reducer: ?(S, A) => S, + ): [S, (A) => void] { + currentHookNameInDev = 'useOptimisticState'; + updateHookTypesDev(); + return rerenderOptimisticState(passthrough, reducer); + }; + } InvalidNestedHooksDispatcherOnMountInDEV = { readContext(context: ReactContext): T { @@ -3943,6 +4221,18 @@ if (__DEV__) { (InvalidNestedHooksDispatcherOnMountInDEV: Dispatcher).useHostTransitionStatus = useHostTransitionStatus; } + if (enableAsyncActions) { + (InvalidNestedHooksDispatcherOnMountInDEV: Dispatcher).useOptimisticState = + function useOptimisticState( + passthrough: S, + reducer: ?(S, A) => S, + ): [S, (A) => void] { + currentHookNameInDev = 'useOptimisticState'; + warnInvalidHookAccess(); + mountHookTypesDev(); + return mountOptimisticState(passthrough, reducer); + }; + } InvalidNestedHooksDispatcherOnUpdateInDEV = { readContext(context: ReactContext): T { @@ -4128,6 +4418,18 @@ if (__DEV__) { (InvalidNestedHooksDispatcherOnUpdateInDEV: Dispatcher).useHostTransitionStatus = useHostTransitionStatus; } + if (enableAsyncActions) { + (InvalidNestedHooksDispatcherOnUpdateInDEV: Dispatcher).useOptimisticState = + function useOptimisticState( + passthrough: S, + reducer: ?(S, A) => S, + ): [S, (A) => void] { + currentHookNameInDev = 'useOptimisticState'; + warnInvalidHookAccess(); + updateHookTypesDev(); + return updateOptimisticState(passthrough, reducer); + }; + } InvalidNestedHooksDispatcherOnRerenderInDEV = { readContext(context: ReactContext): T { @@ -4313,4 +4615,16 @@ if (__DEV__) { (InvalidNestedHooksDispatcherOnRerenderInDEV: Dispatcher).useHostTransitionStatus = useHostTransitionStatus; } + if (enableAsyncActions) { + (InvalidNestedHooksDispatcherOnRerenderInDEV: Dispatcher).useOptimisticState = + function useOptimisticState( + passthrough: S, + reducer: ?(S, A) => S, + ): [S, (A) => void] { + currentHookNameInDev = 'useOptimisticState'; + warnInvalidHookAccess(); + updateHookTypesDev(); + return rerenderOptimisticState(passthrough, reducer); + }; + } } diff --git a/packages/react-reconciler/src/ReactInternalTypes.js b/packages/react-reconciler/src/ReactInternalTypes.js index b177db722d0ca..c70ea5a1a5640 100644 --- a/packages/react-reconciler/src/ReactInternalTypes.js +++ b/packages/react-reconciler/src/ReactInternalTypes.js @@ -57,7 +57,8 @@ export type HookType = | 'useMutableSource' | 'useSyncExternalStore' | 'useId' - | 'useCacheRefresh'; + | 'useCacheRefresh' + | 'useOptimisticState'; export type ContextDependency = { context: ReactContext, @@ -423,6 +424,10 @@ export type Dispatcher = { useCacheRefresh?: () => (?() => T, ?T) => void, useMemoCache?: (size: number) => Array, useHostTransitionStatus?: () => TransitionStatus, + useOptimisticState?: ( + passthrough: S, + reducer: ?(S, A) => S, + ) => [S, (A) => void], }; export type CacheDispatcher = { diff --git a/packages/react-reconciler/src/__tests__/ReactAsyncActions-test.js b/packages/react-reconciler/src/__tests__/ReactAsyncActions-test.js index 654ebf62e05f3..7937067d1b2ec 100644 --- a/packages/react-reconciler/src/__tests__/ReactAsyncActions-test.js +++ b/packages/react-reconciler/src/__tests__/ReactAsyncActions-test.js @@ -5,6 +5,7 @@ let act; let assertLog; let useTransition; let useState; +let useOptimisticState; let textCache; describe('ReactAsyncActions', () => { @@ -18,6 +19,7 @@ describe('ReactAsyncActions', () => { assertLog = require('internal-test-utils').assertLog; useTransition = React.useTransition; useState = React.useState; + useOptimisticState = React.experimental_useOptimisticState; textCache = new Map(); }); @@ -644,4 +646,432 @@ describe('ReactAsyncActions', () => { , ); }); + + // @gate enableAsyncActions + test('useOptimisticState can be used to implement a pending state', async () => { + const startTransition = React.startTransition; + + let setIsPending; + function App({text}) { + const [isPending, _setIsPending] = useOptimisticState(false); + setIsPending = _setIsPending; + return ( + <> + + + + ); + } + + // Initial render + const root = ReactNoop.createRoot(); + resolveText('A'); + await act(() => root.render()); + assertLog(['Pending: false', 'A']); + expect(root).toMatchRenderedOutput('Pending: falseA'); + + // Start a transition + await act(() => + startTransition(() => { + setIsPending(true); + root.render(); + }), + ); + assertLog([ + // Render the pending state immediately + 'Pending: true', + 'A', + + // Then attempt to render the transition. The pending state will be + // automatically reverted. + 'Pending: false', + 'Suspend! [B]', + ]); + + // Resolve the transition + await act(() => resolveText('B')); + assertLog([ + // Render the pending state immediately + 'Pending: false', + 'B', + ]); + }); + + // @gate enableAsyncActions + test('useOptimisticState rebases pending updates on top of passthrough value', async () => { + let serverCart = ['A']; + + async function submitNewItem(item) { + await getText('Adding item ' + item); + serverCart = [...serverCart, item]; + React.startTransition(() => { + root.render(); + }); + } + + let addItemToCart; + function App({cart}) { + const [isPending, startTransition] = useTransition(); + + const savedCartSize = cart.length; + const [optimisticCartSize, setOptimisticCartSize] = + useOptimisticState(savedCartSize); + + addItemToCart = item => { + startTransition(async () => { + setOptimisticCartSize(n => n + 1); + await submitNewItem(item); + }); + }; + + return ( + <> +
+ +
+
+ +
+
    + {cart.map(item => ( +
  • + +
  • + ))} +
+ + ); + } + + // Initial render + const root = ReactNoop.createRoot(); + await act(() => root.render()); + assertLog(['Pending: false', 'Items in cart: 1', 'Item A']); + expect(root).toMatchRenderedOutput( + <> +
Pending: false
+
Items in cart: 1
+
    +
  • Item A
  • +
+ , + ); + + // The cart size is incremented even though B hasn't been added yet. + await act(() => addItemToCart('B')); + assertLog(['Pending: true', 'Items in cart: 2', 'Item A']); + expect(root).toMatchRenderedOutput( + <> +
Pending: true
+
Items in cart: 2
+
    +
  • Item A
  • +
+ , + ); + + // While B is still pending, another item gets added to the cart + // out-of-band. + serverCart = [...serverCart, 'C']; + // NOTE: This is a synchronous update only because we don't yet support + // parallel transitions; all transitions are entangled together. Once we add + // support for parallel transitions, we can update this test. + ReactNoop.flushSync(() => root.render()); + assertLog([ + 'Pending: true', + // Note that the optimistic cart size is still correct, because the + // pending update was rebased on top new value. + 'Items in cart: 3', + 'Item A', + 'Item C', + ]); + expect(root).toMatchRenderedOutput( + <> +
Pending: true
+
Items in cart: 3
+
    +
  • Item A
  • +
  • Item C
  • +
+ , + ); + + // Finish loading B. The optimistic state is reverted. + await act(() => resolveText('Adding item B')); + assertLog([ + 'Pending: false', + 'Items in cart: 3', + 'Item A', + 'Item C', + 'Item B', + ]); + expect(root).toMatchRenderedOutput( + <> +
Pending: false
+
Items in cart: 3
+
    +
  • Item A
  • +
  • Item C
  • +
  • Item B
  • +
+ , + ); + }); + + // @gate enableAsyncActions + test('useOptimisticState accepts a custom reducer', async () => { + let serverCart = ['A']; + + async function submitNewItem(item) { + await getText('Adding item ' + item); + serverCart = [...serverCart, item]; + React.startTransition(() => { + root.render(); + }); + } + + let addItemToCart; + function App({cart}) { + const [isPending, startTransition] = useTransition(); + + const savedCartSize = cart.length; + const [optimisticCartSize, addToOptimisticCart] = useOptimisticState( + savedCartSize, + (prevSize, newItem) => { + Scheduler.log('Increment optimistic cart size for ' + newItem); + return prevSize + 1; + }, + ); + + addItemToCart = item => { + startTransition(async () => { + addToOptimisticCart(item); + await submitNewItem(item); + }); + }; + + return ( + <> +
+ +
+
+ +
+
    + {cart.map(item => ( +
  • + +
  • + ))} +
+ + ); + } + + // Initial render + const root = ReactNoop.createRoot(); + await act(() => root.render()); + assertLog(['Pending: false', 'Items in cart: 1', 'Item A']); + expect(root).toMatchRenderedOutput( + <> +
Pending: false
+
Items in cart: 1
+
    +
  • Item A
  • +
+ , + ); + + // The cart size is incremented even though B hasn't been added yet. + await act(() => addItemToCart('B')); + assertLog([ + 'Increment optimistic cart size for B', + 'Pending: true', + 'Items in cart: 2', + 'Item A', + ]); + expect(root).toMatchRenderedOutput( + <> +
Pending: true
+
Items in cart: 2
+
    +
  • Item A
  • +
+ , + ); + + // While B is still pending, another item gets added to the cart + // out-of-band. + serverCart = [...serverCart, 'C']; + // NOTE: This is a synchronous update only because we don't yet support + // parallel transitions; all transitions are entangled together. Once we add + // support for parallel transitions, we can update this test. + ReactNoop.flushSync(() => root.render()); + assertLog([ + 'Increment optimistic cart size for B', + 'Pending: true', + // Note that the optimistic cart size is still correct, because the + // pending update was rebased on top new value. + 'Items in cart: 3', + 'Item A', + 'Item C', + ]); + expect(root).toMatchRenderedOutput( + <> +
Pending: true
+
Items in cart: 3
+
    +
  • Item A
  • +
  • Item C
  • +
+ , + ); + + // Finish loading B. The optimistic state is reverted. + await act(() => resolveText('Adding item B')); + assertLog([ + 'Pending: false', + 'Items in cart: 3', + 'Item A', + 'Item C', + 'Item B', + ]); + expect(root).toMatchRenderedOutput( + <> +
Pending: false
+
Items in cart: 3
+
    +
  • Item A
  • +
  • Item C
  • +
  • Item B
  • +
+ , + ); + }); + + // @gate enableAsyncActions + test('useOptimisticState rebases if the passthrough is updated during a render phase update', async () => { + // This is kind of an esoteric case where it's hard to come up with a + // realistic real-world scenario but it should still work. + let increment; + let setCount; + function App() { + const [isPending, startTransition] = useTransition(2); + const [count, _setCount] = useState(0); + setCount = _setCount; + + const [optimisticCount, setOptimisticCount] = useOptimisticState( + count, + prev => { + Scheduler.log('Increment optimistic count'); + return prev + 1; + }, + ); + + if (count === 1) { + Scheduler.log('Render phase update count from 1 to 2'); + setCount(2); + } + + increment = () => + startTransition(async () => { + setOptimisticCount(n => n + 1); + await getText('Wait to increment'); + React.startTransition(() => setCount(n => n + 1)); + }); + + return ( + <> +
+ +
+ {isPending ? ( +
+ +
+ ) : null} + + ); + } + + const root = ReactNoop.createRoot(); + await act(() => root.render()); + assertLog(['Count: 0']); + expect(root).toMatchRenderedOutput(
Count: 0
); + + await act(() => increment()); + assertLog([ + 'Increment optimistic count', + 'Count: 0', + 'Optimistic count: 1', + ]); + expect(root).toMatchRenderedOutput( + <> +
Count: 0
+
Optimistic count: 1
+ , + ); + + await act(() => setCount(1)); + assertLog([ + 'Increment optimistic count', + 'Render phase update count from 1 to 2', + // The optimistic update is rebased on top of the new passthrough value. + 'Increment optimistic count', + 'Count: 2', + 'Optimistic count: 3', + ]); + expect(root).toMatchRenderedOutput( + <> +
Count: 2
+
Optimistic count: 3
+ , + ); + + // Finish the action + await act(() => resolveText('Wait to increment')); + assertLog(['Count: 3']); + expect(root).toMatchRenderedOutput(
Count: 3
); + }); + + // @gate enableAsyncActions + test('useOptimisticState rebases if the passthrough is updated during a render phase update (initial mount)', async () => { + // This is kind of an esoteric case where it's hard to come up with a + // realistic real-world scenario but it should still work. + function App() { + const [count, setCount] = useState(0); + const [optimisticCount] = useOptimisticState(count); + + if (count === 0) { + Scheduler.log('Render phase update count from 1 to 2'); + setCount(1); + } + + return ( + <> +
+ +
+
+ +
+ + ); + } + + const root = ReactNoop.createRoot(); + await act(() => root.render()); + assertLog([ + 'Render phase update count from 1 to 2', + 'Count: 1', + 'Optimistic count: 1', + ]); + expect(root).toMatchRenderedOutput( + <> +
Count: 1
+
Optimistic count: 1
+ , + ); + }); }); diff --git a/packages/react-server/src/ReactFizzHooks.js b/packages/react-server/src/ReactFizzHooks.js index 332c1721d5e6e..b42a5e469320e 100644 --- a/packages/react-server/src/ReactFizzHooks.js +++ b/packages/react-server/src/ReactFizzHooks.js @@ -553,6 +553,18 @@ function useHostTransitionStatus(): TransitionStatus { return NotPendingTransition; } +function unsupportedSetOptimisticState() { + throw new Error('Cannot update optimistic state while rendering.'); +} + +function useOptimisticState( + passthrough: S, + reducer: ?(S, A) => S, +): [S, (A) => void] { + resolveCurrentlyRenderingComponent(); + return [passthrough, unsupportedSetOptimisticState]; +} + function useId(): string { const task: Task = (currentlyRenderingTask: any); const treeId = getTreeId(task.treeContext); @@ -652,6 +664,9 @@ if (enableUseMemoCacheHook) { if (enableFormActions && enableAsyncActions) { HooksDispatcher.useHostTransitionStatus = useHostTransitionStatus; } +if (enableAsyncActions) { + HooksDispatcher.useOptimisticState = useOptimisticState; +} export let currentResponseState: null | ResponseState = (null: any); export function setCurrentResponseState( diff --git a/packages/react/index.classic.fb.js b/packages/react/index.classic.fb.js index 48caeb5e13894..274602af47f4b 100644 --- a/packages/react/index.classic.fb.js +++ b/packages/react/index.classic.fb.js @@ -59,6 +59,7 @@ export { useMemo, useMutableSource, useMutableSource as unstable_useMutableSource, + experimental_useOptimisticState, useReducer, useRef, useState, diff --git a/packages/react/index.experimental.js b/packages/react/index.experimental.js index 876aa3a761758..1b9937e682aa9 100644 --- a/packages/react/index.experimental.js +++ b/packages/react/index.experimental.js @@ -49,6 +49,7 @@ export { useInsertionEffect, useLayoutEffect, useMemo, + experimental_useOptimisticState, useReducer, useRef, useState, diff --git a/packages/react/index.js b/packages/react/index.js index 2ed91ec25b7e6..f7a190f13694a 100644 --- a/packages/react/index.js +++ b/packages/react/index.js @@ -77,6 +77,7 @@ export { useLayoutEffect, useMemo, useMutableSource, + experimental_useOptimisticState, useSyncExternalStore, useReducer, useRef, diff --git a/packages/react/index.modern.fb.js b/packages/react/index.modern.fb.js index 68adc9a224641..166d19b77daef 100644 --- a/packages/react/index.modern.fb.js +++ b/packages/react/index.modern.fb.js @@ -57,6 +57,7 @@ export { useMemo, useMutableSource, useMutableSource as unstable_useMutableSource, + experimental_useOptimisticState, useReducer, useRef, useState, diff --git a/packages/react/src/React.js b/packages/react/src/React.js index 38d40ece0503b..9605916fcb214 100644 --- a/packages/react/src/React.js +++ b/packages/react/src/React.js @@ -59,6 +59,7 @@ import { useCacheRefresh, use, useMemoCache, + useOptimisticState, } from './ReactHooks'; import { createElementWithValidation, @@ -112,6 +113,7 @@ export { useLayoutEffect, useMemo, useMutableSource, + useOptimisticState as experimental_useOptimisticState, useSyncExternalStore, useReducer, useRef, diff --git a/packages/react/src/ReactHooks.js b/packages/react/src/ReactHooks.js index 6d157cbc60289..2323bdbc29c20 100644 --- a/packages/react/src/ReactHooks.js +++ b/packages/react/src/ReactHooks.js @@ -241,3 +241,12 @@ export function useEffectEvent) => mixed>( // $FlowFixMe[not-a-function] This is unstable, thus optional return dispatcher.useEffectEvent(callback); } + +export function useOptimisticState( + passthrough: S, + reducer: ?(S, A) => S, +): [S, (A) => void] { + const dispatcher = resolveDispatcher(); + // $FlowFixMe[not-a-function] This is unstable, thus optional + return dispatcher.useOptimisticState(passthrough, reducer); +} diff --git a/scripts/error-codes/codes.json b/scripts/error-codes/codes.json index 9f378cea27489..8d7812c2f650d 100644 --- a/scripts/error-codes/codes.json +++ b/scripts/error-codes/codes.json @@ -463,5 +463,6 @@ "475": "Internal React Error: suspendedState null when it was expected to exists. Please report this as a React bug.", "476": "Expected the form instance to be a HostComponent. This is a bug in React.", "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.", - "478": "Thenable should have already resolved. This is a bug in React." + "478": "Thenable should have already resolved. This is a bug in React.", + "479": "Cannot update optimistic state while rendering." }