diff --git a/packages/react-dom-bindings/src/shared/ReactDOMFormActions.js b/packages/react-dom-bindings/src/shared/ReactDOMFormActions.js index 28914b354ba71..ec29a67b508af 100644 --- a/packages/react-dom-bindings/src/shared/ReactDOMFormActions.js +++ b/packages/react-dom-bindings/src/shared/ReactDOMFormActions.js @@ -76,7 +76,7 @@ export function useFormStatus(): FormStatus { } export function useFormState( - action: (S, P) => S, + action: (S, P) => Promise, initialState: S, url?: string, ): [S, (P) => void] { diff --git a/packages/react-dom/src/__tests__/ReactDOMForm-test.js b/packages/react-dom/src/__tests__/ReactDOMForm-test.js index 184ef8ca74b45..97cbf1aa622ff 100644 --- a/packages/react-dom/src/__tests__/ReactDOMForm-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMForm-test.js @@ -35,6 +35,7 @@ describe('ReactDOMForm', () => { let ReactDOMClient; let Scheduler; let assertLog; + let waitForThrow; let useState; let Suspense; let startTransition; @@ -50,6 +51,7 @@ describe('ReactDOMForm', () => { Scheduler = require('scheduler'); act = require('internal-test-utils').act; assertLog = require('internal-test-utils').assertLog; + waitForThrow = require('internal-test-utils').waitForThrow; useState = React.useState; Suspense = React.Suspense; startTransition = React.startTransition; @@ -974,15 +976,28 @@ describe('ReactDOMForm', () => { // @gate enableFormActions // @gate enableAsyncActions - test('useFormState exists', async () => { - // TODO: Not yet implemented. This just tests that the API is wired up. - - async function action(state) { - return state; + test('useFormState updates state asynchronously and queues multiple actions', async () => { + let actionCounter = 0; + async function action(state, type) { + actionCounter++; + + Scheduler.log(`Async action started [${actionCounter}]`); + await getText(`Wait [${actionCounter}]`); + + switch (type) { + case 'increment': + return state + 1; + case 'decrement': + return state - 1; + default: + return state; + } } + let dispatch; function App() { - const [state] = useFormState(action, 0); + const [state, _dispatch] = useFormState(action, 0); + dispatch = _dispatch; return ; } @@ -990,5 +1005,108 @@ describe('ReactDOMForm', () => { await act(() => root.render()); assertLog([0]); expect(container.textContent).toBe('0'); + + await act(() => dispatch('increment')); + assertLog(['Async action started [1]']); + expect(container.textContent).toBe('0'); + + // Dispatch a few more actions. None of these will start until the previous + // one finishes. + await act(() => dispatch('increment')); + await act(() => dispatch('decrement')); + await act(() => dispatch('increment')); + assertLog([]); + + // Each action starts as soon as the previous one finishes. + // NOTE: React does not render in between these actions because they all + // update the same queue, which means they get entangled together. This is + // intentional behavior. + await act(() => resolveText('Wait [1]')); + assertLog(['Async action started [2]']); + await act(() => resolveText('Wait [2]')); + assertLog(['Async action started [3]']); + await act(() => resolveText('Wait [3]')); + assertLog(['Async action started [4]']); + await act(() => resolveText('Wait [4]')); + + // Finally the last action finishes and we can render the result. + assertLog([2]); + expect(container.textContent).toBe('2'); + }); + + // @gate enableFormActions + // @gate enableAsyncActions + test('useFormState supports inline actions', async () => { + let increment; + function App({stepSize}) { + const [state, dispatch] = useFormState(async prevState => { + return prevState + stepSize; + }, 0); + increment = dispatch; + return ; + } + + // Initial render + const root = ReactDOMClient.createRoot(container); + await act(() => root.render()); + assertLog([0]); + + // Perform an action. This will increase the state by 1, as defined by the + // stepSize prop. + await act(() => increment()); + assertLog([1]); + + // Now increase the stepSize prop to 10. Subsequent steps will increase + // by this amount. + await act(() => root.render()); + assertLog([1]); + + // Increment again. The state should increase by 10. + await act(() => increment()); + assertLog([11]); + }); + + // @gate enableFormActions + // @gate enableAsyncActions + test('useFormState: dispatch throws if called during render', async () => { + function App() { + const [state, dispatch] = useFormState(async () => {}, 0); + dispatch(); + return ; + } + + const root = ReactDOMClient.createRoot(container); + await act(async () => { + root.render(); + await waitForThrow('Cannot update form state while rendering.'); + }); + }); + + // @gate enableFormActions + // @gate enableAsyncActions + test('useFormState: warns if action is not async', async () => { + let dispatch; + function App() { + const [state, _dispatch] = useFormState(() => {}, 0); + dispatch = _dispatch; + return ; + } + + const root = ReactDOMClient.createRoot(container); + await act(async () => { + root.render(); + }); + assertLog([0]); + + expect(() => { + // This throws because React expects the action to return a promise. + expect(() => dispatch()).toThrow('Cannot read properties of undefined'); + }).toErrorDev( + [ + // In dev we also log a warning. + 'The action passed to useFormState must be an async function', + ], + {withoutStack: true}, + ); }); }); diff --git a/packages/react-reconciler/src/ReactFiberAsyncAction.js b/packages/react-reconciler/src/ReactFiberAsyncAction.js index ef721ca77bae3..910f47c0831c9 100644 --- a/packages/react-reconciler/src/ReactFiberAsyncAction.js +++ b/packages/react-reconciler/src/ReactFiberAsyncAction.js @@ -34,97 +34,108 @@ let currentEntangledPendingCount: number = 0; let currentEntangledLane: Lane = NoLane; export function requestAsyncActionContext( - actionReturnValue: mixed, - finishedState: S, -): Thenable | S { - if ( - actionReturnValue !== null && - typeof actionReturnValue === 'object' && - typeof actionReturnValue.then === 'function' - ) { - // This is an async action. - // - // Return a thenable that resolves once the action scope (i.e. the async - // function passed to startTransition) has finished running. + actionReturnValue: Thenable, + // If this is provided, this resulting thenable resolves to this value instead + // of the return value of the action. This is a perf trick to avoid composing + // an extra async function. + overrideReturnValue: S | null, +): Thenable { + // This is an async action. + // + // Return a thenable that resolves once the action scope (i.e. the async + // function passed to startTransition) has finished running. - const thenable: Thenable = (actionReturnValue: any); - let entangledListeners; - if (currentEntangledListeners === null) { - // There's no outer async action scope. Create a new one. - entangledListeners = currentEntangledListeners = []; - currentEntangledPendingCount = 0; - currentEntangledLane = requestTransitionLane(); - } else { - entangledListeners = currentEntangledListeners; - } + const thenable: Thenable = (actionReturnValue: any); + let entangledListeners; + if (currentEntangledListeners === null) { + // There's no outer async action scope. Create a new one. + entangledListeners = currentEntangledListeners = []; + currentEntangledPendingCount = 0; + currentEntangledLane = requestTransitionLane(); + } else { + entangledListeners = currentEntangledListeners; + } - currentEntangledPendingCount++; - let resultStatus = 'pending'; - let rejectedReason; - thenable.then( - () => { - resultStatus = 'fulfilled'; - pingEngtangledActionScope(); - }, - error => { - resultStatus = 'rejected'; - rejectedReason = error; - pingEngtangledActionScope(); - }, - ); + currentEntangledPendingCount++; - // Create a thenable that represents the result of this action, but doesn't - // resolve until the entire entangled scope has finished. - // - // Expressed using promises: - // const [thisResult] = await Promise.all([thisAction, entangledAction]); - // return thisResult; - const resultThenable = createResultThenable(entangledListeners); + // Create a thenable that represents the result of this action, but doesn't + // resolve until the entire entangled scope has finished. + // + // Expressed using promises: + // const [thisResult] = await Promise.all([thisAction, entangledAction]); + // return thisResult; + const resultThenable = createResultThenable(entangledListeners); - // Attach a listener to fill in the result. - entangledListeners.push(() => { - switch (resultStatus) { - case 'fulfilled': { - const fulfilledThenable: FulfilledThenable = (resultThenable: any); - fulfilledThenable.status = 'fulfilled'; - fulfilledThenable.value = finishedState; - break; - } - case 'rejected': { - const rejectedThenable: RejectedThenable = (resultThenable: any); - rejectedThenable.status = 'rejected'; - rejectedThenable.reason = rejectedReason; - break; - } - case 'pending': - default: { - // The listener above should have been called first, so `resultStatus` - // should already be set to the correct value. - throw new Error( - 'Thenable should have already resolved. This ' + - 'is a bug in React.', - ); - } - } - }); + let resultStatus = 'pending'; + let resultValue; + let rejectedReason; + thenable.then( + (value: S) => { + resultStatus = 'fulfilled'; + resultValue = overrideReturnValue !== null ? overrideReturnValue : value; + pingEngtangledActionScope(); + }, + error => { + resultStatus = 'rejected'; + rejectedReason = error; + pingEngtangledActionScope(); + }, + ); - return resultThenable; - } else { - // This is not an async action, but it may be part of an outer async action. - if (currentEntangledListeners === null) { - return finishedState; - } else { - // Return a thenable that does not resolve until the entangled actions - // have finished. - const entangledListeners = currentEntangledListeners; - const resultThenable = createResultThenable(entangledListeners); - entangledListeners.push(() => { + // Attach a listener to fill in the result. + entangledListeners.push(() => { + switch (resultStatus) { + case 'fulfilled': { const fulfilledThenable: FulfilledThenable = (resultThenable: any); fulfilledThenable.status = 'fulfilled'; - fulfilledThenable.value = finishedState; - }); - return resultThenable; + fulfilledThenable.value = resultValue; + break; + } + case 'rejected': { + const rejectedThenable: RejectedThenable = (resultThenable: any); + rejectedThenable.status = 'rejected'; + rejectedThenable.reason = rejectedReason; + break; + } + case 'pending': + default: { + // The listener above should have been called first, so `resultStatus` + // should already be set to the correct value. + throw new Error( + 'Thenable should have already resolved. This ' + 'is a bug in React.', + ); + } } + }); + + return resultThenable; +} + +export function requestSyncActionContext( + actionReturnValue: mixed, + // If this is provided, this resulting thenable resolves to this value instead + // of the return value of the action. This is a perf trick to avoid composing + // an extra async function. + overrideReturnValue: S | null, +): Thenable | S { + const resultValue: S = + overrideReturnValue !== null + ? overrideReturnValue + : (actionReturnValue: any); + // This is not an async action, but it may be part of an outer async action. + if (currentEntangledListeners === null) { + return resultValue; + } else { + // Return a thenable that does not resolve until the entangled actions + // have finished. + const entangledListeners = currentEntangledListeners; + const resultThenable = createResultThenable(entangledListeners); + entangledListeners.push(() => { + const fulfilledThenable: FulfilledThenable = (resultThenable: any); + fulfilledThenable.status = 'fulfilled'; + fulfilledThenable.value = resultValue; + }); + return resultThenable; } } diff --git a/packages/react-reconciler/src/ReactFiberHooks.js b/packages/react-reconciler/src/ReactFiberHooks.js index 2b27777c6c399..d45dbaedc7753 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.js +++ b/packages/react-reconciler/src/ReactFiberHooks.js @@ -137,7 +137,10 @@ import { } from './ReactFiberThenable'; import type {ThenableState} from './ReactFiberThenable'; import type {BatchConfigTransition} from './ReactFiberTracingMarkerComponent'; -import {requestAsyncActionContext} from './ReactFiberAsyncAction'; +import { + requestAsyncActionContext, + requestSyncActionContext, +} from './ReactFiberAsyncAction'; import {HostTransitionContext} from './ReactFiberHostContext'; import {requestTransitionLane} from './ReactFiberRootScheduler'; @@ -1854,35 +1857,304 @@ function rerenderOptimistic( return [passthrough, dispatch]; } -function TODO_formStateDispatch() { - throw new Error('Not implemented.'); +// useFormState actions run sequentially, because each action receives the +// previous state as an argument. We store pending actions on a queue. +type FormStateActionQueue = { + // This is the most recent state returned from an action. It's updated as + // soon as the action finishes running. + state: S, + // A stable dispatch method, passed to the user. + dispatch: Dispatch

, + // This is the most recent action function that was rendered. It's updated + // during the commit phase. + action: (S, P) => Promise, + // This is a circular linked list of pending action payloads. It incudes the + // action that is currently running. + pending: FormStateActionQueueNode

| null, +}; + +type FormStateActionQueueNode

= { + payload: P, + // This is never null because it's part of a circular linked list. + next: FormStateActionQueueNode

, +}; + +function dispatchFormState( + fiber: Fiber, + actionQueue: FormStateActionQueue, + setState: Dispatch>, + payload: P, +): void { + if (isRenderPhaseUpdate(fiber)) { + throw new Error('Cannot update form state while rendering.'); + } + const last = actionQueue.pending; + if (last === null) { + // There are no pending actions; this is the first one. We can run + // it immediately. + const newLast: FormStateActionQueueNode

= { + payload, + next: (null: any), // circular + }; + newLast.next = actionQueue.pending = newLast; + + runFormStateAction(actionQueue, setState, payload); + } else { + // There's already an action running. Add to the queue. + const first = last.next; + const newLast: FormStateActionQueueNode

= { + payload, + next: first, + }; + last.next = newLast; + } +} + +function runFormStateAction( + actionQueue: FormStateActionQueue, + setState: Dispatch>, + payload: P, +) { + const action = actionQueue.action; + const prevState = actionQueue.state; + + // This is a fork of startTransition + const prevTransition = ReactCurrentBatchConfig.transition; + ReactCurrentBatchConfig.transition = ({}: BatchConfigTransition); + const currentTransition = ReactCurrentBatchConfig.transition; + if (__DEV__) { + ReactCurrentBatchConfig.transition._updatedFibers = new Set(); + } + try { + const promise = action(prevState, payload); + + if (__DEV__) { + if ( + promise === null || + typeof promise !== 'object' || + typeof (promise: any).then !== 'function' + ) { + console.error( + 'The action passed to useFormState must be an async function.', + ); + } + } + + // Attach a listener to read the return state of the action. As soon as this + // resolves, we can run the next action in the sequence. + promise.then( + (nextState: S) => { + actionQueue.state = nextState; + finishRunningFormStateAction(actionQueue, setState); + }, + () => finishRunningFormStateAction(actionQueue, setState), + ); + + // Create a thenable that resolves once the current async action scope has + // finished. Then stash that thenable in state. We'll unwrap it with the + // `use` algorithm during render. This is the same logic used + // by startTransition. + const entangledThenable: Thenable = requestAsyncActionContext( + promise, + null, + ); + setState(entangledThenable); + } finally { + ReactCurrentBatchConfig.transition = prevTransition; + + if (__DEV__) { + if (prevTransition === null && currentTransition._updatedFibers) { + const updatedFibersCount = currentTransition._updatedFibers.size; + currentTransition._updatedFibers.clear(); + if (updatedFibersCount > 10) { + console.warn( + 'Detected a large number of updates inside startTransition. ' + + 'If this is due to a subscription please re-write it to use React provided hooks. ' + + 'Otherwise concurrent mode guarantees are off the table.', + ); + } + } + } + } +} + +function finishRunningFormStateAction( + actionQueue: FormStateActionQueue, + setState: Dispatch>, +) { + // The action finished running. Pop it from the queue and run the next pending + // action, if there are any. + const last = actionQueue.pending; + if (last !== null) { + const first = last.next; + if (first === last) { + // This was the last action in the queue. + actionQueue.pending = null; + } else { + // Remove the first node from the circular queue. + const next = first.next; + last.next = next; + + // Run the next action. + runFormStateAction(actionQueue, setState, next.payload); + } + } +} + +function formStateReducer(oldState: S, newState: S): S { + return newState; } function mountFormState( - action: (S, P) => S, + action: (S, P) => Promise, initialState: S, url?: string, ): [S, (P) => void] { - // TODO: Not yet implemented - return [initialState, TODO_formStateDispatch]; + // State hook. The state is stored in a thenable which is then unwrapped by + // the `use` algorithm during render. + const stateHook = mountWorkInProgressHook(); + stateHook.memoizedState = stateHook.baseState = { + status: 'fulfilled', + value: initialState, + }; + const stateQueue: UpdateQueue, Thenable> = { + pending: null, + lanes: NoLanes, + dispatch: null, + lastRenderedReducer: formStateReducer, + lastRenderedState: (initialState: any), + }; + stateHook.queue = stateQueue; + const setState: Dispatch> = (dispatchSetState.bind( + null, + currentlyRenderingFiber, + stateQueue, + ): any); + stateQueue.dispatch = setState; + + // Action queue hook. This is used to queue pending actions. The queue is + // shared between all instances of the hook. Similar to a regular state queue, + // but different because the actions are run sequentially, and they run in + // an event instead of during render. + const actionQueueHook = mountWorkInProgressHook(); + const actionQueue: FormStateActionQueue = { + state: initialState, + dispatch: (null: any), // circular + action, + pending: null, + }; + actionQueueHook.queue = actionQueue; + const dispatch = dispatchFormState.bind( + null, + currentlyRenderingFiber, + actionQueue, + setState, + ); + actionQueue.dispatch = dispatch; + + // Stash the action function on the memoized state of the hook. We'll use this + // to detect when the action function changes so we can update it in + // an effect. + actionQueueHook.memoizedState = action; + + return [initialState, dispatch]; } function updateFormState( - action: (S, P) => S, + action: (S, P) => Promise, initialState: S, url?: string, ): [S, (P) => void] { - // TODO: Not yet implemented - return [initialState, TODO_formStateDispatch]; + const stateHook = updateWorkInProgressHook(); + const currentStateHook = ((currentHook: any): Hook); + return updateFormStateImpl( + stateHook, + currentStateHook, + action, + initialState, + url, + ); +} + +function updateFormStateImpl( + stateHook: Hook, + currentStateHook: Hook, + action: (S, P) => Promise, + initialState: S, + url?: string, +): [S, (P) => void] { + const [thenable] = updateReducerImpl, Thenable>( + stateHook, + currentStateHook, + formStateReducer, + ); + + // This will suspend until the action finishes. + const state = useThenable(thenable); + + const actionQueueHook = updateWorkInProgressHook(); + const actionQueue = actionQueueHook.queue; + const dispatch = actionQueue.dispatch; + + // Check if a new action was passed. If so, update it in an effect. + const prevAction = actionQueueHook.memoizedState; + if (action !== prevAction) { + currentlyRenderingFiber.flags |= PassiveEffect; + pushEffect( + HookHasEffect | HookPassive, + formStateActionEffect.bind(null, actionQueue, action), + createEffectInstance(), + null, + ); + } + + return [state, dispatch]; +} + +function formStateActionEffect( + actionQueue: FormStateActionQueue, + action: (S, P) => Promise, +): void { + actionQueue.action = action; } function rerenderFormState( - action: (S, P) => S, + action: (S, P) => Promise, initialState: S, url?: string, ): [S, (P) => void] { - // TODO: Not yet implemented - return [initialState, TODO_formStateDispatch]; + // Unlike useState, useFormState 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. + const stateHook = updateWorkInProgressHook(); + const currentStateHook = currentHook; + + if (currentStateHook !== null) { + // This is an update. Process the update queue. + return updateFormStateImpl( + stateHook, + currentStateHook, + action, + initialState, + url, + ); + } + + // This is a mount. No updates to process. + const state = stateHook.memoizedState; + + const actionQueueHook = updateWorkInProgressHook(); + const actionQueue = actionQueueHook.queue; + const dispatch = actionQueue.dispatch; + + // This may have changed during the rerender. + actionQueueHook.memoizedState = action; + + return [state, dispatch]; } function pushEffect( @@ -2459,15 +2731,37 @@ function startTransition( if (enableAsyncActions) { const returnValue = callback(); - // This is either `finishedState` or a thenable that resolves to - // `finishedState`, depending on whether the action scope is an async - // function. In the async case, the resulting render will suspend until - // the async action scope has finished. - const maybeThenable = requestAsyncActionContext( - returnValue, - finishedState, - ); - dispatchSetState(fiber, queue, maybeThenable); + // Check if we're inside an async action scope. If so, we'll entangle + // this new action with the existing scope. + // + // If we're not already inside an async action scope, and this action is + // async, then we'll create a new async scope. + // + // In the async case, the resulting render will suspend until the async + // action scope has finished. + if ( + returnValue !== null && + typeof returnValue === 'object' && + typeof returnValue.then === 'function' + ) { + const thenable = ((returnValue: any): Thenable); + // This is a thenable that resolves to `finishedState` once the async + // action scope has finished. + const entangledResult = requestAsyncActionContext( + thenable, + finishedState, + ); + dispatchSetState(fiber, queue, entangledResult); + } else { + // This is either `finishedState` or a thenable that resolves to + // `finishedState`, depending on whether we're inside an async + // action scope. + const entangledResult = requestSyncActionContext( + returnValue, + finishedState, + ); + dispatchSetState(fiber, queue, entangledResult); + } } else { // Async actions are not enabled. dispatchSetState(fiber, queue, finishedState); @@ -3332,7 +3626,7 @@ if (__DEV__) { useHostTransitionStatus; (HooksDispatcherOnMountInDEV: Dispatcher).useFormState = function useFormState( - action: (S, P) => S, + action: (S, P) => Promise, initialState: S, url?: string, ): [S, (P) => void] { @@ -3502,7 +3796,7 @@ if (__DEV__) { useHostTransitionStatus; (HooksDispatcherOnMountWithHookTypesInDEV: Dispatcher).useFormState = function useFormState( - action: (S, P) => S, + action: (S, P) => Promise, initialState: S, url?: string, ): [S, (P) => void] { @@ -3674,7 +3968,7 @@ if (__DEV__) { useHostTransitionStatus; (HooksDispatcherOnUpdateInDEV: Dispatcher).useFormState = function useFormState( - action: (S, P) => S, + action: (S, P) => Promise, initialState: S, url?: string, ): [S, (P) => void] { @@ -3846,7 +4140,7 @@ if (__DEV__) { useHostTransitionStatus; (HooksDispatcherOnRerenderInDEV: Dispatcher).useFormState = function useFormState( - action: (S, P) => S, + action: (S, P) => Promise, initialState: S, url?: string, ): [S, (P) => void] { @@ -4039,7 +4333,7 @@ if (__DEV__) { useHostTransitionStatus; (InvalidNestedHooksDispatcherOnMountInDEV: Dispatcher).useFormState = function useFormState( - action: (S, P) => S, + action: (S, P) => Promise, initialState: S, url?: string, ): [S, (P) => void] { @@ -4237,7 +4531,7 @@ if (__DEV__) { useHostTransitionStatus; (InvalidNestedHooksDispatcherOnUpdateInDEV: Dispatcher).useFormState = function useFormState( - action: (S, P) => S, + action: (S, P) => Promise, initialState: S, url?: string, ): [S, (P) => void] { @@ -4435,7 +4729,7 @@ if (__DEV__) { useHostTransitionStatus; (InvalidNestedHooksDispatcherOnRerenderInDEV: Dispatcher).useFormState = function useFormState( - action: (S, P) => S, + action: (S, P) => Promise, initialState: S, url?: string, ): [S, (P) => void] { diff --git a/packages/react-reconciler/src/ReactInternalTypes.js b/packages/react-reconciler/src/ReactInternalTypes.js index 2372fc13051ba..e852057f20c1a 100644 --- a/packages/react-reconciler/src/ReactInternalTypes.js +++ b/packages/react-reconciler/src/ReactInternalTypes.js @@ -415,7 +415,7 @@ export type Dispatcher = { reducer: ?(S, A) => S, ) => [S, (A) => void], useFormState?: ( - action: (S, P) => S, + action: (S, P) => Promise, initialState: S, url?: string, ) => [S, (P) => void], diff --git a/packages/react-server/src/ReactFizzHooks.js b/packages/react-server/src/ReactFizzHooks.js index 7fa62950229b3..0df810e88da60 100644 --- a/packages/react-server/src/ReactFizzHooks.js +++ b/packages/react-server/src/ReactFizzHooks.js @@ -555,7 +555,7 @@ function useOptimistic( } function useFormState( - action: (S, P) => S, + action: (S, P) => Promise, initialState: S, url?: string, ): [S, (P) => void] {