diff --git a/packages/react-debug-tools/src/ReactDebugHooks.js b/packages/react-debug-tools/src/ReactDebugHooks.js index 68ac2587be3d0..20e3d89e1bf64 100644 --- a/packages/react-debug-tools/src/ReactDebugHooks.js +++ b/packages/react-debug-tools/src/ReactDebugHooks.js @@ -521,8 +521,9 @@ function useFormState( action: (Awaited, P) => S, initialState: Awaited, permalink?: string, -): [Awaited, (P) => void] { +): [Awaited, (P) => void, boolean] { const hook = nextHook(); // FormState + nextHook(); // PendingState nextHook(); // ActionQueue const stackError = new Error(); let value; @@ -580,7 +581,9 @@ function useFormState( // value being a Thenable is equivalent to error being not null // i.e. we only reach this point with Awaited const state = ((value: any): Awaited); - return [state, (payload: P) => {}]; + + // TODO: support displaying pending value + return [state, (payload: P) => {}, false]; } const Dispatcher: DispatcherType = { diff --git a/packages/react-dom-bindings/src/shared/ReactDOMFormActions.js b/packages/react-dom-bindings/src/shared/ReactDOMFormActions.js index 492f2065edd77..80d544cd28f44 100644 --- a/packages/react-dom-bindings/src/shared/ReactDOMFormActions.js +++ b/packages/react-dom-bindings/src/shared/ReactDOMFormActions.js @@ -80,7 +80,7 @@ export function useFormState( action: (Awaited, P) => S, initialState: Awaited, permalink?: string, -): [Awaited, (P) => void] { +): [Awaited, (P) => void, boolean] { if (!(enableFormActions && enableAsyncActions)) { throw new Error('Not implemented.'); } else { diff --git a/packages/react-dom/index.experimental.js b/packages/react-dom/index.experimental.js index 7a6e52121f75b..539dbddb5a89f 100644 --- a/packages/react-dom/index.experimental.js +++ b/packages/react-dom/index.experimental.js @@ -45,7 +45,7 @@ export function experimental_useFormState( action: (Awaited, P) => S, initialState: Awaited, permalink?: string, -): [Awaited, (P) => void] { +): [Awaited, (P) => void, boolean] { if (__DEV__) { console.error( 'useFormState is now in canary. Remove the experimental_ prefix. ' + diff --git a/packages/react-dom/server-rendering-stub.js b/packages/react-dom/server-rendering-stub.js index 71d4124ddabfa..db427710333a5 100644 --- a/packages/react-dom/server-rendering-stub.js +++ b/packages/react-dom/server-rendering-stub.js @@ -50,7 +50,7 @@ export function experimental_useFormState( action: (Awaited, P) => S, initialState: Awaited, permalink?: string, -): [Awaited, (P) => void] { +): [Awaited, (P) => void, boolean] { if (__DEV__) { console.error( 'useFormState is now in canary. Remove the experimental_ prefix. ' + diff --git a/packages/react-dom/src/__tests__/ReactDOMForm-test.js b/packages/react-dom/src/__tests__/ReactDOMForm-test.js index 000cfbf70c246..977439b099971 100644 --- a/packages/react-dom/src/__tests__/ReactDOMForm-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMForm-test.js @@ -63,21 +63,6 @@ describe('ReactDOMForm', () => { textCache = new Map(); }); - function resolveText(text) { - const record = textCache.get(text); - if (record === undefined) { - const newRecord = { - status: 'resolved', - value: text, - }; - textCache.set(text, newRecord); - } else if (record.status === 'pending') { - const thenable = record.value; - record.status = 'resolved'; - record.value = text; - thenable.pings.forEach(t => t()); - } - } function resolveText(text) { const record = textCache.get(text); if (record === undefined) { @@ -997,19 +982,20 @@ describe('ReactDOMForm', () => { let dispatch; function App() { - const [state, _dispatch] = useFormState(action, 0); + const [state, _dispatch, isPending] = useFormState(action, 0); dispatch = _dispatch; - return ; + const pending = isPending ? 'Pending ' : ''; + return ; } const root = ReactDOMClient.createRoot(container); await act(() => root.render()); - assertLog([0]); + assertLog(['0']); expect(container.textContent).toBe('0'); await act(() => dispatch('increment')); - assertLog(['Async action started [1]']); - expect(container.textContent).toBe('0'); + assertLog(['Async action started [1]', 'Pending 0']); + expect(container.textContent).toBe('Pending 0'); // Dispatch a few more actions. None of these will start until the previous // one finishes. @@ -1031,7 +1017,7 @@ describe('ReactDOMForm', () => { await act(() => resolveText('Wait [4]')); // Finally the last action finishes and we can render the result. - assertLog([2]); + assertLog(['2']); expect(container.textContent).toBe('2'); }); @@ -1040,40 +1026,42 @@ describe('ReactDOMForm', () => { test('useFormState supports inline actions', async () => { let increment; function App({stepSize}) { - const [state, dispatch] = useFormState(async prevState => { + const [state, dispatch, isPending] = useFormState(async prevState => { return prevState + stepSize; }, 0); increment = dispatch; - return ; + const pending = isPending ? 'Pending ' : ''; + return ; } // Initial render const root = ReactDOMClient.createRoot(container); await act(() => root.render()); - assertLog([0]); + assertLog(['0']); // Perform an action. This will increase the state by 1, as defined by the // stepSize prop. await act(() => increment()); - assertLog([1]); + assertLog(['Pending 0', '1']); // Now increase the stepSize prop to 10. Subsequent steps will increase // by this amount. await act(() => root.render()); - assertLog([1]); + assertLog(['1']); // Increment again. The state should increase by 10. await act(() => increment()); - assertLog([11]); + assertLog(['Pending 1', '11']); }); // @gate enableFormActions // @gate enableAsyncActions test('useFormState: dispatch throws if called during render', async () => { function App() { - const [state, dispatch] = useFormState(async () => {}, 0); + const [state, dispatch, isPending] = useFormState(async () => {}, 0); dispatch(); - return ; + const pending = isPending ? 'Pending ' : ''; + return ; } const root = ReactDOMClient.createRoot(container); @@ -1088,12 +1076,13 @@ describe('ReactDOMForm', () => { test('queues multiple actions and runs them in order', async () => { let action; function App() { - const [state, dispatch] = useFormState( + const [state, dispatch, isPending] = useFormState( async (s, a) => await getText(a), 'A', ); action = dispatch; - return ; + const pending = isPending ? 'Pending ' : ''; + return ; } const root = ReactDOMClient.createRoot(container); @@ -1101,8 +1090,11 @@ describe('ReactDOMForm', () => { assertLog(['A']); await act(() => action('B')); + // The first dispatch will update the pending state. + assertLog(['Pending A']); await act(() => action('C')); await act(() => action('D')); + assertLog([]); await act(() => resolveText('B')); await act(() => resolveText('C')); @@ -1117,31 +1109,32 @@ describe('ReactDOMForm', () => { test('useFormState: works if action is sync', async () => { let increment; function App({stepSize}) { - const [state, dispatch] = useFormState(prevState => { + const [state, dispatch, isPending] = useFormState(prevState => { return prevState + stepSize; }, 0); increment = dispatch; - return ; + const pending = isPending ? 'Pending ' : ''; + return ; } // Initial render const root = ReactDOMClient.createRoot(container); await act(() => root.render()); - assertLog([0]); + assertLog(['0']); // Perform an action. This will increase the state by 1, as defined by the // stepSize prop. await act(() => increment()); - assertLog([1]); + assertLog(['Pending 0', '1']); // Now increase the stepSize prop to 10. Subsequent steps will increase // by this amount. await act(() => root.render()); - assertLog([1]); + assertLog(['1']); // Increment again. The state should increase by 10. await act(() => increment()); - assertLog([11]); + assertLog(['Pending 1', '11']); }); // @gate enableFormActions @@ -1149,9 +1142,10 @@ describe('ReactDOMForm', () => { test('useFormState: can mix sync and async actions', async () => { let action; function App() { - const [state, dispatch] = useFormState((s, a) => a, 'A'); + const [state, dispatch, isPending] = useFormState((s, a) => a, 'A'); action = dispatch; - return ; + const pending = isPending ? 'Pending ' : ''; + return ; } const root = ReactDOMClient.createRoot(container); @@ -1159,9 +1153,12 @@ describe('ReactDOMForm', () => { assertLog(['A']); await act(() => action(getText('B'))); + // The first dispatch will update the pending state. + assertLog(['Pending A']); await act(() => action('C')); await act(() => action(getText('D'))); await act(() => action('E')); + assertLog([]); await act(() => resolveText('B')); await act(() => resolveText('D')); @@ -1189,14 +1186,15 @@ describe('ReactDOMForm', () => { let action; function App() { - const [state, dispatch] = useFormState((s, a) => { + const [state, dispatch, isPending] = useFormState((s, a) => { if (a.endsWith('!')) { throw new Error(a); } return a; }, 'A'); action = dispatch; - return ; + const pending = isPending ? 'Pending ' : ''; + return ; } const root = ReactDOMClient.createRoot(container); @@ -1210,7 +1208,13 @@ describe('ReactDOMForm', () => { assertLog(['A']); await act(() => action('Oops!')); - assertLog(['Caught an error: Oops!', 'Caught an error: Oops!']); + assertLog([ + // Action begins, error has not thrown yet. + 'Pending A', + // Now the action runs and throws. + 'Caught an error: Oops!', + 'Caught an error: Oops!', + ]); expect(container.textContent).toBe('Caught an error: Oops!'); // Reset the error boundary @@ -1223,7 +1227,7 @@ describe('ReactDOMForm', () => { action('Oops!'); action('B'); }); - assertLog(['B']); + assertLog(['Pending A', 'B']); expect(container.textContent).toBe('B'); }); @@ -1247,7 +1251,7 @@ describe('ReactDOMForm', () => { let action; function App() { - const [state, dispatch] = useFormState(async (s, a) => { + const [state, dispatch, isPending] = useFormState(async (s, a) => { const text = await getText(a); if (text.endsWith('!')) { throw new Error(text); @@ -1255,7 +1259,8 @@ describe('ReactDOMForm', () => { return text; }, 'A'); action = dispatch; - return ; + const pending = isPending ? 'Pending ' : ''; + return ; } const root = ReactDOMClient.createRoot(container); @@ -1269,7 +1274,8 @@ describe('ReactDOMForm', () => { assertLog(['A']); await act(() => action('Oops!')); - assertLog([]); + // The first dispatch will update the pending state. + assertLog(['Pending A']); await act(() => resolveText('Oops!')); assertLog(['Caught an error: Oops!', 'Caught an error: Oops!']); expect(container.textContent).toBe('Caught an error: Oops!'); @@ -1284,7 +1290,7 @@ describe('ReactDOMForm', () => { action('Oops!'); action('B'); }); - assertLog([]); + assertLog(['Pending A']); await act(() => resolveText('B')); assertLog(['B']); expect(container.textContent).toBe('B'); diff --git a/packages/react-reconciler/src/ReactFiberHooks.js b/packages/react-reconciler/src/ReactFiberHooks.js index 8a7f868b64504..75acffc1b9a11 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.js +++ b/packages/react-reconciler/src/ReactFiberHooks.js @@ -1915,6 +1915,7 @@ type FormStateActionQueueNode

= { function dispatchFormState( fiber: Fiber, actionQueue: FormStateActionQueue, + setPendingState: boolean => void, setState: Dispatch>, payload: P, ): void { @@ -1931,7 +1932,12 @@ function dispatchFormState( }; newLast.next = actionQueue.pending = newLast; - runFormStateAction(actionQueue, (setState: any), payload); + runFormStateAction( + actionQueue, + (setPendingState: any), + (setState: any), + payload, + ); } else { // There's already an action running. Add to the queue. const first = last.next; @@ -1945,6 +1951,7 @@ function dispatchFormState( function runFormStateAction( actionQueue: FormStateActionQueue, + setPendingState: boolean => void, setState: Dispatch>, payload: P, ) { @@ -1960,6 +1967,11 @@ function runFormStateAction( if (__DEV__) { ReactCurrentBatchConfig.transition._updatedFibers = new Set(); } + + // Optimistically update the pending state, similar to useTransition. + // This will be reverted automatically when all actions are finished. + setPendingState(true); + try { const returnValue = action(prevState, payload); if ( @@ -1976,9 +1988,18 @@ function runFormStateAction( thenable.then( (nextState: Awaited) => { actionQueue.state = nextState; - finishRunningFormStateAction(actionQueue, (setState: any)); + finishRunningFormStateAction( + actionQueue, + (setPendingState: any), + (setState: any), + ); }, - () => finishRunningFormStateAction(actionQueue, (setState: any)), + () => + finishRunningFormStateAction( + actionQueue, + (setPendingState: any), + (setState: any), + ), ); setState((thenable: any)); @@ -1987,7 +2008,11 @@ function runFormStateAction( const nextState = ((returnValue: any): Awaited); actionQueue.state = nextState; - finishRunningFormStateAction(actionQueue, (setState: any)); + finishRunningFormStateAction( + actionQueue, + (setPendingState: any), + (setState: any), + ); } } catch (error) { // This is a trick to get the `useFormState` hook to rethrow the error. @@ -2000,7 +2025,11 @@ function runFormStateAction( // $FlowFixMe: Not sure why this doesn't work }: RejectedThenable>); setState(rejectedThenable); - finishRunningFormStateAction(actionQueue, (setState: any)); + finishRunningFormStateAction( + actionQueue, + (setPendingState: any), + (setState: any), + ); } finally { ReactCurrentBatchConfig.transition = prevTransition; @@ -2022,6 +2051,7 @@ function runFormStateAction( function finishRunningFormStateAction( actionQueue: FormStateActionQueue, + setPendingState: Dispatch>, setState: Dispatch>, ) { // The action finished running. Pop it from the queue and run the next pending @@ -2038,7 +2068,12 @@ function finishRunningFormStateAction( last.next = next; // Run the next action. - runFormStateAction(actionQueue, (setState: any), next.payload); + runFormStateAction( + actionQueue, + (setPendingState: any), + (setState: any), + next.payload, + ); } } } @@ -2051,7 +2086,7 @@ function mountFormState( action: (Awaited, P) => S, initialStateProp: Awaited, permalink?: string, -): [Awaited, (P) => void] { +): [Awaited, (P) => void, boolean] { let initialState: Awaited = initialStateProp; if (getIsHydrating()) { const root: FiberRoot = (getWorkInProgressRoot(): any); @@ -2090,6 +2125,19 @@ function mountFormState( ): any); stateQueue.dispatch = setState; + // Pending state. This is used to store the pending state of the action. + // Tracked optimistically, like a transition pending state. + const pendingStateHook = mountStateImpl((false: Thenable | boolean)); + const setPendingState: boolean => void = (dispatchOptimisticSetState.bind( + null, + currentlyRenderingFiber, + false, + ((pendingStateHook.queue: any): UpdateQueue< + S | Awaited, + S | Awaited, + >), + ): any); + // 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 @@ -2106,6 +2154,7 @@ function mountFormState( null, currentlyRenderingFiber, actionQueue, + setPendingState, setState, ); actionQueue.dispatch = dispatch; @@ -2115,14 +2164,14 @@ function mountFormState( // an effect. actionQueueHook.memoizedState = action; - return [initialState, dispatch]; + return [initialState, dispatch, false]; } function updateFormState( action: (Awaited, P) => S, initialState: Awaited, permalink?: string, -): [Awaited, (P) => void] { +): [Awaited, (P) => void, boolean] { const stateHook = updateWorkInProgressHook(); const currentStateHook = ((currentHook: any): Hook); return updateFormStateImpl( @@ -2140,13 +2189,15 @@ function updateFormStateImpl( action: (Awaited, P) => S, initialState: Awaited, permalink?: string, -): [Awaited, (P) => void] { +): [Awaited, (P) => void, boolean] { const [actionResult] = updateReducerImpl, S | Thenable>( stateHook, currentStateHook, formStateReducer, ); + const [isPending] = updateState(false); + // This will suspend until the action finishes. const state: Awaited = typeof actionResult === 'object' && @@ -2172,7 +2223,7 @@ function updateFormStateImpl( ); } - return [state, dispatch]; + return [state, dispatch, isPending]; } function formStateActionEffect( @@ -2186,7 +2237,7 @@ function rerenderFormState( action: (Awaited, P) => S, initialState: Awaited, permalink?: string, -): [Awaited, (P) => void] { +): [Awaited, (P) => void, boolean] { // 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. @@ -2218,7 +2269,8 @@ function rerenderFormState( // This may have changed during the rerender. actionQueueHook.memoizedState = action; - return [state, dispatch]; + // For mount, pending is always false. + return [state, dispatch, false]; } function pushEffect( @@ -3765,7 +3817,7 @@ if (__DEV__) { action: (Awaited, P) => S, initialState: Awaited, permalink?: string, - ): [Awaited, (P) => void] { + ): [Awaited, (P) => void, boolean] { currentHookNameInDev = 'useFormState'; mountHookTypesDev(); return mountFormState(action, initialState, permalink); @@ -3935,7 +3987,7 @@ if (__DEV__) { action: (Awaited, P) => S, initialState: Awaited, permalink?: string, - ): [Awaited, (P) => void] { + ): [Awaited, (P) => void, boolean] { currentHookNameInDev = 'useFormState'; updateHookTypesDev(); return mountFormState(action, initialState, permalink); @@ -4107,7 +4159,7 @@ if (__DEV__) { action: (Awaited, P) => S, initialState: Awaited, permalink?: string, - ): [Awaited, (P) => void] { + ): [Awaited, (P) => void, boolean] { currentHookNameInDev = 'useFormState'; updateHookTypesDev(); return updateFormState(action, initialState, permalink); @@ -4279,7 +4331,7 @@ if (__DEV__) { action: (Awaited, P) => S, initialState: Awaited, permalink?: string, - ): [Awaited, (P) => void] { + ): [Awaited, (P) => void, boolean] { currentHookNameInDev = 'useFormState'; updateHookTypesDev(); return rerenderFormState(action, initialState, permalink); @@ -4472,7 +4524,7 @@ if (__DEV__) { action: (Awaited, P) => S, initialState: Awaited, permalink?: string, - ): [Awaited, (P) => void] { + ): [Awaited, (P) => void, boolean] { currentHookNameInDev = 'useFormState'; warnInvalidHookAccess(); mountHookTypesDev(); @@ -4670,7 +4722,7 @@ if (__DEV__) { action: (Awaited, P) => S, initialState: Awaited, permalink?: string, - ): [Awaited, (P) => void] { + ): [Awaited, (P) => void, boolean] { currentHookNameInDev = 'useFormState'; warnInvalidHookAccess(); updateHookTypesDev(); @@ -4868,7 +4920,7 @@ if (__DEV__) { action: (Awaited, P) => S, initialState: Awaited, permalink?: string, - ): [Awaited, (P) => void] { + ): [Awaited, (P) => void, boolean] { currentHookNameInDev = 'useFormState'; warnInvalidHookAccess(); updateHookTypesDev(); diff --git a/packages/react-reconciler/src/ReactInternalTypes.js b/packages/react-reconciler/src/ReactInternalTypes.js index 0227970a77b33..8d3e99b986cfb 100644 --- a/packages/react-reconciler/src/ReactInternalTypes.js +++ b/packages/react-reconciler/src/ReactInternalTypes.js @@ -413,7 +413,7 @@ export type Dispatcher = { action: (Awaited, P) => S, initialState: Awaited, permalink?: string, - ) => [Awaited, (P) => void], + ) => [Awaited, (P) => void, boolean], }; export type CacheDispatcher = { diff --git a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMForm-test.js b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMForm-test.js index dcab688b10c83..4ac9663237af6 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMForm-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMForm-test.js @@ -358,14 +358,16 @@ describe('ReactFlightDOMForm', () => { const initialState = {count: 1}; function Client({action}) { - const [state, dispatch] = useFormState(action, initialState); + const [state, dispatch, isPending] = useFormState(action, initialState); return (

+ {isPending ? 'Pending...' : ''} Count: {state.count} ); } + const ClientRef = await clientExports(Client); const rscStream = ReactServerDOMServer.renderToReadableStream( @@ -382,8 +384,10 @@ describe('ReactFlightDOMForm', () => { await readIntoContainer(ssrStream); const form = container.getElementsByTagName('form')[0]; - const span = container.getElementsByTagName('span')[0]; - expect(span.textContent).toBe('Count: 1'); + const pendingSpan = container.getElementsByTagName('span')[0]; + const stateSpan = container.getElementsByTagName('span')[1]; + expect(pendingSpan.textContent).toBe(''); + expect(stateSpan.textContent).toBe('Count: 1'); const {returnValue} = await submit(form); expect(await returnValue).toEqual({count: 6}); @@ -399,8 +403,13 @@ describe('ReactFlightDOMForm', () => { ); function Form({action}) { - const [count, dispatch] = useFormState(action, 1); - return
{count}
; + const [count, dispatch, isPending] = useFormState(action, 1); + return ( +
+ {isPending ? 'Pending...' : ''} + {count} +
+ ); } function Client({action}) { @@ -487,8 +496,13 @@ describe('ReactFlightDOMForm', () => { ); function Form({action}) { - const [count, dispatch] = useFormState(action, 1); - return
{count}
; + const [count, dispatch, isPending] = useFormState(action, 1); + return ( +
+ {isPending ? 'Pending...' : ''} + {count} +
+ ); } function Client({action}) { @@ -607,8 +621,13 @@ describe('ReactFlightDOMForm', () => { ); function Form({action}) { - const [count, dispatch] = useFormState(action, 1); - return
{count}
; + const [count, dispatch, isPending] = useFormState(action, 1); + return ( +
+ {isPending ? 'Pending...' : ''} + {count} +
+ ); } function Client({action}) { @@ -682,8 +701,13 @@ describe('ReactFlightDOMForm', () => { ); function Form({action, permalink}) { - const [count, dispatch] = useFormState(action, 1, permalink); - return
{count}
; + const [count, dispatch, isPending] = useFormState(action, 1, permalink); + return ( +
+ {isPending ? 'Pending...' : ''} + {count} +
+ ); } function Page1({action, permalink}) { @@ -783,17 +807,19 @@ describe('ReactFlightDOMForm', () => { const initialState = {count: 1}; function Client({action}) { - const [state, dispatch] = useFormState( + const [state, dispatch, isPending] = useFormState( action, initialState, '/permalink', ); return (
+ {isPending ? 'Pending...' : ''} Count: {state.count}
); } + const ClientRef = await clientExports(Client); const rscStream = ReactServerDOMServer.renderToReadableStream( @@ -810,8 +836,10 @@ describe('ReactFlightDOMForm', () => { await readIntoContainer(ssrStream); const form = container.getElementsByTagName('form')[0]; - const span = container.getElementsByTagName('span')[0]; - expect(span.textContent).toBe('Count: 1'); + const pendingSpan = container.getElementsByTagName('span')[0]; + const stateSpan = container.getElementsByTagName('span')[1]; + expect(pendingSpan.textContent).toBe(''); + expect(stateSpan.textContent).toBe('Count: 1'); expect(form.action).toBe('http://localhost/permalink'); }); @@ -833,13 +861,19 @@ describe('ReactFlightDOMForm', () => { const initialState = {count: 1}; function Client({action}) { - const [state, dispatch] = useFormState(action, initialState, permalink); + const [state, dispatch, isPending] = useFormState( + action, + initialState, + permalink, + ); return (
+ {isPending ? 'Pending...' : ''} Count: {state.count}
); } + const ClientRef = await clientExports(Client); const rscStream = ReactServerDOMServer.renderToReadableStream( @@ -856,8 +890,10 @@ describe('ReactFlightDOMForm', () => { await readIntoContainer(ssrStream); const form = container.getElementsByTagName('form')[0]; - const span = container.getElementsByTagName('span')[0]; - expect(span.textContent).toBe('Count: 1'); + const pendingSpan = container.getElementsByTagName('span')[0]; + const stateSpan = container.getElementsByTagName('span')[1]; + expect(pendingSpan.textContent).toBe(''); + expect(stateSpan.textContent).toBe('Count: 1'); expect(form.action).toBe('http://localhost/permalink'); }); diff --git a/packages/react-server/src/ReactFizzHooks.js b/packages/react-server/src/ReactFizzHooks.js index 36da5e6539b2a..efcb05bcba941 100644 --- a/packages/react-server/src/ReactFizzHooks.js +++ b/packages/react-server/src/ReactFizzHooks.js @@ -615,7 +615,7 @@ function useFormState( action: (Awaited, P) => S, initialState: Awaited, permalink?: string, -): [Awaited, (P) => void] { +): [Awaited, (P) => void, boolean] { resolveCurrentlyRenderingComponent(); // Count the number of useFormState hooks per component. We also use this to @@ -708,7 +708,7 @@ function useFormState( }; } - return [state, dispatch]; + return [state, dispatch, false]; } else { // This is not a server action, so the implementation is much simpler. @@ -718,7 +718,7 @@ function useFormState( const dispatch = (payload: P): void => { boundAction(payload); }; - return [initialState, dispatch]; + return [initialState, dispatch, false]; } }