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..03810d4066b10 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzForm-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzForm-test.js @@ -24,6 +24,7 @@ let ReactDOMClient; let useFormStatus; let useOptimistic; let useFormState; +let useActionState; describe('ReactDOMFizzForm', () => { beforeEach(() => { @@ -34,6 +35,7 @@ describe('ReactDOMFizzForm', () => { useFormStatus = require('react-dom').useFormStatus; useFormState = require('react-dom').useFormState; useOptimistic = require('react').useOptimistic; + useActionState = require('react').useActionState; act = require('internal-test-utils').act; container = document.createElement('div'); document.body.appendChild(container); @@ -494,6 +496,28 @@ describe('ReactDOMFizzForm', () => { expect(container.textContent).toBe('0'); }); + // @gate enableFormActions + // @gate enableAsyncActions + it('useActionState returns initial state', async () => { + async function action(state) { + return state; + } + + function App() { + const [state] = useActionState(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__/ReactDOMFizzServer-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js index 0d49e7bae122a..20efc70c00977 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js @@ -31,6 +31,7 @@ let useSyncExternalStore; let useSyncExternalStoreWithSelector; let use; let useFormState; +let useActionState; let PropTypes; let textCache; let writable; @@ -90,7 +91,7 @@ describe('ReactDOMFizzServer', () => { SuspenseList = React.unstable_SuspenseList; } useFormState = ReactDOM.useFormState; - + useActionState = React.useActionState; PropTypes = require('prop-types'); const InternalTestUtils = require('internal-test-utils'); @@ -6338,6 +6339,123 @@ describe('ReactDOMFizzServer', () => { expect(childRef.current).toBe(child); }); + // @gate enableFormActions + // @gate enableAsyncActions + 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. + + async function action(state) { + return state; + } + + const childRef = React.createRef(null); + function Form() { + const [state] = useActionState(action, 0); + const text = `Child: ${state}`; + return ( +
+ {text} +
+ ); + } + + function App() { + return ( +
+
+
+
+ Sibling +
+ ); + } + + await act(() => { + const {pipe} = renderToPipeableStream(); + pipe(writable); + }); + expect(getVisibleChildren(container)).toEqual( +
+
+
Child: 0
+
+ Sibling +
, + ); + const child = document.getElementById('child'); + + // Confirm that it hydrates correctly + await clientAct(() => { + ReactDOMClient.hydrateRoot(container, ); + }); + expect(childRef.current).toBe(child); + }); + + // @gate enableFormActions + // @gate enableAsyncActions + it("useActionState hydrates without a mismatch if there's a render phase update", async () => { + async function action(state) { + return state; + } + + const childRef = React.createRef(null); + function Form() { + const [localState, setLocalState] = React.useState(0); + if (localState < 3) { + setLocalState(localState + 1); + } + + // 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 useActionState instance. + const [formState] = useActionState(action, 0); + const text = `${readText('Child')}:${formState}:${localState}`; + return ( +
+ {text} +
+ ); + } + + function App() { + return ( +
+ + + + Sibling +
+ ); + } + + await act(() => { + const {pipe} = renderToPipeableStream(); + pipe(writable); + }); + expect(getVisibleChildren(container)).toEqual( +
+ Loading...Sibling +
, + ); + + await act(() => resolveText('Child')); + expect(getVisibleChildren(container)).toEqual( +
+
Child:0:3
+ Sibling +
, + ); + const child = document.getElementById('child'); + + // Confirm that it hydrates correctly + await clientAct(() => { + ReactDOMClient.hydrateRoot(container, ); + }); + expect(childRef.current).toBe(child); + }); + describe('useEffectEvent', () => { // @gate enableUseEffectEventHook it('can server render a component with useEffectEvent', async () => { 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..1f05c53f301f9 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..cddd43b8e52a8 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMForm-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMForm-test.js @@ -32,6 +32,7 @@ let ReactServerDOMServer; let ReactServerDOMClient; let ReactDOMClient; let useFormState; +let useActionState; let act; describe('ReactFlightDOMForm', () => { @@ -56,6 +57,7 @@ describe('ReactFlightDOMForm', () => { ReactDOMClient = require('react-dom/client'); act = React.act; useFormState = require('react-dom').useFormState; + useActionState = require('react').useActionState; container = document.createElement('div'); document.body.appendChild(container); }); @@ -344,6 +346,557 @@ describe('ReactFlightDOMForm', () => { expect(foo).toBe('barobject'); }); + // @gate enableFormActions + // @gate enableAsyncActions + it("useActionState's dispatch binds the initial state to the provided action", async () => { + const serverAction = serverExports( + async function action(prevState, formData) { + return { + count: + prevState.count + parseInt(formData.get('incrementAmount'), 10), + }; + }, + ); + + const initialState = {count: 1}; + function Client({action}) { + const [state, dispatch, isPending] = useActionState(action, initialState); + return ( + + {isPending && 'Pending...'} + Count: {state.count} + + + ); + } + const ClientRef = await clientExports(Client); + + const rscStream = ReactServerDOMServer.renderToReadableStream( + , + webpackMap, + ); + const response = ReactServerDOMClient.createFromReadableStream(rscStream, { + ssrManifest: { + moduleMap: null, + moduleLoading: null, + }, + }); + const ssrStream = await ReactDOMServer.renderToReadableStream(response); + await readIntoContainer(ssrStream); + + const form = container.getElementsByTagName('form')[0]; + 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}); + }); + + // @gate enableFormActions + // @gate enableAsyncActions + it('useActionState can reuse state during MPA form submission', async () => { + const serverAction = serverExports( + async function action(prevState, formData) { + return prevState + 1; + }, + ); + + function Form({action}) { + const [count, dispatch, isPending] = useActionState(action, 1); + return ( +
+ {isPending ? 'Pending...' : ''} + {count} +
+ ); + } + + function Client({action}) { + return ( +
+
+ + +
+ ); + } + + const ClientRef = await clientExports(Client); + + const rscStream = ReactServerDOMServer.renderToReadableStream( + , + webpackMap, + ); + const response = ReactServerDOMClient.createFromReadableStream(rscStream, { + ssrManifest: { + moduleMap: null, + moduleLoading: null, + }, + }); + const ssrStream = await ReactDOMServer.renderToReadableStream(response); + await readIntoContainer(ssrStream); + + expect(container.textContent).toBe('111'); + + // There are three identical forms. We're going to submit the second one. + const form = container.getElementsByTagName('form')[1]; + const {formState} = await submit(form); + + // Simulate an MPA form submission by resetting the container and + // rendering again. + container.innerHTML = ''; + + const postbackRscStream = ReactServerDOMServer.renderToReadableStream( + , + webpackMap, + ); + const postbackResponse = ReactServerDOMClient.createFromReadableStream( + postbackRscStream, + { + ssrManifest: { + moduleMap: null, + moduleLoading: null, + }, + }, + ); + const postbackSsrStream = await ReactDOMServer.renderToReadableStream( + postbackResponse, + {formState: formState}, + ); + await readIntoContainer(postbackSsrStream); + + // Only the second form's state should have been updated. + expect(container.textContent).toBe('121'); + + // Test that it hydrates correctly + if (__DEV__) { + // TODO: Can't use our internal act() util that works in production + // because it works by overriding the timer APIs, which this test module + // also does. Remove dev condition once FlightServer.act() is available. + await act(() => { + ReactDOMClient.hydrateRoot(container, postbackResponse, { + formState: formState, + }); + }); + expect(container.textContent).toBe('121'); + } + }); + + // @gate enableFormActions + // @gate enableAsyncActions + it( + 'useActionState preserves state if arity is the same, but different ' + + 'arguments are bound (i.e. inline closure)', + async () => { + const serverAction = serverExports( + async function action(stepSize, prevState, formData) { + return prevState + stepSize; + }, + ); + + function Form({action}) { + const [count, dispatch, isPending] = useActionState(action, 1); + return ( + + {isPending ? 'Pending...' : ''} + {count} + + ); + } + + function Client({action}) { + return ( +
+
+ + +
+ ); + } + + const ClientRef = await clientExports(Client); + + const rscStream = ReactServerDOMServer.renderToReadableStream( + // Note: `.bind` is the same as an inline closure with 'use server' + , + webpackMap, + ); + const response = ReactServerDOMClient.createFromReadableStream( + rscStream, + { + ssrManifest: { + moduleMap: null, + moduleLoading: null, + }, + }, + ); + const ssrStream = await ReactDOMServer.renderToReadableStream(response); + await readIntoContainer(ssrStream); + + expect(container.textContent).toBe('111'); + + // There are three identical forms. We're going to submit the second one. + const form = container.getElementsByTagName('form')[1]; + const {formState} = await submit(form); + + // Simulate an MPA form submission by resetting the container and + // rendering again. + container.innerHTML = ''; + + // On the next page, the same server action is rendered again, but with + // a different bound stepSize argument. We should treat this as the same + // action signature. + const postbackRscStream = ReactServerDOMServer.renderToReadableStream( + // Note: `.bind` is the same as an inline closure with 'use server' + , + webpackMap, + ); + const postbackResponse = ReactServerDOMClient.createFromReadableStream( + postbackRscStream, + { + ssrManifest: { + moduleMap: null, + moduleLoading: null, + }, + }, + ); + const postbackSsrStream = await ReactDOMServer.renderToReadableStream( + postbackResponse, + {formState: formState}, + ); + await readIntoContainer(postbackSsrStream); + + // The state should have been preserved because the action signatures are + // the same. (Note that the amount increased by 1, because that was the + // value of stepSize at the time the form was submitted) + expect(container.textContent).toBe('121'); + + // Now submit the form again. This time, the state should increase by 5 + // because the stepSize argument has changed. + const form2 = container.getElementsByTagName('form')[1]; + const {formState: formState2} = await submit(form2); + + container.innerHTML = ''; + + const postbackRscStream2 = ReactServerDOMServer.renderToReadableStream( + // Note: `.bind` is the same as an inline closure with 'use server' + , + webpackMap, + ); + const postbackResponse2 = ReactServerDOMClient.createFromReadableStream( + postbackRscStream2, + { + ssrManifest: { + moduleMap: null, + moduleLoading: null, + }, + }, + ); + const postbackSsrStream2 = await ReactDOMServer.renderToReadableStream( + postbackResponse2, + {formState: formState2}, + ); + await readIntoContainer(postbackSsrStream2); + + expect(container.textContent).toBe('171'); + }, + ); + + // @gate enableFormActions + // @gate enableAsyncActions + 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( + async function action(prevState, formData) { + return prevState + 1; + }, + ); + + const increaseBy5 = serverExports( + async function action(prevState, formData) { + return prevState + 5; + }, + ); + + function Form({action}) { + const [count, dispatch, isPending] = useActionState(action, 1); + return ( + + {isPending ? 'Pending...' : ''} + {count} + + ); + } + + function Client({action}) { + return ( +
+
+ + +
+ ); + } + + const ClientRef = await clientExports(Client); + + const rscStream = ReactServerDOMServer.renderToReadableStream( + , + webpackMap, + ); + const response = ReactServerDOMClient.createFromReadableStream(rscStream, { + ssrManifest: { + moduleMap: null, + moduleLoading: null, + }, + }); + const ssrStream = await ReactDOMServer.renderToReadableStream(response); + await readIntoContainer(ssrStream); + + expect(container.textContent).toBe('111'); + + // There are three identical forms. We're going to submit the second one. + const form = container.getElementsByTagName('form')[1]; + const {formState} = await submit(form); + + // Simulate an MPA form submission by resetting the container and + // rendering again. + container.innerHTML = ''; + + // On the next page, a different server action is rendered. It should not + // reuse the state from the previous page. + const postbackRscStream = ReactServerDOMServer.renderToReadableStream( + , + webpackMap, + ); + const postbackResponse = ReactServerDOMClient.createFromReadableStream( + postbackRscStream, + { + ssrManifest: { + moduleMap: null, + moduleLoading: null, + }, + }, + ); + const postbackSsrStream = await ReactDOMServer.renderToReadableStream( + postbackResponse, + {formState: formState}, + ); + await readIntoContainer(postbackSsrStream); + + // The state should not have been preserved because the action signatures + // are not the same. + expect(container.textContent).toBe('111'); + }); + + // @gate enableFormActions + // @gate enableAsyncActions + it('when permalink is provided, useActionState compares that instead of the keypath', async () => { + const serverAction = serverExports( + async function action(prevState, formData) { + return prevState + 1; + }, + ); + + function Form({action, permalink}) { + const [count, dispatch, isPending] = useActionState(action, 1, permalink); + return ( + + {isPending ? 'Pending...' : ''} + {count} + + ); + } + + function Page1({action, permalink}) { + return
; + } + + function Page2({action, permalink}) { + return ; + } + + const Page1Ref = await clientExports(Page1); + const Page2Ref = await clientExports(Page2); + + const rscStream = ReactServerDOMServer.renderToReadableStream( + , + webpackMap, + ); + const response = ReactServerDOMClient.createFromReadableStream(rscStream, { + ssrManifest: { + moduleMap: null, + moduleLoading: null, + }, + }); + const ssrStream = await ReactDOMServer.renderToReadableStream(response); + await readIntoContainer(ssrStream); + + expect(container.textContent).toBe('1'); + + // Submit the form + const form = container.getElementsByTagName('form')[0]; + const {formState} = await submit(form); + + // Simulate an MPA form submission by resetting the container and + // rendering again. + container.innerHTML = ''; + + // On the next page, the same server action is rendered again, but in + // a different component tree. However, because a permalink option was + // passed, the state should be preserved. + const postbackRscStream = ReactServerDOMServer.renderToReadableStream( + , + webpackMap, + ); + const postbackResponse = ReactServerDOMClient.createFromReadableStream( + postbackRscStream, + { + ssrManifest: { + moduleMap: null, + moduleLoading: null, + }, + }, + ); + const postbackSsrStream = await ReactDOMServer.renderToReadableStream( + postbackResponse, + {formState: formState}, + ); + await readIntoContainer(postbackSsrStream); + + expect(container.textContent).toBe('2'); + + // Now submit the form again. This time, the permalink will be different, so + // the state is not preserved. + const form2 = container.getElementsByTagName('form')[0]; + const {formState: formState2} = await submit(form2); + + container.innerHTML = ''; + + const postbackRscStream2 = ReactServerDOMServer.renderToReadableStream( + , + webpackMap, + ); + const postbackResponse2 = ReactServerDOMClient.createFromReadableStream( + postbackRscStream2, + { + ssrManifest: { + moduleMap: null, + moduleLoading: null, + }, + }, + ); + const postbackSsrStream2 = await ReactDOMServer.renderToReadableStream( + postbackResponse2, + {formState: formState2}, + ); + await readIntoContainer(postbackSsrStream2); + + // The state was reset because the permalink didn't match + expect(container.textContent).toBe('1'); + }); + + // @gate enableFormActions + // @gate enableAsyncActions + 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] = useActionState( + action, + initialState, + '/permalink', + ); + return ( + + {isPending ? 'Pending...' : ''} + Count: {state.count} + + ); + } + const ClientRef = await clientExports(Client); + + const rscStream = ReactServerDOMServer.renderToReadableStream( + , + webpackMap, + ); + const response = ReactServerDOMClient.createFromReadableStream(rscStream, { + ssrManifest: { + moduleMap: null, + moduleLoading: null, + }, + }); + const ssrStream = await ReactDOMServer.renderToReadableStream(response); + await readIntoContainer(ssrStream); + + const form = container.getElementsByTagName('form')[0]; + 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'); + }); + + // @gate enableFormActions + // @gate enableAsyncActions + it('useActionState `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, isPending] = useActionState( + action, + initialState, + permalink, + ); + return ( +
+ {isPending ? 'Pending...' : ''} + Count: {state.count} +
+ ); + } + const ClientRef = await clientExports(Client); + + const rscStream = ReactServerDOMServer.renderToReadableStream( + , + webpackMap, + ); + const response = ReactServerDOMClient.createFromReadableStream(rscStream, { + ssrManifest: { + moduleMap: null, + moduleLoading: null, + }, + }); + const ssrStream = await ReactDOMServer.renderToReadableStream(response); + await readIntoContainer(ssrStream); + + const form = container.getElementsByTagName('form')[0]; + 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'); + }); + // @gate enableFormActions // @gate enableAsyncActions it("useFormState's dispatch binds the initial state to the provided action", async () => { 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, }; diff --git a/packages/react/src/__tests__/ReactUseActionState-test.js b/packages/react/src/__tests__/ReactUseActionState-test.js new file mode 100644 index 0000000000000..b342a30b5dbae --- /dev/null +++ b/packages/react/src/__tests__/ReactUseActionState-test.js @@ -0,0 +1,467 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @emails react-core + */ + +'use strict'; + +global.IS_REACT_ACT_ENVIRONMENT = true; + +// Our current version of JSDOM doesn't implement the event dispatching +// so we polyfill it. +const NativeFormData = global.FormData; +const FormDataPolyfill = function FormData(form) { + const formData = new NativeFormData(form); + const formDataEvent = new Event('formdata', { + bubbles: true, + cancelable: false, + }); + formDataEvent.formData = formData; + form.dispatchEvent(formDataEvent); + return formData; +}; +NativeFormData.prototype.constructor = FormDataPolyfill; +global.FormData = FormDataPolyfill; + +describe('useActionState', () => { + let act; + let container; + let React; + let ReactDOMClient; + let Scheduler; + let assertLog; + let waitForThrow; + let textCache; + let useActionState; + + beforeEach(() => { + jest.resetModules(); + React = require('react'); + ReactDOMClient = require('react-dom/client'); + Scheduler = require('scheduler'); + act = require('internal-test-utils').act; + assertLog = require('internal-test-utils').assertLog; + waitForThrow = require('internal-test-utils').waitForThrow; + useActionState = React.useActionState; + container = document.createElement('div'); + document.body.appendChild(container); + + 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(text)); + } + } + + function getText(text) { + const record = textCache.get(text); + if (record === undefined) { + const thenable = { + pings: [], + then(resolve) { + if (newRecord.status === 'pending') { + thenable.pings.push(resolve); + } else { + Promise.resolve().then(() => resolve(newRecord.value)); + } + }, + }; + const newRecord = { + status: 'pending', + value: thenable, + }; + textCache.set(text, newRecord); + return thenable; + } else { + switch (record.status) { + case 'pending': + return record.value; + case 'rejected': + return Promise.reject(record.value); + case 'resolved': + return Promise.resolve(record.value); + } + } + } + + function Text({text}) { + Scheduler.log(text); + return text; + } + + afterEach(() => { + document.body.removeChild(container); + }); + + // @gate enableFormActions + // @gate enableAsyncActions + test('updates state asynchronously and queues multiple actions', async () => { + let actionCounter = 0; + async function action(state, type) { + actionCounter++; + + Scheduler.log(`Async action started [${actionCounter}]`); + await getText(`Wait [${actionCounter}]`); + + switch (type) { + case 'increment': + return state + 1; + case 'decrement': + return state - 1; + default: + return state; + } + } + + let dispatch; + function App() { + const [state, _dispatch, isPending] = useActionState(action, 0); + dispatch = _dispatch; + const pending = isPending ? 'Pending ' : ''; + return ; + } + + const root = ReactDOMClient.createRoot(container); + await act(() => root.render()); + assertLog(['0']); + expect(container.textContent).toBe('0'); + + await act(() => dispatch('increment')); + 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. + await act(() => dispatch('increment')); + await act(() => dispatch('decrement')); + await act(() => dispatch('increment')); + assertLog([]); + + // Each action starts as soon as the previous one finishes. + // NOTE: React does not render in between these actions because they all + // update the same queue, which means they get entangled together. This is + // intentional behavior. + await act(() => resolveText('Wait [1]')); + assertLog(['Async action started [2]']); + await act(() => resolveText('Wait [2]')); + assertLog(['Async action started [3]']); + await act(() => resolveText('Wait [3]')); + assertLog(['Async action started [4]']); + await act(() => resolveText('Wait [4]')); + + // Finally the last action finishes and we can render the result. + assertLog(['2']); + expect(container.textContent).toBe('2'); + }); + + // @gate enableFormActions + // @gate enableAsyncActions + test('supports inline actions', async () => { + let increment; + function App({stepSize}) { + const [state, dispatch, isPending] = useActionState(async prevState => { + return prevState + stepSize; + }, 0); + increment = dispatch; + const pending = isPending ? 'Pending ' : ''; + return ; + } + + // Initial render + const root = ReactDOMClient.createRoot(container); + await act(() => root.render()); + assertLog(['0']); + + // Perform an action. This will increase the state by 1, as defined by the + // stepSize prop. + await act(() => increment()); + assertLog(['Pending 0', '1']); + + // Now increase the stepSize prop to 10. Subsequent steps will increase + // by this amount. + await act(() => root.render()); + assertLog(['1']); + + // Increment again. The state should increase by 10. + await act(() => increment()); + assertLog(['Pending 1', '11']); + }); + + // @gate enableFormActions + // @gate enableAsyncActions + test('dispatch throws if called during render', async () => { + function App() { + const [state, dispatch, isPending] = useActionState(async () => {}, 0); + dispatch(); + const pending = isPending ? 'Pending ' : ''; + return ; + } + + const root = ReactDOMClient.createRoot(container); + await act(async () => { + root.render(); + await waitForThrow('Cannot update form state while rendering.'); + }); + }); + + // @gate enableFormActions + // @gate enableAsyncActions + test('queues multiple actions and runs them in order', async () => { + let action; + function App() { + const [state, dispatch, isPending] = useActionState( + async (s, a) => await getText(a), + 'A', + ); + action = dispatch; + const pending = isPending ? 'Pending ' : ''; + return ; + } + + const root = ReactDOMClient.createRoot(container); + await act(() => root.render()); + assertLog(['A']); + + await act(() => action('B')); + assertLog(['Pending A']); + expect(container.textContent).toBe('Pending A'); + + await act(() => action('C')); + assertLog([]); // Still pending. + expect(container.textContent).toBe('Pending A'); + + await act(() => action('D')); + assertLog([]); // Still pending. + expect(container.textContent).toBe('Pending A'); + + await act(() => resolveText('B')); + await act(() => resolveText('C')); + await act(() => resolveText('D')); + + assertLog(['D']); + expect(container.textContent).toBe('D'); + }); + + // @gate enableFormActions + // @gate enableAsyncActions + test('works if action is sync', async () => { + let increment; + function App({stepSize}) { + const [state, dispatch, isPending] = useActionState(prevState => { + return prevState + stepSize; + }, 0); + increment = dispatch; + const pending = isPending ? 'Pending ' : ''; + return ; + } + + // Initial render + const root = ReactDOMClient.createRoot(container); + await act(() => root.render()); + assertLog(['0']); + + // Perform an action. This will increase the state by 1, as defined by the + // stepSize prop. + await act(() => increment()); + assertLog(['Pending 0', '1']); + expect(container.textContent).toBe('1'); + + // Now increase the stepSize prop to 10. Subsequent steps will increase + // by this amount. + await act(() => root.render()); + assertLog(['1']); + expect(container.textContent).toBe('1'); + + // Increment again. The state should increase by 10. + await act(() => increment()); + assertLog(['Pending 1', '11']); + expect(container.textContent).toBe('11'); + }); + + // @gate enableFormActions + // @gate enableAsyncActions + test('can mix sync and async actions', async () => { + let action; + function App() { + const [state, dispatch, isPending] = useActionState((s, a) => a, 'A'); + action = dispatch; + const pending = isPending ? 'Pending ' : ''; + return ; + } + + const root = ReactDOMClient.createRoot(container); + await act(() => root.render()); + assertLog(['A']); + + await act(() => action(getText('B'))); + assertLog(['Pending A']); + expect(container.textContent).toBe('Pending A'); + + await act(() => action('C')); + assertLog([]); // Still pending. + expect(container.textContent).toBe('Pending A'); + + await act(() => action(getText('D'))); + assertLog([]); // Still pending. + expect(container.textContent).toBe('Pending A'); + + await act(() => action('E')); + assertLog([]); // Still pending. + expect(container.textContent).toBe('Pending A'); + + await act(() => resolveText('B')); + assertLog([]); // Still pending. + expect(container.textContent).toBe('Pending A'); + + await act(() => resolveText('D')); + assertLog(['E']); + expect(container.textContent).toBe('E'); + }); + + // @gate enableFormActions + // @gate enableAsyncActions + test('error handling (sync action)', async () => { + let resetErrorBoundary; + class ErrorBoundary extends React.Component { + state = {error: null}; + static getDerivedStateFromError(error) { + return {error}; + } + render() { + resetErrorBoundary = () => this.setState({error: null}); + if (this.state.error !== null) { + return ; + } + return this.props.children; + } + } + + let action; + function App() { + const [state, dispatch, isPending] = useActionState((s, a) => { + if (a.endsWith('!')) { + throw new Error(a); + } + return a; + }, 'A'); + action = dispatch; + const pending = isPending ? 'Pending ' : ''; + return ; + } + + const root = ReactDOMClient.createRoot(container); + await act(() => + root.render( + + + , + ), + ); + assertLog(['A']); + + await act(() => action('Oops!')); + assertLog([ + 'Pending A', // Error has not thrown yet. + 'Caught an error: Oops!', + 'Caught an error: Oops!', + ]); + expect(container.textContent).toBe('Caught an error: Oops!'); + + // Reset the error boundary + await act(() => resetErrorBoundary()); + assertLog(['A']); + + // Trigger an error again, but this time, perform another action that + // overrides the first one and fixes the error + await act(() => { + action('Oops!'); + action('B'); + }); + assertLog(['Pending A', 'B']); + expect(container.textContent).toBe('B'); + }); + + // @gate enableFormActions + // @gate enableAsyncActions + test('error handling (async action)', async () => { + let resetErrorBoundary; + class ErrorBoundary extends React.Component { + state = {error: null}; + static getDerivedStateFromError(error) { + return {error}; + } + render() { + resetErrorBoundary = () => this.setState({error: null}); + if (this.state.error !== null) { + return ; + } + return this.props.children; + } + } + + let action; + function App() { + const [state, dispatch, isPending] = useActionState(async (s, a) => { + const text = await getText(a); + if (text.endsWith('!')) { + throw new Error(text); + } + return text; + }, 'A'); + action = dispatch; + const pending = isPending ? 'Pending ' : ''; + return ; + } + + const root = ReactDOMClient.createRoot(container); + await act(() => + root.render( + + + , + ), + ); + assertLog(['A']); + + await act(() => action('Oops!')); + assertLog(['Pending A']); // Error has not thrown yet. + expect(container.textContent).toBe('Pending A'); + + await act(() => resolveText('Oops!')); + assertLog(['Caught an error: Oops!', 'Caught an error: Oops!']); + expect(container.textContent).toBe('Caught an error: Oops!'); + + // Reset the error boundary + await act(() => resetErrorBoundary()); + assertLog(['A']); + + // Trigger an error again, but this time, perform another action that + // overrides the first one and fixes the error + await act(() => { + action('Oops!'); + action('B'); + }); + assertLog(['Pending A']); + expect(container.textContent).toBe('Pending A'); + + await act(() => resolveText('B')); + assertLog(['B']); + expect(container.textContent).toBe('B'); + }); +});