@samitouri / QOS-React-1 / commits / b4cdd3e892

Scaffolding for useFormState (#27270)

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.

Andrew Clark committed Aug 23, 2023 at 10:58 UTC b4cdd3e8922713f8c9817b004a0dc51be47bc5df
15 files changed +205 -4
.eslintrc.js
+1
@@ -456,6 +456,7 @@ module.exports = {
456 $ReadOnlyArray: 'readonly',
457 $ArrayBufferView: 'readonly',
458 $Shape: 'readonly',
459 + ReturnType: 'readonly',
460 AnimationFrameID: 'readonly',
461 // For Flow type annotation. Only `BigInt` is valid at runtime.
462 bigint: 'readonly',
packages/react-dom-bindings/src/shared/ReactDOMFormActions.js
+14
@@ -74,3 +74,17 @@ export function useFormStatus(): FormStatus {
74 return dispatcher.useHostTransitionStatus();
75 }
76 }
77 +
78 +export function useFormState<S, P>(
79 + action: (S, P) => S,
80 + initialState: S,
81 + url?: string,
82 +): [S, (P) => void] {
83 + if (!(enableFormActions && enableAsyncActions)) {
84 + throw new Error('Not implemented.');
85 + } else {
86 + const dispatcher = resolveDispatcher();
87 + // $FlowFixMe[not-a-function] This is unstable, thus optional
88 + return dispatcher.useFormState(action, initialState, url);
89 + }
90 +}
packages/react-dom/index.classic.fb.js
+1
@@ -32,6 +32,7 @@ export {
32 unstable_renderSubtreeIntoContainer,
33 unstable_runWithPriority, // DO NOT USE: Temporarily exposed to migrate off of Scheduler.runWithPriority.
34 useFormStatus as experimental_useFormStatus,
35 + useFormState as experimental_useFormState,
36 prefetchDNS,
37 preconnect,
38 preload,
packages/react-dom/index.experimental.js
+1
@@ -21,6 +21,7 @@ export {
21 unstable_renderSubtreeIntoContainer,
22 unstable_runWithPriority, // DO NOT USE: Temporarily exposed to migrate off of Scheduler.runWithPriority.
23 useFormStatus as experimental_useFormStatus,
24 + useFormState as experimental_useFormState,
25 prefetchDNS,
26 preconnect,
27 preload,
packages/react-dom/index.js
+1
@@ -24,6 +24,7 @@ export {
24 unstable_renderSubtreeIntoContainer,
25 unstable_runWithPriority, // DO NOT USE: Temporarily exposed to migrate off of Scheduler.runWithPriority.
26 useFormStatus as experimental_useFormStatus,
27 + useFormState as experimental_useFormState,
28 prefetchDNS,
29 preconnect,
30 preload,
packages/react-dom/index.modern.fb.js
+1
@@ -17,6 +17,7 @@ export {
17 unstable_createEventHandle,
18 unstable_runWithPriority, // DO NOT USE: Temporarily exposed to migrate off of Scheduler.runWithPriority.
19 useFormStatus as experimental_useFormStatus,
20 + useFormState as experimental_useFormState,
21 prefetchDNS,
22 preconnect,
23 preload,
packages/react-dom/server-rendering-stub.js
+1
@@ -23,5 +23,6 @@ export {
23 preload,
24 preinit,
25 experimental_useFormStatus,
26 + experimental_useFormState,
27 unstable_batchedUpdates,
28 } from './src/server/ReactDOMServerRenderingStub';
packages/react-dom/src/__tests__/ReactDOMFizzForm-test.js
+24
@@ -23,6 +23,7 @@ let ReactDOMServer;
23 let ReactDOMClient;
24 let useFormStatus;
25 let useOptimistic;
26 +let useFormState;
27
28 describe('ReactDOMFizzForm', () => {
29 beforeEach(() => {
@@ -31,6 +32,7 @@ describe('ReactDOMFizzForm', () => {
32 ReactDOMServer = require('react-dom/server.browser');
33 ReactDOMClient = require('react-dom/client');
34 useFormStatus = require('react-dom').experimental_useFormStatus;
35 + useFormState = require('react-dom').experimental_useFormState;
36 useOptimistic = require('react').experimental_useOptimistic;
37 act = require('internal-test-utils').act;
38 container = document.createElement('div');
@@ -470,6 +472,28 @@ describe('ReactDOMFizzForm', () => {
472 expect(container.textContent).toBe('hi');
473 });
474
475 + // @gate enableFormActions
476 + // @gate enableAsyncActions
477 + it('useFormState returns initial state', async () => {
478 + async function action(state) {
479 + return state;
480 + }
481 +
482 + function App() {
483 + const [state] = useFormState(action, 0);
484 + return state;
485 + }
486 +
487 + const stream = await ReactDOMServer.renderToReadableStream(<App />);
488 + await readIntoContainer(stream);
489 + expect(container.textContent).toBe('0');
490 +
491 + await act(async () => {
492 + ReactDOMClient.hydrateRoot(container, <App />);
493 + });
494 + expect(container.textContent).toBe('0');
495 + });
496 +
497 // @gate enableFormActions
498 it('can provide a custom action on the server for actions', async () => {
499 const ref = React.createRef();
packages/react-dom/src/__tests__/ReactDOMForm-test.js
+22
@@ -40,6 +40,7 @@ describe('ReactDOMForm', () => {
40 let startTransition;
41 let textCache;
42 let useFormStatus;
43 + let useFormState;
44
45 beforeEach(() => {
46 jest.resetModules();
@@ -53,6 +54,7 @@ describe('ReactDOMForm', () => {
54 Suspense = React.Suspense;
55 startTransition = React.startTransition;
56 useFormStatus = ReactDOM.experimental_useFormStatus;
57 + useFormState = ReactDOM.experimental_useFormState;
58 container = document.createElement('div');
59 document.body.appendChild(container);
60
@@ -969,4 +971,24 @@ describe('ReactDOMForm', () => {
971 'A React form was unexpectedly submitted. If you called form.submit()',
972 );
973 });
974 +
975 + // @gate enableFormActions
976 + // @gate enableAsyncActions
977 + test('useFormState exists', async () => {
978 + // TODO: Not yet implemented. This just tests that the API is wired up.
979 +
980 + async function action(state) {
981 + return state;
982 + }
983 +
984 + function App() {
985 + const [state] = useFormState(action, 0);
986 + return <Text text={state} />;
987 + }
988 +
989 + const root = ReactDOMClient.createRoot(container);
990 + await act(() => root.render(<App />));
991 + assertLog([0]);
992 + expect(container.textContent).toBe('0');
993 + });
994 });
packages/react-dom/src/client/ReactDOM.js
+4 -1
@@ -63,7 +63,10 @@ export {
63 preinit,
64 preinitModule,
65 } from '../shared/ReactDOMFloat';
66 -export {useFormStatus} from 'react-dom-bindings/src/shared/ReactDOMFormActions';
66 +export {
67 + useFormStatus,
68 + useFormState,
69 +} from 'react-dom-bindings/src/shared/ReactDOMFormActions';
70
71 if (__DEV__) {
72 if (
packages/react-dom/src/server/ReactDOMServerRenderingStub.js
+4 -1
@@ -13,7 +13,10 @@ export {
13 preconnect,
14 prefetchDNS,
15 } from '../shared/ReactDOMFloat';
16 -export {useFormStatus as experimental_useFormStatus} from 'react-dom-bindings/src/shared/ReactDOMFormActions';
16 +export {
17 + useFormStatus as experimental_useFormStatus,
18 + useFormState as experimental_useFormState,
19 +} from 'react-dom-bindings/src/shared/ReactDOMFormActions';
20
21 export function createPortal() {
22 throw new Error(
packages/react-reconciler/src/ReactFiberHooks.js
+108
@@ -1835,6 +1835,37 @@ function rerenderOptimistic<S, A>(
1835 return [passthrough, dispatch];
1836 }
1837
1838 +function TODO_formStateDispatch() {
1839 + throw new Error('Not implemented.');
1840 +}
1841 +
1842 +function mountFormState<S, P>(
1843 + action: (S, P) => S,
1844 + initialState: S,
1845 + url?: string,
1846 +): [S, (P) => void] {
1847 + // TODO: Not yet implemented
1848 + return [initialState, TODO_formStateDispatch];
1849 +}
1850 +
1851 +function updateFormState<S, P>(
1852 + action: (S, P) => S,
1853 + initialState: S,
1854 + url?: string,
1855 +): [S, (P) => void] {
1856 + // TODO: Not yet implemented
1857 + return [initialState, TODO_formStateDispatch];
1858 +}
1859 +
1860 +function rerenderFormState<S, P>(
1861 + action: (S, P) => S,
1862 + initialState: S,
1863 + url?: string,
1864 +): [S, (P) => void] {
1865 + // TODO: Not yet implemented
1866 + return [initialState, TODO_formStateDispatch];
1867 +}
1868 +
1869 function pushEffect(
1870 tag: HookFlags,
1871 create: () => (() => void) | void,
@@ -2981,6 +3012,7 @@ if (enableUseEffectEventHook) {
3012 if (enableFormActions && enableAsyncActions) {
3013 (ContextOnlyDispatcher: Dispatcher).useHostTransitionStatus =
3014 throwInvalidHookError;
3015 + (ContextOnlyDispatcher: Dispatcher).useFormState = throwInvalidHookError;
3016 }
3017 if (enableAsyncActions) {
3018 (ContextOnlyDispatcher: Dispatcher).useOptimistic = throwInvalidHookError;
@@ -3018,6 +3050,7 @@ if (enableUseEffectEventHook) {
3050 if (enableFormActions && enableAsyncActions) {
3051 (HooksDispatcherOnMount: Dispatcher).useHostTransitionStatus =
3052 useHostTransitionStatus;
3053 + (HooksDispatcherOnMount: Dispatcher).useFormState = mountFormState;
3054 }
3055 if (enableAsyncActions) {
3056 (HooksDispatcherOnMount: Dispatcher).useOptimistic = mountOptimistic;
@@ -3055,6 +3088,7 @@ if (enableUseEffectEventHook) {
3088 if (enableFormActions && enableAsyncActions) {
3089 (HooksDispatcherOnUpdate: Dispatcher).useHostTransitionStatus =
3090 useHostTransitionStatus;
3091 + (HooksDispatcherOnUpdate: Dispatcher).useFormState = updateFormState;
3092 }
3093 if (enableAsyncActions) {
3094 (HooksDispatcherOnUpdate: Dispatcher).useOptimistic = updateOptimistic;
@@ -3092,6 +3126,7 @@ if (enableUseEffectEventHook) {
3126 if (enableFormActions && enableAsyncActions) {
3127 (HooksDispatcherOnRerender: Dispatcher).useHostTransitionStatus =
3128 useHostTransitionStatus;
3129 + (HooksDispatcherOnRerender: Dispatcher).useFormState = rerenderFormState;
3130 }
3131 if (enableAsyncActions) {
3132 (HooksDispatcherOnRerender: Dispatcher).useOptimistic = rerenderOptimistic;
@@ -3276,6 +3311,16 @@ if (__DEV__) {
3311 if (enableFormActions && enableAsyncActions) {
3312 (HooksDispatcherOnMountInDEV: Dispatcher).useHostTransitionStatus =
3313 useHostTransitionStatus;
3314 + (HooksDispatcherOnMountInDEV: Dispatcher).useFormState =
3315 + function useFormState<S, P>(
3316 + action: (S, P) => S,
3317 + initialState: S,
3318 + url?: string,
3319 + ): [S, (P) => void] {
3320 + currentHookNameInDev = 'useFormState';
3321 + mountHookTypesDev();
3322 + return mountFormState(action, initialState, url);
3323 + };
3324 }
3325 if (enableAsyncActions) {
3326 (HooksDispatcherOnMountInDEV: Dispatcher).useOptimistic =
@@ -3436,6 +3481,16 @@ if (__DEV__) {
3481 if (enableFormActions && enableAsyncActions) {
3482 (HooksDispatcherOnMountWithHookTypesInDEV: Dispatcher).useHostTransitionStatus =
3483 useHostTransitionStatus;
3484 + (HooksDispatcherOnMountWithHookTypesInDEV: Dispatcher).useFormState =
3485 + function useFormState<S, P>(
3486 + action: (S, P) => S,
3487 + initialState: S,
3488 + url?: string,
3489 + ): [S, (P) => void] {
3490 + currentHookNameInDev = 'useFormState';
3491 + updateHookTypesDev();
3492 + return mountFormState(action, initialState, url);
3493 + };
3494 }
3495 if (enableAsyncActions) {
3496 (HooksDispatcherOnMountWithHookTypesInDEV: Dispatcher).useOptimistic =
@@ -3598,6 +3653,16 @@ if (__DEV__) {
3653 if (enableFormActions && enableAsyncActions) {
3654 (HooksDispatcherOnUpdateInDEV: Dispatcher).useHostTransitionStatus =
3655 useHostTransitionStatus;
3656 + (HooksDispatcherOnUpdateInDEV: Dispatcher).useFormState =
3657 + function useFormState<S, P>(
3658 + action: (S, P) => S,
3659 + initialState: S,
3660 + url?: string,
3661 + ): [S, (P) => void] {
3662 + currentHookNameInDev = 'useFormState';
3663 + updateHookTypesDev();
3664 + return updateFormState(action, initialState, url);
3665 + };
3666 }
3667 if (enableAsyncActions) {
3668 (HooksDispatcherOnUpdateInDEV: Dispatcher).useOptimistic =
@@ -3760,6 +3825,16 @@ if (__DEV__) {
3825 if (enableFormActions && enableAsyncActions) {
3826 (HooksDispatcherOnRerenderInDEV: Dispatcher).useHostTransitionStatus =
3827 useHostTransitionStatus;
3828 + (HooksDispatcherOnRerenderInDEV: Dispatcher).useFormState =
3829 + function useFormState<S, P>(
3830 + action: (S, P) => S,
3831 + initialState: S,
3832 + url?: string,
3833 + ): [S, (P) => void] {
3834 + currentHookNameInDev = 'useFormState';
3835 + updateHookTypesDev();
3836 + return rerenderFormState(action, initialState, url);
3837 + };
3838 }
3839 if (enableAsyncActions) {
3840 (HooksDispatcherOnRerenderInDEV: Dispatcher).useOptimistic =
@@ -3943,6 +4018,17 @@ if (__DEV__) {
4018 if (enableFormActions && enableAsyncActions) {
4019 (InvalidNestedHooksDispatcherOnMountInDEV: Dispatcher).useHostTransitionStatus =
4020 useHostTransitionStatus;
4021 + (InvalidNestedHooksDispatcherOnMountInDEV: Dispatcher).useFormState =
4022 + function useFormState<S, P>(
4023 + action: (S, P) => S,
4024 + initialState: S,
4025 + url?: string,
4026 + ): [S, (P) => void] {
4027 + currentHookNameInDev = 'useFormState';
4028 + warnInvalidHookAccess();
4029 + mountHookTypesDev();
4030 + return mountFormState(action, initialState, url);
4031 + };
4032 }
4033 if (enableAsyncActions) {
4034 (InvalidNestedHooksDispatcherOnMountInDEV: Dispatcher).useOptimistic =
@@ -4130,6 +4216,17 @@ if (__DEV__) {
4216 if (enableFormActions && enableAsyncActions) {
4217 (InvalidNestedHooksDispatcherOnUpdateInDEV: Dispatcher).useHostTransitionStatus =
4218 useHostTransitionStatus;
4219 + (InvalidNestedHooksDispatcherOnUpdateInDEV: Dispatcher).useFormState =
4220 + function useFormState<S, P>(
4221 + action: (S, P) => S,
4222 + initialState: S,
4223 + url?: string,
4224 + ): [S, (P) => void] {
4225 + currentHookNameInDev = 'useFormState';
4226 + warnInvalidHookAccess();
4227 + updateHookTypesDev();
4228 + return updateFormState(action, initialState, url);
4229 + };
4230 }
4231 if (enableAsyncActions) {
4232 (InvalidNestedHooksDispatcherOnUpdateInDEV: Dispatcher).useOptimistic =
@@ -4317,6 +4414,17 @@ if (__DEV__) {
4414 if (enableFormActions && enableAsyncActions) {
4415 (InvalidNestedHooksDispatcherOnRerenderInDEV: Dispatcher).useHostTransitionStatus =
4416 useHostTransitionStatus;
4417 + (InvalidNestedHooksDispatcherOnRerenderInDEV: Dispatcher).useFormState =
4418 + function useFormState<S, P>(
4419 + action: (S, P) => S,
4420 + initialState: S,
4421 + url?: string,
4422 + ): [S, (P) => void] {
4423 + currentHookNameInDev = 'useFormState';
4424 + warnInvalidHookAccess();
4425 + updateHookTypesDev();
4426 + return rerenderFormState(action, initialState, url);
4427 + };
4428 }
4429 if (enableAsyncActions) {
4430 (InvalidNestedHooksDispatcherOnRerenderInDEV: Dispatcher).useOptimistic =
packages/react-reconciler/src/ReactInternalTypes.js
+7 -1
@@ -53,7 +53,8 @@ export type HookType =
53 | 'useSyncExternalStore'
54 | 'useId'
55 | 'useCacheRefresh'
56 - | 'useOptimistic';
56 + | 'useOptimistic'
57 + | 'useFormState';
58
59 export type ContextDependency<T> = {
60 context: ReactContext<T>,
@@ -413,6 +414,11 @@ export type Dispatcher = {
414 passthrough: S,
415 reducer: ?(S, A) => S,
416 ) => [S, (A) => void],
417 + useFormState?: <S, P>(
418 + action: (S, P) => S,
419 + initialState: S,
420 + url?: string,
421 + ) => [S, (P) => void],
422 };
423
424 export type CacheDispatcher = {
packages/react-server/src/ReactFizzHooks.js
+14
@@ -542,6 +542,10 @@ function unsupportedSetOptimisticState() {
542 throw new Error('Cannot update optimistic state while rendering.');
543 }
544
545 +function unsupportedDispatchFormState() {
546 + throw new Error('Cannot update form state while rendering.');
547 +}
548 +
549 function useOptimistic<S, A>(
550 passthrough: S,
551 reducer: ?(S, A) => S,
@@ -550,6 +554,15 @@ function useOptimistic<S, A>(
554 return [passthrough, unsupportedSetOptimisticState];
555 }
556
557 +function useFormState<S, P>(
558 + action: (S, P) => S,
559 + initialState: S,
560 + url?: string,
561 +): [S, (P) => void] {
562 + resolveCurrentlyRenderingComponent();
563 + return [initialState, unsupportedDispatchFormState];
564 +}
565 +
566 function useId(): string {
567 const task: Task = (currentlyRenderingTask: any);
568 const treeId = getTreeId(task.treeContext);
@@ -650,6 +663,7 @@ if (enableFormActions && enableAsyncActions) {
663 }
664 if (enableAsyncActions) {
665 HooksDispatcher.useOptimistic = useOptimistic;
666 + HooksDispatcher.useFormState = useFormState;
667 }
668
669 export let currentResumableState: null | ResumableState = (null: any);
scripts/error-codes/codes.json
+2 -1
@@ -469,5 +469,6 @@
469 "481": "Tried to encode a Server Action from a different instance than the encoder is from. This is a bug in React.",
470 "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.",
471 "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.",
472 - "484": "A Server Component was postponed. The reason is omitted in production builds to avoid leaking sensitive details."
472 + "484": "A Server Component was postponed. The reason is omitted in production builds to avoid leaking sensitive details.",
473 + "485": "Cannot update form state while rendering."
474 }
\ No newline at end of file