diff --git a/.eslintrc.js b/.eslintrc.js index 941c2e3b23ca8..6b6c9549bc7c4 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -456,6 +456,7 @@ module.exports = { $ReadOnlyArray: 'readonly', $ArrayBufferView: 'readonly', $Shape: 'readonly', + ReturnType: 'readonly', AnimationFrameID: 'readonly', // For Flow type annotation. Only `BigInt` is valid at runtime. bigint: 'readonly', diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzForm-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzForm-test.js index b13eb90304a34..eaedd152d91c6 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzForm-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzForm-test.js @@ -23,6 +23,7 @@ let ReactDOMServer; let ReactDOMClient; let useFormStatus; let useOptimistic; +let useFormState; describe('ReactDOMFizzForm', () => { beforeEach(() => { @@ -32,6 +33,7 @@ describe('ReactDOMFizzForm', () => { ReactDOMClient = require('react-dom/client'); useFormStatus = require('react-dom').experimental_useFormStatus; useOptimistic = require('react').experimental_useOptimistic; + useFormState = require('react').experimental_useFormState; act = require('internal-test-utils').act; container = document.createElement('div'); document.body.appendChild(container); @@ -470,6 +472,27 @@ describe('ReactDOMFizzForm', () => { expect(container.textContent).toBe('hi'); }); + // @gate enableAsyncActions + it('useFormState returns initial state', async () => { + async function action(state) { + return state; + } + + function App() { + const [state] = useFormState(action, 0); + return state; + } + + const stream = await ReactDOMServer.renderToReadableStream(); + await readIntoContainer(stream); + expect(container.textContent).toBe('0'); + + await act(async () => { + ReactDOMClient.hydrateRoot(container, ); + }); + expect(container.textContent).toBe('0'); + }); + // @gate enableFormActions it('can provide a custom action on the server for actions', async () => { const ref = React.createRef(); diff --git a/packages/react-reconciler/src/ReactFiberHooks.js b/packages/react-reconciler/src/ReactFiberHooks.js index 77e9edebe7c67..53d2325ceb439 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.js +++ b/packages/react-reconciler/src/ReactFiberHooks.js @@ -1835,6 +1835,37 @@ function rerenderOptimistic( return [passthrough, dispatch]; } +function TODO_formStateDispatch() { + throw new Error('Not implemented.'); +} + +function mountFormState( + action: (S, P) => S, + initialState: S, + url?: string, +): [S, (P) => void] { + // TODO: Not yet implemented + return [initialState, TODO_formStateDispatch]; +} + +function updateFormState( + action: (S, P) => S, + initialState: S, + url?: string, +): [S, (P) => void] { + // TODO: Not yet implemented + return [initialState, TODO_formStateDispatch]; +} + +function rerenderFormState( + action: (S, P) => S, + initialState: S, + url?: string, +): [S, (P) => void] { + // TODO: Not yet implemented + return [initialState, TODO_formStateDispatch]; +} + function pushEffect( tag: HookFlags, create: () => (() => void) | void, @@ -2984,6 +3015,7 @@ if (enableFormActions && enableAsyncActions) { } if (enableAsyncActions) { (ContextOnlyDispatcher: Dispatcher).useOptimistic = throwInvalidHookError; + (ContextOnlyDispatcher: Dispatcher).useFormState = throwInvalidHookError; } const HooksDispatcherOnMount: Dispatcher = { @@ -3021,6 +3053,7 @@ if (enableFormActions && enableAsyncActions) { } if (enableAsyncActions) { (HooksDispatcherOnMount: Dispatcher).useOptimistic = mountOptimistic; + (HooksDispatcherOnMount: Dispatcher).useFormState = mountFormState; } const HooksDispatcherOnUpdate: Dispatcher = { @@ -3058,6 +3091,7 @@ if (enableFormActions && enableAsyncActions) { } if (enableAsyncActions) { (HooksDispatcherOnUpdate: Dispatcher).useOptimistic = updateOptimistic; + (HooksDispatcherOnUpdate: Dispatcher).useFormState = updateFormState; } const HooksDispatcherOnRerender: Dispatcher = { @@ -3095,6 +3129,7 @@ if (enableFormActions && enableAsyncActions) { } if (enableAsyncActions) { (HooksDispatcherOnRerender: Dispatcher).useOptimistic = rerenderOptimistic; + (HooksDispatcherOnRerender: Dispatcher).useFormState = rerenderFormState; } let HooksDispatcherOnMountInDEV: Dispatcher | null = null; @@ -3287,6 +3322,16 @@ if (__DEV__) { mountHookTypesDev(); return mountOptimistic(passthrough, reducer); }; + (HooksDispatcherOnMountInDEV: Dispatcher).useFormState = + function useFormState( + action: (S, P) => S, + initialState: S, + url?: string, + ): [S, (P) => void] { + currentHookNameInDev = 'useFormState'; + mountHookTypesDev(); + return mountFormState(action, initialState, url); + }; } HooksDispatcherOnMountWithHookTypesInDEV = { @@ -3447,6 +3492,16 @@ if (__DEV__) { updateHookTypesDev(); return mountOptimistic(passthrough, reducer); }; + (HooksDispatcherOnMountWithHookTypesInDEV: Dispatcher).useFormState = + function useFormState( + action: (S, P) => S, + initialState: S, + url?: string, + ): [S, (P) => void] { + currentHookNameInDev = 'useFormState'; + updateHookTypesDev(); + return mountFormState(action, initialState, url); + }; } HooksDispatcherOnUpdateInDEV = { @@ -3609,6 +3664,16 @@ if (__DEV__) { updateHookTypesDev(); return updateOptimistic(passthrough, reducer); }; + (HooksDispatcherOnUpdateInDEV: Dispatcher).useFormState = + function useFormState( + action: (S, P) => S, + initialState: S, + url?: string, + ): [S, (P) => void] { + currentHookNameInDev = 'useFormState'; + updateHookTypesDev(); + return updateFormState(action, initialState, url); + }; } HooksDispatcherOnRerenderInDEV = { @@ -3771,6 +3836,16 @@ if (__DEV__) { updateHookTypesDev(); return rerenderOptimistic(passthrough, reducer); }; + (HooksDispatcherOnRerenderInDEV: Dispatcher).useFormState = + function useFormState( + action: (S, P) => S, + initialState: S, + url?: string, + ): [S, (P) => void] { + currentHookNameInDev = 'useFormState'; + updateHookTypesDev(); + return rerenderFormState(action, initialState, url); + }; } InvalidNestedHooksDispatcherOnMountInDEV = { @@ -3955,6 +4030,17 @@ if (__DEV__) { mountHookTypesDev(); return mountOptimistic(passthrough, reducer); }; + (InvalidNestedHooksDispatcherOnMountInDEV: Dispatcher).useFormState = + function useFormState( + action: (S, P) => S, + initialState: S, + url?: string, + ): [S, (P) => void] { + currentHookNameInDev = 'useFormState'; + warnInvalidHookAccess(); + mountHookTypesDev(); + return mountFormState(action, initialState, url); + }; } InvalidNestedHooksDispatcherOnUpdateInDEV = { @@ -4142,6 +4228,17 @@ if (__DEV__) { updateHookTypesDev(); return updateOptimistic(passthrough, reducer); }; + (InvalidNestedHooksDispatcherOnUpdateInDEV: Dispatcher).useFormState = + function useFormState( + action: (S, P) => S, + initialState: S, + url?: string, + ): [S, (P) => void] { + currentHookNameInDev = 'useFormState'; + warnInvalidHookAccess(); + updateHookTypesDev(); + return updateFormState(action, initialState, url); + }; } InvalidNestedHooksDispatcherOnRerenderInDEV = { @@ -4329,5 +4426,16 @@ if (__DEV__) { updateHookTypesDev(); return rerenderOptimistic(passthrough, reducer); }; + (InvalidNestedHooksDispatcherOnRerenderInDEV: Dispatcher).useFormState = + function useFormState( + action: (S, P) => S, + initialState: S, + url?: string, + ): [S, (P) => void] { + currentHookNameInDev = 'useFormState'; + warnInvalidHookAccess(); + updateHookTypesDev(); + return rerenderFormState(action, initialState, url); + }; } } diff --git a/packages/react-reconciler/src/ReactInternalTypes.js b/packages/react-reconciler/src/ReactInternalTypes.js index 50a0ec85f9f25..2372fc13051ba 100644 --- a/packages/react-reconciler/src/ReactInternalTypes.js +++ b/packages/react-reconciler/src/ReactInternalTypes.js @@ -53,7 +53,8 @@ export type HookType = | 'useSyncExternalStore' | 'useId' | 'useCacheRefresh' - | 'useOptimistic'; + | 'useOptimistic' + | 'useFormState'; export type ContextDependency = { context: ReactContext, @@ -413,6 +414,11 @@ export type Dispatcher = { passthrough: S, reducer: ?(S, A) => S, ) => [S, (A) => void], + useFormState?: ( + action: (S, P) => S, + initialState: S, + url?: string, + ) => [S, (P) => 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 65b14b87f569c..a8849b3768683 100644 --- a/packages/react-reconciler/src/__tests__/ReactAsyncActions-test.js +++ b/packages/react-reconciler/src/__tests__/ReactAsyncActions-test.js @@ -6,6 +6,7 @@ let assertLog; let useTransition; let useState; let useOptimistic; +let useFormState; let textCache; describe('ReactAsyncActions', () => { @@ -20,6 +21,7 @@ describe('ReactAsyncActions', () => { useTransition = React.useTransition; useState = React.useState; useOptimistic = React.experimental_useOptimistic; + useFormState = React.experimental_useFormState; textCache = new Map(); }); @@ -1074,4 +1076,23 @@ describe('ReactAsyncActions', () => { , ); }); + + // @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; + } + + function App() { + const [state] = useFormState(action, 0); + return ; + } + + const root = ReactNoop.createRoot(); + await act(() => root.render()); + assertLog([0]); + expect(root).toMatchRenderedOutput('0'); + }); }); diff --git a/packages/react-server/src/ReactFizzHooks.js b/packages/react-server/src/ReactFizzHooks.js index f832aeb578fcb..dd56dceaf8c03 100644 --- a/packages/react-server/src/ReactFizzHooks.js +++ b/packages/react-server/src/ReactFizzHooks.js @@ -542,6 +542,10 @@ function unsupportedSetOptimisticState() { throw new Error('Cannot update optimistic state while rendering.'); } +function unsupportedDispatchFormState() { + throw new Error('Cannot update form state while rendering.'); +} + function useOptimistic( passthrough: S, reducer: ?(S, A) => S, @@ -550,6 +554,15 @@ function useOptimistic( return [passthrough, unsupportedSetOptimisticState]; } +function useFormState( + action: (S, P) => S, + initialState: S, + url?: string, +): [S, (P) => void] { + resolveCurrentlyRenderingComponent(); + return [initialState, unsupportedDispatchFormState]; +} + function useId(): string { const task: Task = (currentlyRenderingTask: any); const treeId = getTreeId(task.treeContext); @@ -650,6 +663,7 @@ if (enableFormActions && enableAsyncActions) { } if (enableAsyncActions) { HooksDispatcher.useOptimistic = useOptimistic; + HooksDispatcher.useFormState = useFormState; } export let currentResponseState: null | ResponseState = (null: any); diff --git a/packages/react/index.classic.fb.js b/packages/react/index.classic.fb.js index 2d2c4ea8ff180..31c5990432876 100644 --- a/packages/react/index.classic.fb.js +++ b/packages/react/index.classic.fb.js @@ -53,6 +53,7 @@ export { useInsertionEffect, useMemo, experimental_useOptimistic, + experimental_useFormState, useReducer, useRef, useState, diff --git a/packages/react/index.experimental.js b/packages/react/index.experimental.js index 081fa2746964b..7e07b970333ff 100644 --- a/packages/react/index.experimental.js +++ b/packages/react/index.experimental.js @@ -51,6 +51,7 @@ export { useLayoutEffect, useMemo, experimental_useOptimistic, + experimental_useFormState, useReducer, useRef, useState, diff --git a/packages/react/index.js b/packages/react/index.js index fd2c377668c21..5a59a730c4af4 100644 --- a/packages/react/index.js +++ b/packages/react/index.js @@ -76,6 +76,7 @@ export { useLayoutEffect, useMemo, experimental_useOptimistic, + experimental_useFormState, useSyncExternalStore, useReducer, useRef, diff --git a/packages/react/index.modern.fb.js b/packages/react/index.modern.fb.js index 2658db8acfc82..3c7f15fcf2e2b 100644 --- a/packages/react/index.modern.fb.js +++ b/packages/react/index.modern.fb.js @@ -51,6 +51,7 @@ export { useLayoutEffect, useMemo, experimental_useOptimistic, + experimental_useFormState, useReducer, useRef, useState, diff --git a/packages/react/src/React.js b/packages/react/src/React.js index b45a0cda05370..e6a0a43751cfd 100644 --- a/packages/react/src/React.js +++ b/packages/react/src/React.js @@ -60,6 +60,7 @@ import { use, useMemoCache, useOptimistic, + useFormState, } from './ReactHooks'; import { createElementWithValidation, @@ -112,6 +113,7 @@ export { useLayoutEffect, useMemo, useOptimistic as experimental_useOptimistic, + useFormState as experimental_useFormState, useSyncExternalStore, useReducer, useRef, diff --git a/packages/react/src/ReactHooks.js b/packages/react/src/ReactHooks.js index 112baab1eaa5f..22e9761b31000 100644 --- a/packages/react/src/ReactHooks.js +++ b/packages/react/src/ReactHooks.js @@ -237,3 +237,13 @@ export function useOptimistic( // $FlowFixMe[not-a-function] This is unstable, thus optional return dispatcher.useOptimistic(passthrough, reducer); } + +export function useFormState( + action: (S, P) => S, + initialState: S, + url?: string, +): [S, (P) => void] { + const dispatcher = resolveDispatcher(); + // $FlowFixMe[not-a-function] This is unstable, thus optional + return dispatcher.useFormState(action, initialState, url); +}