diff --git a/packages/react-debug-tools/src/ReactDebugHooks.js b/packages/react-debug-tools/src/ReactDebugHooks.js index 20e3d89e1bf64..9c8f179325243 100644 --- a/packages/react-debug-tools/src/ReactDebugHooks.js +++ b/packages/react-debug-tools/src/ReactDebugHooks.js @@ -103,6 +103,10 @@ function getPrimitiveStackCache(): Map> { // This type check is for Flow only. Dispatcher.useFormState((s: mixed, p: mixed) => s, null); } + if (typeof Dispatcher.useActionState === 'function') { + // This type check is for Flow only. + Dispatcher.useActionState((s: mixed, p: mixed) => s, null); + } if (typeof Dispatcher.use === 'function') { // This type check is for Flow only. Dispatcher.use( @@ -586,6 +590,75 @@ function useFormState( return [state, (payload: P) => {}, false]; } +function useActionState( + action: (Awaited, P) => S, + initialState: Awaited, + permalink?: string, +): [Awaited, (P) => void, boolean] { + const hook = nextHook(); // FormState + nextHook(); // PendingState + nextHook(); // ActionQueue + const stackError = new Error(); + let value; + let debugInfo = null; + let error = null; + + if (hook !== null) { + const actionResult = hook.memoizedState; + if ( + typeof actionResult === 'object' && + actionResult !== null && + // $FlowFixMe[method-unbinding] + typeof actionResult.then === 'function' + ) { + const thenable: Thenable> = (actionResult: any); + switch (thenable.status) { + case 'fulfilled': { + value = thenable.value; + debugInfo = + thenable._debugInfo === undefined ? null : thenable._debugInfo; + break; + } + case 'rejected': { + const rejectedError = thenable.reason; + error = rejectedError; + break; + } + default: + // If this was an uncached Promise we have to abandon this attempt + // but we can still emit anything up until this point. + error = SuspenseException; + debugInfo = + thenable._debugInfo === undefined ? null : thenable._debugInfo; + value = thenable; + } + } else { + value = (actionResult: any); + } + } else { + value = initialState; + } + + hookLog.push({ + displayName: null, + primitive: 'ActionState', + stackError: stackError, + value: value, + debugInfo: debugInfo, + }); + + if (error !== null) { + throw error; + } + + // 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); + + // TODO: support displaying pending value + return [state, (payload: P) => {}, false]; +} + const Dispatcher: DispatcherType = { use, readContext, @@ -608,6 +681,7 @@ const Dispatcher: DispatcherType = { useDeferredValue, useId, useFormState, + useActionState, }; // create a proxy to throw a custom error diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzForm-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzForm-test.js index ae56c5eae4dd2..ad21f230ed380 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzForm-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzForm-test.js @@ -23,7 +23,7 @@ let ReactDOMServer; let ReactDOMClient; let useFormStatus; let useOptimistic; -let useFormState; +let useActionState; describe('ReactDOMFizzForm', () => { beforeEach(() => { @@ -32,11 +32,16 @@ describe('ReactDOMFizzForm', () => { ReactDOMServer = require('react-dom/server.browser'); ReactDOMClient = require('react-dom/client'); useFormStatus = require('react-dom').useFormStatus; - useFormState = require('react-dom').useFormState; useOptimistic = require('react').useOptimistic; act = require('internal-test-utils').act; container = document.createElement('div'); document.body.appendChild(container); + if (__VARIANT__) { + // Remove after API is deleted. + useActionState = require('react-dom').useFormState; + } else { + useActionState = require('react').useActionState; + } }); afterEach(() => { @@ -474,13 +479,13 @@ describe('ReactDOMFizzForm', () => { // @gate enableFormActions // @gate enableAsyncActions - it('useFormState returns initial state', async () => { + it('useActionState returns initial state', async () => { async function action(state) { return state; } function App() { - const [state] = useFormState(action, 0); + const [state] = useActionState(action, 0); return state; } diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js index e6ff3e2c22089..24eac0102af97 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js @@ -30,7 +30,7 @@ let SuspenseList; let useSyncExternalStore; let useSyncExternalStoreWithSelector; let use; -let useFormState; +let useActionState; let PropTypes; let textCache; let writable; @@ -89,9 +89,13 @@ describe('ReactDOMFizzServer', () => { if (gate(flags => flags.enableSuspenseList)) { SuspenseList = React.unstable_SuspenseList; } - useFormState = ReactDOM.useFormState; - PropTypes = require('prop-types'); + if (__VARIANT__) { + // Remove after API is deleted. + useActionState = ReactDOM.useFormState; + } else { + useActionState = React.useActionState; + } const InternalTestUtils = require('internal-test-utils'); waitForAll = InternalTestUtils.waitForAll; @@ -6203,8 +6207,8 @@ describe('ReactDOMFizzServer', () => { // @gate enableFormActions // @gate enableAsyncActions - it('useFormState hydrates without a mismatch', async () => { - // This is testing an implementation detail: useFormState emits comment + it('useActionState hydrates without a mismatch', async () => { + // This is testing an implementation detail: useActionState emits comment // nodes into the SSR stream, so this checks that they are handled correctly // during hydration. @@ -6214,7 +6218,7 @@ describe('ReactDOMFizzServer', () => { const childRef = React.createRef(null); function Form() { - const [state] = useFormState(action, 0); + const [state] = useActionState(action, 0); const text = `Child: ${state}`; return (
@@ -6257,7 +6261,7 @@ describe('ReactDOMFizzServer', () => { // @gate enableFormActions // @gate enableAsyncActions - it("useFormState hydrates without a mismatch if there's a render phase update", async () => { + it("useActionState hydrates without a mismatch if there's a render phase update", async () => { async function action(state) { return state; } @@ -6271,8 +6275,8 @@ describe('ReactDOMFizzServer', () => { // Because of the render phase update above, this component is evaluated // multiple times (even during SSR), but it should only emit a single - // marker per useFormState instance. - const [formState] = useFormState(action, 0); + // marker per useActionState instance. + const [formState] = useActionState(action, 0); const text = `${readText('Child')}:${formState}:${localState}`; return (
diff --git a/packages/react-dom/src/__tests__/ReactDOMForm-test.js b/packages/react-dom/src/__tests__/ReactDOMForm-test.js index 977439b099971..a1a32f906204e 100644 --- a/packages/react-dom/src/__tests__/ReactDOMForm-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMForm-test.js @@ -41,7 +41,7 @@ describe('ReactDOMForm', () => { let startTransition; let textCache; let useFormStatus; - let useFormState; + let useActionState; beforeEach(() => { jest.resetModules(); @@ -56,11 +56,17 @@ describe('ReactDOMForm', () => { Suspense = React.Suspense; startTransition = React.startTransition; useFormStatus = ReactDOM.useFormStatus; - useFormState = ReactDOM.useFormState; container = document.createElement('div'); document.body.appendChild(container); textCache = new Map(); + + if (__VARIANT__) { + // Remove after API is deleted. + useActionState = ReactDOM.useFormState; + } else { + useActionState = React.useActionState; + } }); function resolveText(text) { @@ -962,7 +968,7 @@ describe('ReactDOMForm', () => { // @gate enableFormActions // @gate enableAsyncActions - test('useFormState updates state asynchronously and queues multiple actions', async () => { + test('useActionState updates state asynchronously and queues multiple actions', async () => { let actionCounter = 0; async function action(state, type) { actionCounter++; @@ -982,7 +988,7 @@ describe('ReactDOMForm', () => { let dispatch; function App() { - const [state, _dispatch, isPending] = useFormState(action, 0); + const [state, _dispatch, isPending] = useActionState(action, 0); dispatch = _dispatch; const pending = isPending ? 'Pending ' : ''; return ; @@ -1023,10 +1029,10 @@ describe('ReactDOMForm', () => { // @gate enableFormActions // @gate enableAsyncActions - test('useFormState supports inline actions', async () => { + test('useActionState supports inline actions', async () => { let increment; function App({stepSize}) { - const [state, dispatch, isPending] = useFormState(async prevState => { + const [state, dispatch, isPending] = useActionState(async prevState => { return prevState + stepSize; }, 0); increment = dispatch; @@ -1056,9 +1062,9 @@ describe('ReactDOMForm', () => { // @gate enableFormActions // @gate enableAsyncActions - test('useFormState: dispatch throws if called during render', async () => { + test('useActionState: dispatch throws if called during render', async () => { function App() { - const [state, dispatch, isPending] = useFormState(async () => {}, 0); + const [state, dispatch, isPending] = useActionState(async () => {}, 0); dispatch(); const pending = isPending ? 'Pending ' : ''; return ; @@ -1076,7 +1082,7 @@ describe('ReactDOMForm', () => { test('queues multiple actions and runs them in order', async () => { let action; function App() { - const [state, dispatch, isPending] = useFormState( + const [state, dispatch, isPending] = useActionState( async (s, a) => await getText(a), 'A', ); @@ -1106,10 +1112,10 @@ describe('ReactDOMForm', () => { // @gate enableFormActions // @gate enableAsyncActions - test('useFormState: works if action is sync', async () => { + test('useActionState: works if action is sync', async () => { let increment; function App({stepSize}) { - const [state, dispatch, isPending] = useFormState(prevState => { + const [state, dispatch, isPending] = useActionState(prevState => { return prevState + stepSize; }, 0); increment = dispatch; @@ -1139,10 +1145,10 @@ describe('ReactDOMForm', () => { // @gate enableFormActions // @gate enableAsyncActions - test('useFormState: can mix sync and async actions', async () => { + test('useActionState: can mix sync and async actions', async () => { let action; function App() { - const [state, dispatch, isPending] = useFormState((s, a) => a, 'A'); + const [state, dispatch, isPending] = useActionState((s, a) => a, 'A'); action = dispatch; const pending = isPending ? 'Pending ' : ''; return ; @@ -1168,7 +1174,7 @@ describe('ReactDOMForm', () => { // @gate enableFormActions // @gate enableAsyncActions - test('useFormState: error handling (sync action)', async () => { + test('useActionState: error handling (sync action)', async () => { let resetErrorBoundary; class ErrorBoundary extends React.Component { state = {error: null}; @@ -1186,7 +1192,7 @@ describe('ReactDOMForm', () => { let action; function App() { - const [state, dispatch, isPending] = useFormState((s, a) => { + const [state, dispatch, isPending] = useActionState((s, a) => { if (a.endsWith('!')) { throw new Error(a); } @@ -1233,7 +1239,7 @@ describe('ReactDOMForm', () => { // @gate enableFormActions // @gate enableAsyncActions - test('useFormState: error handling (async action)', async () => { + test('useActionState: error handling (async action)', async () => { let resetErrorBoundary; class ErrorBoundary extends React.Component { state = {error: null}; @@ -1251,7 +1257,7 @@ describe('ReactDOMForm', () => { let action; function App() { - const [state, dispatch, isPending] = useFormState(async (s, a) => { + const [state, dispatch, isPending] = useActionState(async (s, a) => { const text = await getText(a); if (text.endsWith('!')) { throw new Error(text); diff --git a/packages/react-reconciler/src/ReactFiberHooks.js b/packages/react-reconciler/src/ReactFiberHooks.js index 75acffc1b9a11..4748aef25e1b3 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.js +++ b/packages/react-reconciler/src/ReactFiberHooks.js @@ -3514,6 +3514,7 @@ if (enableFormActions && enableAsyncActions) { (ContextOnlyDispatcher: Dispatcher).useHostTransitionStatus = throwInvalidHookError; (ContextOnlyDispatcher: Dispatcher).useFormState = throwInvalidHookError; + (ContextOnlyDispatcher: Dispatcher).useActionState = throwInvalidHookError; } if (enableAsyncActions) { (ContextOnlyDispatcher: Dispatcher).useOptimistic = throwInvalidHookError; @@ -3552,6 +3553,7 @@ if (enableFormActions && enableAsyncActions) { (HooksDispatcherOnMount: Dispatcher).useHostTransitionStatus = useHostTransitionStatus; (HooksDispatcherOnMount: Dispatcher).useFormState = mountFormState; + (HooksDispatcherOnMount: Dispatcher).useActionState = mountFormState; } if (enableAsyncActions) { (HooksDispatcherOnMount: Dispatcher).useOptimistic = mountOptimistic; @@ -3590,6 +3592,7 @@ if (enableFormActions && enableAsyncActions) { (HooksDispatcherOnUpdate: Dispatcher).useHostTransitionStatus = useHostTransitionStatus; (HooksDispatcherOnUpdate: Dispatcher).useFormState = updateFormState; + (HooksDispatcherOnUpdate: Dispatcher).useActionState = updateFormState; } if (enableAsyncActions) { (HooksDispatcherOnUpdate: Dispatcher).useOptimistic = updateOptimistic; @@ -3628,6 +3631,7 @@ if (enableFormActions && enableAsyncActions) { (HooksDispatcherOnRerender: Dispatcher).useHostTransitionStatus = useHostTransitionStatus; (HooksDispatcherOnRerender: Dispatcher).useFormState = rerenderFormState; + (HooksDispatcherOnRerender: Dispatcher).useActionState = rerenderFormState; } if (enableAsyncActions) { (HooksDispatcherOnRerender: Dispatcher).useOptimistic = rerenderOptimistic; @@ -3822,6 +3826,16 @@ if (__DEV__) { mountHookTypesDev(); return mountFormState(action, initialState, permalink); }; + (HooksDispatcherOnMountInDEV: Dispatcher).useActionState = + function useActionState( + action: (Awaited, P) => S, + initialState: Awaited, + permalink?: string, + ): [Awaited, (P) => void, boolean] { + currentHookNameInDev = 'useActionState'; + mountHookTypesDev(); + return mountFormState(action, initialState, permalink); + }; } if (enableAsyncActions) { (HooksDispatcherOnMountInDEV: Dispatcher).useOptimistic = @@ -3992,6 +4006,16 @@ if (__DEV__) { updateHookTypesDev(); return mountFormState(action, initialState, permalink); }; + (HooksDispatcherOnMountWithHookTypesInDEV: Dispatcher).useActionState = + function useActionState( + action: (Awaited, P) => S, + initialState: Awaited, + permalink?: string, + ): [Awaited, (P) => void, boolean] { + currentHookNameInDev = 'useActionState'; + updateHookTypesDev(); + return mountFormState(action, initialState, permalink); + }; } if (enableAsyncActions) { (HooksDispatcherOnMountWithHookTypesInDEV: Dispatcher).useOptimistic = @@ -4164,6 +4188,16 @@ if (__DEV__) { updateHookTypesDev(); return updateFormState(action, initialState, permalink); }; + (HooksDispatcherOnUpdateInDEV: Dispatcher).useActionState = + function useActionState( + action: (Awaited, P) => S, + initialState: Awaited, + permalink?: string, + ): [Awaited, (P) => void, boolean] { + currentHookNameInDev = 'useActionState'; + updateHookTypesDev(); + return updateFormState(action, initialState, permalink); + }; } if (enableAsyncActions) { (HooksDispatcherOnUpdateInDEV: Dispatcher).useOptimistic = @@ -4336,6 +4370,16 @@ if (__DEV__) { updateHookTypesDev(); return rerenderFormState(action, initialState, permalink); }; + (HooksDispatcherOnRerenderInDEV: Dispatcher).useActionState = + function useActionState( + action: (Awaited, P) => S, + initialState: Awaited, + permalink?: string, + ): [Awaited, (P) => void, boolean] { + currentHookNameInDev = 'useActionState'; + updateHookTypesDev(); + return rerenderFormState(action, initialState, permalink); + }; } if (enableAsyncActions) { (HooksDispatcherOnRerenderInDEV: Dispatcher).useOptimistic = @@ -4530,6 +4574,17 @@ if (__DEV__) { mountHookTypesDev(); return mountFormState(action, initialState, permalink); }; + (InvalidNestedHooksDispatcherOnMountInDEV: Dispatcher).useActionState = + function useActionState( + action: (Awaited, P) => S, + initialState: Awaited, + permalink?: string, + ): [Awaited, (P) => void, boolean] { + currentHookNameInDev = 'useActionState'; + warnInvalidHookAccess(); + mountHookTypesDev(); + return mountFormState(action, initialState, permalink); + }; } if (enableAsyncActions) { (InvalidNestedHooksDispatcherOnMountInDEV: Dispatcher).useOptimistic = @@ -4728,6 +4783,17 @@ if (__DEV__) { updateHookTypesDev(); return updateFormState(action, initialState, permalink); }; + (InvalidNestedHooksDispatcherOnUpdateInDEV: Dispatcher).useActionState = + function useActionState( + action: (Awaited, P) => S, + initialState: Awaited, + permalink?: string, + ): [Awaited, (P) => void, boolean] { + currentHookNameInDev = 'useActionState'; + warnInvalidHookAccess(); + updateHookTypesDev(); + return updateFormState(action, initialState, permalink); + }; } if (enableAsyncActions) { (InvalidNestedHooksDispatcherOnUpdateInDEV: Dispatcher).useOptimistic = @@ -4926,6 +4992,17 @@ if (__DEV__) { updateHookTypesDev(); return rerenderFormState(action, initialState, permalink); }; + (InvalidNestedHooksDispatcherOnRerenderInDEV: Dispatcher).useActionState = + function useActionState( + action: (Awaited, P) => S, + initialState: Awaited, + permalink?: string, + ): [Awaited, (P) => void, boolean] { + currentHookNameInDev = 'useActionState'; + warnInvalidHookAccess(); + updateHookTypesDev(); + return rerenderFormState(action, initialState, permalink); + }; } if (enableAsyncActions) { (InvalidNestedHooksDispatcherOnRerenderInDEV: Dispatcher).useOptimistic = diff --git a/packages/react-reconciler/src/ReactInternalTypes.js b/packages/react-reconciler/src/ReactInternalTypes.js index 8d3e99b986cfb..398a5720abf5f 100644 --- a/packages/react-reconciler/src/ReactInternalTypes.js +++ b/packages/react-reconciler/src/ReactInternalTypes.js @@ -56,7 +56,8 @@ export type HookType = | 'useId' | 'useCacheRefresh' | 'useOptimistic' - | 'useFormState'; + | 'useFormState' + | 'useActionState'; export type ContextDependency = { context: ReactContext, @@ -414,6 +415,11 @@ export type Dispatcher = { initialState: Awaited, permalink?: string, ) => [Awaited, (P) => void, boolean], + useActionState?: ( + action: (Awaited, P) => S, + initialState: Awaited, + permalink?: string, + ) => [Awaited, (P) => void, boolean], }; export type CacheDispatcher = { diff --git a/packages/react-refresh/src/ReactFreshBabelPlugin.js b/packages/react-refresh/src/ReactFreshBabelPlugin.js index fa8ba43f16062..9592a994399e5 100644 --- a/packages/react-refresh/src/ReactFreshBabelPlugin.js +++ b/packages/react-refresh/src/ReactFreshBabelPlugin.js @@ -244,6 +244,8 @@ export default function (babel, opts = {}) { case 'React.useFormStatus': case 'useFormState': case 'React.useFormState': + case 'useActionState': + case 'React.useActionState': case 'useOptimistic': case 'React.useOptimistic': return true; 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 4ac9663237af6..e661efa8c8667 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMForm-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMForm-test.js @@ -31,7 +31,7 @@ let ReactDOMServer; let ReactServerDOMServer; let ReactServerDOMClient; let ReactDOMClient; -let useFormState; +let useActionState; let act; describe('ReactFlightDOMForm', () => { @@ -55,7 +55,13 @@ describe('ReactFlightDOMForm', () => { ReactDOMServer = require('react-dom/server.edge'); ReactDOMClient = require('react-dom/client'); act = React.act; - useFormState = require('react-dom').useFormState; + + if (__VARIANT__) { + // Remove after API is deleted. + useActionState = require('react-dom').useFormState; + } else { + useActionState = require('react').useActionState; + } container = document.createElement('div'); document.body.appendChild(container); }); @@ -346,7 +352,7 @@ describe('ReactFlightDOMForm', () => { // @gate enableFormActions // @gate enableAsyncActions - it("useFormState's dispatch binds the initial state to the provided action", async () => { + it("useActionState's dispatch binds the initial state to the provided action", async () => { const serverAction = serverExports( async function action(prevState, formData) { return { @@ -358,7 +364,7 @@ describe('ReactFlightDOMForm', () => { const initialState = {count: 1}; function Client({action}) { - const [state, dispatch, isPending] = useFormState(action, initialState); + const [state, dispatch, isPending] = useActionState(action, initialState); return (
{isPending ? 'Pending...' : ''} @@ -395,7 +401,7 @@ describe('ReactFlightDOMForm', () => { // @gate enableFormActions // @gate enableAsyncActions - it('useFormState can reuse state during MPA form submission', async () => { + it('useActionState can reuse state during MPA form submission', async () => { const serverAction = serverExports( async function action(prevState, formData) { return prevState + 1; @@ -403,7 +409,7 @@ describe('ReactFlightDOMForm', () => { ); function Form({action}) { - const [count, dispatch, isPending] = useFormState(action, 1); + const [count, dispatch, isPending] = useActionState(action, 1); return ( {isPending ? 'Pending...' : ''} @@ -486,7 +492,7 @@ describe('ReactFlightDOMForm', () => { // @gate enableFormActions // @gate enableAsyncActions it( - 'useFormState preserves state if arity is the same, but different ' + + 'useActionState preserves state if arity is the same, but different ' + 'arguments are bound (i.e. inline closure)', async () => { const serverAction = serverExports( @@ -496,7 +502,7 @@ describe('ReactFlightDOMForm', () => { ); function Form({action}) { - const [count, dispatch, isPending] = useFormState(action, 1); + const [count, dispatch, isPending] = useActionState(action, 1); return ( {isPending ? 'Pending...' : ''} @@ -605,7 +611,7 @@ describe('ReactFlightDOMForm', () => { // @gate enableFormActions // @gate enableAsyncActions - it('useFormState does not reuse state if action signatures are different', async () => { + it('useActionState does not reuse state if action signatures are different', async () => { // This is the same as the previous test, except instead of using bind to // configure the server action (i.e. a closure), it swaps the action. const increaseBy1 = serverExports( @@ -621,7 +627,7 @@ describe('ReactFlightDOMForm', () => { ); function Form({action}) { - const [count, dispatch, isPending] = useFormState(action, 1); + const [count, dispatch, isPending] = useActionState(action, 1); return ( {isPending ? 'Pending...' : ''} @@ -693,7 +699,7 @@ describe('ReactFlightDOMForm', () => { // @gate enableFormActions // @gate enableAsyncActions - it('when permalink is provided, useFormState compares that instead of the keypath', async () => { + it('when permalink is provided, useActionState compares that instead of the keypath', async () => { const serverAction = serverExports( async function action(prevState, formData) { return prevState + 1; @@ -701,7 +707,7 @@ describe('ReactFlightDOMForm', () => { ); function Form({action, permalink}) { - const [count, dispatch, isPending] = useFormState(action, 1, permalink); + const [count, dispatch, isPending] = useActionState(action, 1, permalink); return ( {isPending ? 'Pending...' : ''} @@ -800,14 +806,14 @@ describe('ReactFlightDOMForm', () => { // @gate enableFormActions // @gate enableAsyncActions - it('useFormState can change the action URL with the `permalink` argument', async () => { + it('useActionState can change the action URL with the `permalink` argument', async () => { const serverAction = serverExports(function action(prevState) { return {state: prevState.count + 1}; }); const initialState = {count: 1}; function Client({action}) { - const [state, dispatch, isPending] = useFormState( + const [state, dispatch, isPending] = useActionState( action, initialState, '/permalink', @@ -846,7 +852,7 @@ describe('ReactFlightDOMForm', () => { // @gate enableFormActions // @gate enableAsyncActions - it('useFormState `permalink` is coerced to string', async () => { + it('useActionState `permalink` is coerced to string', async () => { const serverAction = serverExports(function action(prevState) { return {state: prevState.count + 1}; }); @@ -861,7 +867,7 @@ describe('ReactFlightDOMForm', () => { const initialState = {count: 1}; function Client({action}) { - const [state, dispatch, isPending] = useFormState( + const [state, dispatch, isPending] = useActionState( action, initialState, permalink, diff --git a/packages/react-server/src/ReactFizzHooks.js b/packages/react-server/src/ReactFizzHooks.js index efcb05bcba941..1f3e632d31474 100644 --- a/packages/react-server/src/ReactFizzHooks.js +++ b/packages/react-server/src/ReactFizzHooks.js @@ -820,6 +820,7 @@ if (enableFormActions && enableAsyncActions) { if (enableAsyncActions) { HooksDispatcher.useOptimistic = useOptimistic; HooksDispatcher.useFormState = useFormState; + HooksDispatcher.useActionState = useFormState; } export let currentResumableState: null | ResumableState = (null: any); diff --git a/packages/react/index.classic.fb.js b/packages/react/index.classic.fb.js index 7c84775bb6cc3..949a23c70ab33 100644 --- a/packages/react/index.classic.fb.js +++ b/packages/react/index.classic.fb.js @@ -57,6 +57,7 @@ export { useState, useSyncExternalStore, useTransition, + useActionState, version, } from './src/ReactClient'; export {jsx, jsxs, jsxDEV} from './src/jsx/ReactJSX'; diff --git a/packages/react/index.experimental.js b/packages/react/index.experimental.js index dcb695e438a19..30472a43d33f6 100644 --- a/packages/react/index.experimental.js +++ b/packages/react/index.experimental.js @@ -55,6 +55,7 @@ export { useState, useSyncExternalStore, useTransition, + useActionState, version, } from './src/ReactClient'; diff --git a/packages/react/index.js b/packages/react/index.js index ac3b45c3a7ca8..a3bd5737066d9 100644 --- a/packages/react/index.js +++ b/packages/react/index.js @@ -78,5 +78,6 @@ export { useRef, useState, useTransition, + useActionState, version, } from './src/ReactClient'; diff --git a/packages/react/index.modern.fb.js b/packages/react/index.modern.fb.js index 4f73dded7f7eb..d5d9a10f4ca93 100644 --- a/packages/react/index.modern.fb.js +++ b/packages/react/index.modern.fb.js @@ -55,6 +55,7 @@ export { useState, useSyncExternalStore, useTransition, + useActionState, version, } from './src/ReactClient'; export {jsx, jsxs, jsxDEV} from './src/jsx/ReactJSX'; diff --git a/packages/react/index.stable.js b/packages/react/index.stable.js index 2997a62b4a44a..ebab580542b24 100644 --- a/packages/react/index.stable.js +++ b/packages/react/index.stable.js @@ -46,5 +46,6 @@ export { useState, useSyncExternalStore, useTransition, + useActionState, version, } from './src/ReactClient'; diff --git a/packages/react/src/ReactClient.js b/packages/react/src/ReactClient.js index ff988a2a2cf3d..711afdad469de 100644 --- a/packages/react/src/ReactClient.js +++ b/packages/react/src/ReactClient.js @@ -60,6 +60,7 @@ import { use, useMemoCache, useOptimistic, + useActionState, } from './ReactHooks'; import ReactSharedInternals from './ReactSharedInternalsClient'; @@ -95,6 +96,7 @@ export { useLayoutEffect, useMemo, useOptimistic, + useActionState, useSyncExternalStore, useReducer, useRef, diff --git a/packages/react/src/ReactHooks.js b/packages/react/src/ReactHooks.js index 60fc20062d597..227584698ae94 100644 --- a/packages/react/src/ReactHooks.js +++ b/packages/react/src/ReactHooks.js @@ -12,11 +12,13 @@ import type { ReactContext, StartTransitionOptions, Usable, + Awaited, } from 'shared/ReactTypes'; import {REACT_CONSUMER_TYPE} from 'shared/ReactSymbols'; import ReactCurrentDispatcher from './ReactCurrentDispatcher'; import ReactCurrentCache from './ReactCurrentCache'; +import {enableAsyncActions, enableFormActions} from 'shared/ReactFeatureFlags'; type BasicStateAction = (S => S) | S; type Dispatch = A => void; @@ -227,3 +229,17 @@ export function useOptimistic( // $FlowFixMe[not-a-function] This is unstable, thus optional return dispatcher.useOptimistic(passthrough, reducer); } + +export function useActionState( + action: (Awaited, P) => S, + initialState: Awaited, + permalink?: string, +): [Awaited, (P) => void, boolean] { + if (!(enableFormActions && enableAsyncActions)) { + throw new Error('Not implemented.'); + } else { + const dispatcher = resolveDispatcher(); + // $FlowFixMe[not-a-function] This is unstable, thus optional + return dispatcher.useActionState(action, initialState, permalink); + } +} diff --git a/packages/react/src/ReactServer.experimental.js b/packages/react/src/ReactServer.experimental.js index ccc6de20eca34..fb90fd18e7b73 100644 --- a/packages/react/src/ReactServer.experimental.js +++ b/packages/react/src/ReactServer.experimental.js @@ -34,6 +34,7 @@ import { useCallback, useDebugValue, useMemo, + useActionState, getCacheSignal, getCacheForType, } from './ReactHooks'; @@ -84,5 +85,6 @@ export { useCallback, useDebugValue, useMemo, + useActionState, version, }; diff --git a/packages/react/src/ReactServer.js b/packages/react/src/ReactServer.js index cc7e2c1847e5a..0c5eacbba23d0 100644 --- a/packages/react/src/ReactServer.js +++ b/packages/react/src/ReactServer.js @@ -27,7 +27,14 @@ import { isValidElement, } from './jsx/ReactJSXElement'; import {createRef} from './ReactCreateRef'; -import {use, useId, useCallback, useDebugValue, useMemo} from './ReactHooks'; +import { + use, + useId, + useCallback, + useDebugValue, + useMemo, + useActionState, +} from './ReactHooks'; import {forwardRef} from './ReactForwardRef'; import {lazy} from './ReactLazy'; import {memo} from './ReactMemo'; @@ -63,5 +70,6 @@ export { useCallback, useDebugValue, useMemo, + useActionState, version, };