From f1aa04b3ebddaf6aad26892c95bb60c44cd05ab4 Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Wed, 23 Aug 2023 10:58:09 -0400 Subject: [PATCH] Scaffolding for useFormState (#27270) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This exposes, but does not yet implement, a new experimental API called useFormState. It's gated behind the enableAsyncActions flag. useFormState has a similar signature to useReducer, except instead of a reducer it accepts an (async) action function. React will wait until the promise resolves before updating the state: ```js async function action(prevState, payload) { // .. } const [state, dispatch] = useFormState(action, initialState) ``` When used in combination with Server Actions, it will also support progressive enhancement — a form that is submitted before it has hydrated will have its state transferred to the next page. However, like the other action-related hooks, it works with fully client-driven actions, too. --- .eslintrc.js | 1 + .../src/shared/ReactDOMFormActions.js | 14 +++ packages/react-dom/index.classic.fb.js | 1 + packages/react-dom/index.experimental.js | 1 + packages/react-dom/index.js | 1 + packages/react-dom/index.modern.fb.js | 1 + packages/react-dom/server-rendering-stub.js | 1 + .../src/__tests__/ReactDOMFizzForm-test.js | 24 ++++ .../src/__tests__/ReactDOMForm-test.js | 22 ++++ packages/react-dom/src/client/ReactDOM.js | 5 +- .../src/server/ReactDOMServerRenderingStub.js | 5 +- .../react-reconciler/src/ReactFiberHooks.js | 108 ++++++++++++++++++ .../src/ReactInternalTypes.js | 8 +- packages/react-server/src/ReactFizzHooks.js | 14 +++ scripts/error-codes/codes.json | 3 +- 15 files changed, 205 insertions(+), 4 deletions(-) diff --git a/.eslintrc.js b/.eslintrc.js index 2d0e4abe710de..35e83b0d7abcc 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-bindings/src/shared/ReactDOMFormActions.js b/packages/react-dom-bindings/src/shared/ReactDOMFormActions.js index addb70394b9a0..28914b354ba71 100644 --- a/packages/react-dom-bindings/src/shared/ReactDOMFormActions.js +++ b/packages/react-dom-bindings/src/shared/ReactDOMFormActions.js @@ -74,3 +74,17 @@ export function useFormStatus(): FormStatus { return dispatcher.useHostTransitionStatus(); } } + +export function useFormState( + action: (S, P) => S, + initialState: S, + url?: string, +): [S, (P) => void] { + if (!(enableFormActions && enableAsyncActions)) { + throw new Error('Not implemented.'); + } else { + const dispatcher = resolveDispatcher(); + // $FlowFixMe[not-a-function] This is unstable, thus optional + return dispatcher.useFormState(action, initialState, url); + } +} diff --git a/packages/react-dom/index.classic.fb.js b/packages/react-dom/index.classic.fb.js index 422cc977a5d7a..4a8521810551b 100644 --- a/packages/react-dom/index.classic.fb.js +++ b/packages/react-dom/index.classic.fb.js @@ -32,6 +32,7 @@ export { unstable_renderSubtreeIntoContainer, unstable_runWithPriority, // DO NOT USE: Temporarily exposed to migrate off of Scheduler.runWithPriority. useFormStatus as experimental_useFormStatus, + useFormState as experimental_useFormState, prefetchDNS, preconnect, preload, diff --git a/packages/react-dom/index.experimental.js b/packages/react-dom/index.experimental.js index 0d945c25511e7..39151bc1bfcf8 100644 --- a/packages/react-dom/index.experimental.js +++ b/packages/react-dom/index.experimental.js @@ -21,6 +21,7 @@ export { unstable_renderSubtreeIntoContainer, unstable_runWithPriority, // DO NOT USE: Temporarily exposed to migrate off of Scheduler.runWithPriority. useFormStatus as experimental_useFormStatus, + useFormState as experimental_useFormState, prefetchDNS, preconnect, preload, diff --git a/packages/react-dom/index.js b/packages/react-dom/index.js index 58bcd92dc7a03..7d9dff6b469c5 100644 --- a/packages/react-dom/index.js +++ b/packages/react-dom/index.js @@ -24,6 +24,7 @@ export { unstable_renderSubtreeIntoContainer, unstable_runWithPriority, // DO NOT USE: Temporarily exposed to migrate off of Scheduler.runWithPriority. useFormStatus as experimental_useFormStatus, + useFormState as experimental_useFormState, prefetchDNS, preconnect, preload, diff --git a/packages/react-dom/index.modern.fb.js b/packages/react-dom/index.modern.fb.js index bbfaa1cbf4a55..c1548b73036c3 100644 --- a/packages/react-dom/index.modern.fb.js +++ b/packages/react-dom/index.modern.fb.js @@ -17,6 +17,7 @@ export { unstable_createEventHandle, unstable_runWithPriority, // DO NOT USE: Temporarily exposed to migrate off of Scheduler.runWithPriority. useFormStatus as experimental_useFormStatus, + useFormState as experimental_useFormState, prefetchDNS, preconnect, preload, diff --git a/packages/react-dom/server-rendering-stub.js b/packages/react-dom/server-rendering-stub.js index cda0c95579ed9..41861fbfc9ea9 100644 --- a/packages/react-dom/server-rendering-stub.js +++ b/packages/react-dom/server-rendering-stub.js @@ -23,5 +23,6 @@ export { preload, preinit, experimental_useFormStatus, + experimental_useFormState, unstable_batchedUpdates, } from './src/server/ReactDOMServerRenderingStub'; diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzForm-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzForm-test.js index b13eb90304a34..b9ffe6520c82a 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(() => { @@ -31,6 +32,7 @@ describe('ReactDOMFizzForm', () => { ReactDOMServer = require('react-dom/server.browser'); ReactDOMClient = require('react-dom/client'); useFormStatus = require('react-dom').experimental_useFormStatus; + useFormState = require('react-dom').experimental_useFormState; useOptimistic = require('react').experimental_useOptimistic; act = require('internal-test-utils').act; container = document.createElement('div'); @@ -470,6 +472,28 @@ describe('ReactDOMFizzForm', () => { expect(container.textContent).toBe('hi'); }); + // @gate enableFormActions + // @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-dom/src/__tests__/ReactDOMForm-test.js b/packages/react-dom/src/__tests__/ReactDOMForm-test.js index 2cb2144f532aa..184ef8ca74b45 100644 --- a/packages/react-dom/src/__tests__/ReactDOMForm-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMForm-test.js @@ -40,6 +40,7 @@ describe('ReactDOMForm', () => { let startTransition; let textCache; let useFormStatus; + let useFormState; beforeEach(() => { jest.resetModules(); @@ -53,6 +54,7 @@ describe('ReactDOMForm', () => { Suspense = React.Suspense; startTransition = React.startTransition; useFormStatus = ReactDOM.experimental_useFormStatus; + useFormState = ReactDOM.experimental_useFormState; container = document.createElement('div'); document.body.appendChild(container); @@ -969,4 +971,24 @@ describe('ReactDOMForm', () => { 'A React form was unexpectedly submitted. If you called form.submit()', ); }); + + // @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; + } + + function App() { + const [state] = useFormState(action, 0); + return ; + } + + const root = ReactDOMClient.createRoot(container); + await act(() => root.render()); + assertLog([0]); + expect(container.textContent).toBe('0'); + }); }); diff --git a/packages/react-dom/src/client/ReactDOM.js b/packages/react-dom/src/client/ReactDOM.js index fd7f38981f7d6..f2e4c626070f4 100644 --- a/packages/react-dom/src/client/ReactDOM.js +++ b/packages/react-dom/src/client/ReactDOM.js @@ -63,7 +63,10 @@ export { preinit, preinitModule, } from '../shared/ReactDOMFloat'; -export {useFormStatus} from 'react-dom-bindings/src/shared/ReactDOMFormActions'; +export { + useFormStatus, + useFormState, +} from 'react-dom-bindings/src/shared/ReactDOMFormActions'; if (__DEV__) { if ( diff --git a/packages/react-dom/src/server/ReactDOMServerRenderingStub.js b/packages/react-dom/src/server/ReactDOMServerRenderingStub.js index dec67c41058f2..537f8c5618c39 100644 --- a/packages/react-dom/src/server/ReactDOMServerRenderingStub.js +++ b/packages/react-dom/src/server/ReactDOMServerRenderingStub.js @@ -13,7 +13,10 @@ export { preconnect, prefetchDNS, } from '../shared/ReactDOMFloat'; -export {useFormStatus as experimental_useFormStatus} from 'react-dom-bindings/src/shared/ReactDOMFormActions'; +export { + useFormStatus as experimental_useFormStatus, + useFormState as experimental_useFormState, +} from 'react-dom-bindings/src/shared/ReactDOMFormActions'; export function createPortal() { throw new Error( diff --git a/packages/react-reconciler/src/ReactFiberHooks.js b/packages/react-reconciler/src/ReactFiberHooks.js index eaa4826cbda01..9d04b94deaf75 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, @@ -2981,6 +3012,7 @@ if (enableUseEffectEventHook) { if (enableFormActions && enableAsyncActions) { (ContextOnlyDispatcher: Dispatcher).useHostTransitionStatus = throwInvalidHookError; + (ContextOnlyDispatcher: Dispatcher).useFormState = throwInvalidHookError; } if (enableAsyncActions) { (ContextOnlyDispatcher: Dispatcher).useOptimistic = throwInvalidHookError; @@ -3018,6 +3050,7 @@ if (enableUseEffectEventHook) { if (enableFormActions && enableAsyncActions) { (HooksDispatcherOnMount: Dispatcher).useHostTransitionStatus = useHostTransitionStatus; + (HooksDispatcherOnMount: Dispatcher).useFormState = mountFormState; } if (enableAsyncActions) { (HooksDispatcherOnMount: Dispatcher).useOptimistic = mountOptimistic; @@ -3055,6 +3088,7 @@ if (enableUseEffectEventHook) { if (enableFormActions && enableAsyncActions) { (HooksDispatcherOnUpdate: Dispatcher).useHostTransitionStatus = useHostTransitionStatus; + (HooksDispatcherOnUpdate: Dispatcher).useFormState = updateFormState; } if (enableAsyncActions) { (HooksDispatcherOnUpdate: Dispatcher).useOptimistic = updateOptimistic; @@ -3092,6 +3126,7 @@ if (enableUseEffectEventHook) { if (enableFormActions && enableAsyncActions) { (HooksDispatcherOnRerender: Dispatcher).useHostTransitionStatus = useHostTransitionStatus; + (HooksDispatcherOnRerender: Dispatcher).useFormState = rerenderFormState; } if (enableAsyncActions) { (HooksDispatcherOnRerender: Dispatcher).useOptimistic = rerenderOptimistic; @@ -3276,6 +3311,16 @@ if (__DEV__) { if (enableFormActions && enableAsyncActions) { (HooksDispatcherOnMountInDEV: Dispatcher).useHostTransitionStatus = useHostTransitionStatus; + (HooksDispatcherOnMountInDEV: Dispatcher).useFormState = + function useFormState( + action: (S, P) => S, + initialState: S, + url?: string, + ): [S, (P) => void] { + currentHookNameInDev = 'useFormState'; + mountHookTypesDev(); + return mountFormState(action, initialState, url); + }; } if (enableAsyncActions) { (HooksDispatcherOnMountInDEV: Dispatcher).useOptimistic = @@ -3436,6 +3481,16 @@ if (__DEV__) { if (enableFormActions && enableAsyncActions) { (HooksDispatcherOnMountWithHookTypesInDEV: Dispatcher).useHostTransitionStatus = useHostTransitionStatus; + (HooksDispatcherOnMountWithHookTypesInDEV: Dispatcher).useFormState = + function useFormState( + action: (S, P) => S, + initialState: S, + url?: string, + ): [S, (P) => void] { + currentHookNameInDev = 'useFormState'; + updateHookTypesDev(); + return mountFormState(action, initialState, url); + }; } if (enableAsyncActions) { (HooksDispatcherOnMountWithHookTypesInDEV: Dispatcher).useOptimistic = @@ -3598,6 +3653,16 @@ if (__DEV__) { if (enableFormActions && enableAsyncActions) { (HooksDispatcherOnUpdateInDEV: Dispatcher).useHostTransitionStatus = useHostTransitionStatus; + (HooksDispatcherOnUpdateInDEV: Dispatcher).useFormState = + function useFormState( + action: (S, P) => S, + initialState: S, + url?: string, + ): [S, (P) => void] { + currentHookNameInDev = 'useFormState'; + updateHookTypesDev(); + return updateFormState(action, initialState, url); + }; } if (enableAsyncActions) { (HooksDispatcherOnUpdateInDEV: Dispatcher).useOptimistic = @@ -3760,6 +3825,16 @@ if (__DEV__) { if (enableFormActions && enableAsyncActions) { (HooksDispatcherOnRerenderInDEV: Dispatcher).useHostTransitionStatus = useHostTransitionStatus; + (HooksDispatcherOnRerenderInDEV: Dispatcher).useFormState = + function useFormState( + action: (S, P) => S, + initialState: S, + url?: string, + ): [S, (P) => void] { + currentHookNameInDev = 'useFormState'; + updateHookTypesDev(); + return rerenderFormState(action, initialState, url); + }; } if (enableAsyncActions) { (HooksDispatcherOnRerenderInDEV: Dispatcher).useOptimistic = @@ -3943,6 +4018,17 @@ if (__DEV__) { if (enableFormActions && enableAsyncActions) { (InvalidNestedHooksDispatcherOnMountInDEV: Dispatcher).useHostTransitionStatus = useHostTransitionStatus; + (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); + }; } if (enableAsyncActions) { (InvalidNestedHooksDispatcherOnMountInDEV: Dispatcher).useOptimistic = @@ -4130,6 +4216,17 @@ if (__DEV__) { if (enableFormActions && enableAsyncActions) { (InvalidNestedHooksDispatcherOnUpdateInDEV: Dispatcher).useHostTransitionStatus = useHostTransitionStatus; + (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); + }; } if (enableAsyncActions) { (InvalidNestedHooksDispatcherOnUpdateInDEV: Dispatcher).useOptimistic = @@ -4317,6 +4414,17 @@ if (__DEV__) { if (enableFormActions && enableAsyncActions) { (InvalidNestedHooksDispatcherOnRerenderInDEV: Dispatcher).useHostTransitionStatus = useHostTransitionStatus; + (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); + }; } if (enableAsyncActions) { (InvalidNestedHooksDispatcherOnRerenderInDEV: Dispatcher).useOptimistic = 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-server/src/ReactFizzHooks.js b/packages/react-server/src/ReactFizzHooks.js index db32d407b2907..7fa62950229b3 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 currentResumableState: null | ResumableState = (null: any); diff --git a/scripts/error-codes/codes.json b/scripts/error-codes/codes.json index efa16f3b35a63..b87b82fafd316 100644 --- a/scripts/error-codes/codes.json +++ b/scripts/error-codes/codes.json @@ -469,5 +469,6 @@ "481": "Tried to encode a Server Action from a different instance than the encoder is from. This is a bug in React.", "482": "async/await is not yet supported in Client Components, only Server Components. This error is often caused by accidentally adding `'use client'` to a module that was originally written for the server.", "483": "Hooks are not supported inside an async component. This error is often caused by accidentally adding `'use client'` to a module that was originally written for the server.", - "484": "A Server Component was postponed. The reason is omitted in production builds to avoid leaking sensitive details." + "484": "A Server Component was postponed. The reason is omitted in production builds to avoid leaking sensitive details.", + "485": "Cannot update form state while rendering." } \ No newline at end of file