diff --git a/packages/react-dom-bindings/src/shared/ReactDOMFormActions.js b/packages/react-dom-bindings/src/shared/ReactDOMFormActions.js index ec29a67b508af..8c0f764d6cab5 100644 --- a/packages/react-dom-bindings/src/shared/ReactDOMFormActions.js +++ b/packages/react-dom-bindings/src/shared/ReactDOMFormActions.js @@ -78,13 +78,13 @@ export function useFormStatus(): FormStatus { export function useFormState( action: (S, P) => Promise, initialState: S, - url?: string, + permalink?: 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); + return dispatcher.useFormState(action, initialState, permalink); } } diff --git a/packages/react-reconciler/src/ReactFiberHooks.js b/packages/react-reconciler/src/ReactFiberHooks.js index d45dbaedc7753..92864e038b2d2 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.js +++ b/packages/react-reconciler/src/ReactFiberHooks.js @@ -2008,7 +2008,7 @@ function formStateReducer(oldState: S, newState: S): S { function mountFormState( action: (S, P) => Promise, initialState: S, - url?: string, + permalink?: string, ): [S, (P) => void] { // State hook. The state is stored in a thenable which is then unwrapped by // the `use` algorithm during render. @@ -2063,7 +2063,7 @@ function mountFormState( function updateFormState( action: (S, P) => Promise, initialState: S, - url?: string, + permalink?: string, ): [S, (P) => void] { const stateHook = updateWorkInProgressHook(); const currentStateHook = ((currentHook: any): Hook); @@ -2072,7 +2072,7 @@ function updateFormState( currentStateHook, action, initialState, - url, + permalink, ); } @@ -2081,7 +2081,7 @@ function updateFormStateImpl( currentStateHook: Hook, action: (S, P) => Promise, initialState: S, - url?: string, + permalink?: string, ): [S, (P) => void] { const [thenable] = updateReducerImpl, Thenable>( stateHook, @@ -2121,7 +2121,7 @@ function formStateActionEffect( function rerenderFormState( action: (S, P) => Promise, initialState: S, - url?: string, + permalink?: string, ): [S, (P) => void] { // Unlike useState, useFormState doesn't support render phase updates. // Also unlike useState, we need to replay all pending updates again in case @@ -2140,7 +2140,7 @@ function rerenderFormState( currentStateHook, action, initialState, - url, + permalink, ); } @@ -3628,11 +3628,11 @@ if (__DEV__) { function useFormState( action: (S, P) => Promise, initialState: S, - url?: string, + permalink?: string, ): [S, (P) => void] { currentHookNameInDev = 'useFormState'; mountHookTypesDev(); - return mountFormState(action, initialState, url); + return mountFormState(action, initialState, permalink); }; } if (enableAsyncActions) { @@ -3798,11 +3798,11 @@ if (__DEV__) { function useFormState( action: (S, P) => Promise, initialState: S, - url?: string, + permalink?: string, ): [S, (P) => void] { currentHookNameInDev = 'useFormState'; updateHookTypesDev(); - return mountFormState(action, initialState, url); + return mountFormState(action, initialState, permalink); }; } if (enableAsyncActions) { @@ -3970,11 +3970,11 @@ if (__DEV__) { function useFormState( action: (S, P) => Promise, initialState: S, - url?: string, + permalink?: string, ): [S, (P) => void] { currentHookNameInDev = 'useFormState'; updateHookTypesDev(); - return updateFormState(action, initialState, url); + return updateFormState(action, initialState, permalink); }; } if (enableAsyncActions) { @@ -4142,11 +4142,11 @@ if (__DEV__) { function useFormState( action: (S, P) => Promise, initialState: S, - url?: string, + permalink?: string, ): [S, (P) => void] { currentHookNameInDev = 'useFormState'; updateHookTypesDev(); - return rerenderFormState(action, initialState, url); + return rerenderFormState(action, initialState, permalink); }; } if (enableAsyncActions) { @@ -4335,12 +4335,12 @@ if (__DEV__) { function useFormState( action: (S, P) => Promise, initialState: S, - url?: string, + permalink?: string, ): [S, (P) => void] { currentHookNameInDev = 'useFormState'; warnInvalidHookAccess(); mountHookTypesDev(); - return mountFormState(action, initialState, url); + return mountFormState(action, initialState, permalink); }; } if (enableAsyncActions) { @@ -4533,12 +4533,12 @@ if (__DEV__) { function useFormState( action: (S, P) => Promise, initialState: S, - url?: string, + permalink?: string, ): [S, (P) => void] { currentHookNameInDev = 'useFormState'; warnInvalidHookAccess(); updateHookTypesDev(); - return updateFormState(action, initialState, url); + return updateFormState(action, initialState, permalink); }; } if (enableAsyncActions) { @@ -4731,12 +4731,12 @@ if (__DEV__) { function useFormState( action: (S, P) => Promise, initialState: S, - url?: string, + permalink?: string, ): [S, (P) => void] { currentHookNameInDev = 'useFormState'; warnInvalidHookAccess(); updateHookTypesDev(); - return rerenderFormState(action, initialState, url); + return rerenderFormState(action, initialState, permalink); }; } if (enableAsyncActions) { diff --git a/packages/react-reconciler/src/ReactInternalTypes.js b/packages/react-reconciler/src/ReactInternalTypes.js index e852057f20c1a..8dae4fa10e07f 100644 --- a/packages/react-reconciler/src/ReactInternalTypes.js +++ b/packages/react-reconciler/src/ReactInternalTypes.js @@ -417,7 +417,7 @@ export type Dispatcher = { useFormState?: ( action: (S, P) => Promise, initialState: S, - url?: string, + permalink?: string, ) => [S, (P) => void], }; 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 e66a368a11c93..7e1955b753107 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMForm-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMForm-test.js @@ -30,6 +30,7 @@ let React; let ReactDOMServer; let ReactServerDOMServer; let ReactServerDOMClient; +let useFormState; describe('ReactFlightDOMForm', () => { beforeEach(() => { @@ -47,6 +48,7 @@ describe('ReactFlightDOMForm', () => { ReactServerDOMServer = require('react-server-dom-webpack/server.edge'); ReactServerDOMClient = require('react-server-dom-webpack/client.edge'); ReactDOMServer = require('react-dom/server.edge'); + useFormState = require('react-dom').experimental_useFormState; container = document.createElement('div'); document.body.appendChild(container); }); @@ -308,4 +310,123 @@ describe('ReactFlightDOMForm', () => { expect(result).toBe('hello'); expect(foo).toBe('barobject'); }); + + // @gate enableFormActions + // @gate enableAsyncActions + it("useFormState's dispatch binds the initial state to the provided action", async () => { + let serverActionResult = null; + + const serverAction = serverExports(function action(prevState, formData) { + const newState = { + count: prevState.count + parseInt(formData.get('incrementAmount'), 10), + }; + serverActionResult = newState; + return newState; + }); + + const initialState = {count: 1}; + function Client({action}) { + const [state, dispatch] = useFormState(action, initialState); + return ( +
+ Count: {state.count} + +
+ ); + } + const ClientRef = await clientExports(Client); + + const rscStream = ReactServerDOMServer.renderToReadableStream( + , + webpackMap, + ); + const response = ReactServerDOMClient.createFromReadableStream(rscStream); + const ssrStream = await ReactDOMServer.renderToReadableStream(response); + await readIntoContainer(ssrStream); + + const form = container.firstChild; + const span = container.getElementsByTagName('span')[0]; + expect(span.textContent).toBe('Count: 1'); + + await submit(form); + expect(serverActionResult.count).toBe(6); + }); + + // @gate enableFormActions + // @gate enableAsyncActions + it("useFormState can change the action's target 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] = useFormState( + action, + initialState, + '/permalink', + ); + return ( +
+ Count: {state.count} +
+ ); + } + const ClientRef = await clientExports(Client); + + const rscStream = ReactServerDOMServer.renderToReadableStream( + , + webpackMap, + ); + const response = ReactServerDOMClient.createFromReadableStream(rscStream); + const ssrStream = await ReactDOMServer.renderToReadableStream(response); + await readIntoContainer(ssrStream); + + const form = container.firstChild; + const span = container.getElementsByTagName('span')[0]; + expect(span.textContent).toBe('Count: 1'); + + expect(form.target).toBe('/permalink'); + }); + + // @gate enableFormActions + // @gate enableAsyncActions + it('useFormState `permalink` is coerced to string', async () => { + const serverAction = serverExports(function action(prevState) { + return {state: prevState.count + 1}; + }); + + class Permalink { + toString() { + return '/permalink'; + } + } + + const permalink = new Permalink(); + + const initialState = {count: 1}; + function Client({action}) { + const [state, dispatch] = useFormState(action, initialState, permalink); + return ( +
+ Count: {state.count} +
+ ); + } + const ClientRef = await clientExports(Client); + + const rscStream = ReactServerDOMServer.renderToReadableStream( + , + webpackMap, + ); + const response = ReactServerDOMClient.createFromReadableStream(rscStream); + const ssrStream = await ReactDOMServer.renderToReadableStream(response); + await readIntoContainer(ssrStream); + + const form = container.firstChild; + const span = container.getElementsByTagName('span')[0]; + expect(span.textContent).toBe('Count: 1'); + + expect(form.target).toBe('/permalink'); + }); }); diff --git a/packages/react-server/src/ReactFizzHooks.js b/packages/react-server/src/ReactFizzHooks.js index 0df810e88da60..377c21a9fe637 100644 --- a/packages/react-server/src/ReactFizzHooks.js +++ b/packages/react-server/src/ReactFizzHooks.js @@ -14,6 +14,7 @@ import type { StartTransitionOptions, Thenable, Usable, + ReactCustomFormAction, } from 'shared/ReactTypes'; import type {ResumableState} from './ReactFizzConfig'; @@ -40,6 +41,7 @@ import { REACT_CONTEXT_TYPE, REACT_MEMO_CACHE_SENTINEL, } from 'shared/ReactSymbols'; +import {checkAttributeStringCoercion} from 'shared/CheckStringCoercion'; type BasicStateAction = (S => S) | S; type Dispatch = A => void; @@ -542,10 +544,6 @@ 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, @@ -557,10 +555,41 @@ function useOptimistic( function useFormState( action: (S, P) => Promise, initialState: S, - url?: string, + permalink?: string, ): [S, (P) => void] { resolveCurrentlyRenderingComponent(); - return [initialState, unsupportedDispatchFormState]; + + // Bind the initial state to the first argument of the action. + // TODO: Use the keypath (or permalink) to check if there's matching state + // from the previous page. + const boundAction = action.bind(null, initialState); + + // Wrap the action so the return value is void. + const dispatch = (payload: P): void => { + boundAction(payload); + }; + + // $FlowIgnore[prop-missing] + if (typeof boundAction.$$FORM_ACTION === 'function') { + // $FlowIgnore[prop-missing] + dispatch.$$FORM_ACTION = (prefix: string) => { + // $FlowIgnore[prop-missing] + const metadata: ReactCustomFormAction = boundAction.$$FORM_ACTION(prefix); + // Override the target URL + if (permalink !== undefined) { + if (__DEV__) { + checkAttributeStringCoercion(permalink, 'target'); + } + metadata.target = permalink + ''; + } + return metadata; + }; + } else { + // This is not a server action, so the permalink argument has + // no effect. The form will have to be hydrated before it's submitted. + } + + return [initialState, dispatch]; } function useId(): string {