Client implementation of useFormState (#27278)
This implements useFormState in Fiber. (It does not include any progressive enhancement features; those will be added later.) useFormState is a hook for tracking state produced by async actions. It has a signature similar to useReducer, but instead of a reducer, it accepts an async action function. ```js async function action(prevState, payload) { // .. } const [state, dispatch] = useFormState(action, initialState) ``` Calling dispatch runs the async action and updates the state to the returned value. Async actions run before React's render cycle, so unlike reducers, they can contain arbitrary side effects.
Andrew Clark committed
Aug 28, 2023 at 11:05 UTC
456d153bb582798effa76c09bec2405ab2e392cf
6 files changed
+543
-120
packages/react-dom-bindings/src/shared/ReactDOMFormActions.js
+1
-1
@@ -76,7 +76,7 @@ export function useFormStatus(): FormStatus {
76
}
77
78
export function useFormState<S, P>(
79
- action: (S, P) => S,
79
+ action: (S, P) => Promise<S>,
80
initialState: S,
81
url?: string,
82
): [S, (P) => void] {
packages/react-dom/src/__tests__/ReactDOMForm-test.js
+124
-6
@@ -35,6 +35,7 @@ describe('ReactDOMForm', () => {
35
let ReactDOMClient;
36
let Scheduler;
37
let assertLog;
38
+ let waitForThrow;
39
let useState;
40
let Suspense;
41
let startTransition;
@@ -50,6 +51,7 @@ describe('ReactDOMForm', () => {
51
Scheduler = require('scheduler');
52
act = require('internal-test-utils').act;
53
assertLog = require('internal-test-utils').assertLog;
54
+ waitForThrow = require('internal-test-utils').waitForThrow;
55
useState = React.useState;
56
Suspense = React.Suspense;
57
startTransition = React.startTransition;
@@ -974,15 +976,28 @@ describe('ReactDOMForm', () => {
976
977
// @gate enableFormActions
978
// @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;
979
+ test('useFormState updates state asynchronously and queues multiple actions', async () => {
980
+ let actionCounter = 0;
981
+ async function action(state, type) {
982
+ actionCounter++;
983
+
984
+ Scheduler.log(`Async action started [${actionCounter}]`);
985
+ await getText(`Wait [${actionCounter}]`);
986
+
987
+ switch (type) {
988
+ case 'increment':
989
+ return state + 1;
990
+ case 'decrement':
991
+ return state - 1;
992
+ default:
993
+ return state;
994
+ }
995
}
996
997
+ let dispatch;
998
function App() {
985
- const [state] = useFormState(action, 0);
999
+ const [state, _dispatch] = useFormState(action, 0);
1000
+ dispatch = _dispatch;
1001
return <Text text={state} />;
1002
}
1003
@@ -990,5 +1005,108 @@ describe('ReactDOMForm', () => {
1005
await act(() => root.render(<App />));
1006
assertLog([0]);
1007
expect(container.textContent).toBe('0');
1008
+
1009
+ await act(() => dispatch('increment'));
1010
+ assertLog(['Async action started [1]']);
1011
+ expect(container.textContent).toBe('0');
1012
+
1013
+ // Dispatch a few more actions. None of these will start until the previous
1014
+ // one finishes.
1015
+ await act(() => dispatch('increment'));
1016
+ await act(() => dispatch('decrement'));
1017
+ await act(() => dispatch('increment'));
1018
+ assertLog([]);
1019
+
1020
+ // Each action starts as soon as the previous one finishes.
1021
+ // NOTE: React does not render in between these actions because they all
1022
+ // update the same queue, which means they get entangled together. This is
1023
+ // intentional behavior.
1024
+ await act(() => resolveText('Wait [1]'));
1025
+ assertLog(['Async action started [2]']);
1026
+ await act(() => resolveText('Wait [2]'));
1027
+ assertLog(['Async action started [3]']);
1028
+ await act(() => resolveText('Wait [3]'));
1029
+ assertLog(['Async action started [4]']);
1030
+ await act(() => resolveText('Wait [4]'));
1031
+
1032
+ // Finally the last action finishes and we can render the result.
1033
+ assertLog([2]);
1034
+ expect(container.textContent).toBe('2');
1035
+ });
1036
+
1037
+ // @gate enableFormActions
1038
+ // @gate enableAsyncActions
1039
+ test('useFormState supports inline actions', async () => {
1040
+ let increment;
1041
+ function App({stepSize}) {
1042
+ const [state, dispatch] = useFormState(async prevState => {
1043
+ return prevState + stepSize;
1044
+ }, 0);
1045
+ increment = dispatch;
1046
+ return <Text text={state} />;
1047
+ }
1048
+
1049
+ // Initial render
1050
+ const root = ReactDOMClient.createRoot(container);
1051
+ await act(() => root.render(<App stepSize={1} />));
1052
+ assertLog([0]);
1053
+
1054
+ // Perform an action. This will increase the state by 1, as defined by the
1055
+ // stepSize prop.
1056
+ await act(() => increment());
1057
+ assertLog([1]);
1058
+
1059
+ // Now increase the stepSize prop to 10. Subsequent steps will increase
1060
+ // by this amount.
1061
+ await act(() => root.render(<App stepSize={10} />));
1062
+ assertLog([1]);
1063
+
1064
+ // Increment again. The state should increase by 10.
1065
+ await act(() => increment());
1066
+ assertLog([11]);
1067
+ });
1068
+
1069
+ // @gate enableFormActions
1070
+ // @gate enableAsyncActions
1071
+ test('useFormState: dispatch throws if called during render', async () => {
1072
+ function App() {
1073
+ const [state, dispatch] = useFormState(async () => {}, 0);
1074
+ dispatch();
1075
+ return <Text text={state} />;
1076
+ }
1077
+
1078
+ const root = ReactDOMClient.createRoot(container);
1079
+ await act(async () => {
1080
+ root.render(<App />);
1081
+ await waitForThrow('Cannot update form state while rendering.');
1082
+ });
1083
+ });
1084
+
1085
+ // @gate enableFormActions
1086
+ // @gate enableAsyncActions
1087
+ test('useFormState: warns if action is not async', async () => {
1088
+ let dispatch;
1089
+ function App() {
1090
+ const [state, _dispatch] = useFormState(() => {}, 0);
1091
+ dispatch = _dispatch;
1092
+ return <Text text={state} />;
1093
+ }
1094
+
1095
+ const root = ReactDOMClient.createRoot(container);
1096
+ await act(async () => {
1097
+ root.render(<App />);
1098
+ });
1099
+ assertLog([0]);
1100
+
1101
+ expect(() => {
1102
+ // This throws because React expects the action to return a promise.
1103
+ expect(() => dispatch()).toThrow('Cannot read properties of undefined');
1104
+ }).toErrorDev(
1105
+ [
1106
+ // In dev we also log a warning.
1107
+ 'The action passed to useFormState must be an async function',
1108
+ ],
1109
+ {withoutStack: true},
1110
+ );
1111
});
1112
});
packages/react-reconciler/src/ReactFiberAsyncAction.js
+94
-83
@@ -34,97 +34,108 @@ let currentEntangledPendingCount: number = 0;
34
let currentEntangledLane: Lane = NoLane;
35
36
export function requestAsyncActionContext<S>(
37
- actionReturnValue: mixed,
38
- finishedState: S,
39
-): Thenable<S> | S {
40
- if (
41
- actionReturnValue !== null &&
42
- typeof actionReturnValue === 'object' &&
43
- typeof actionReturnValue.then === 'function'
44
- ) {
45
- // This is an async action.
46
- //
47
- // Return a thenable that resolves once the action scope (i.e. the async
48
- // function passed to startTransition) has finished running.
37
+ actionReturnValue: Thenable<mixed>,
38
+ // If this is provided, this resulting thenable resolves to this value instead
39
+ // of the return value of the action. This is a perf trick to avoid composing
40
+ // an extra async function.
41
+ overrideReturnValue: S | null,
42
+): Thenable<S> {
43
+ // This is an async action.
44
+ //
45
+ // Return a thenable that resolves once the action scope (i.e. the async
46
+ // function passed to startTransition) has finished running.
47
50
- const thenable: Thenable<mixed> = (actionReturnValue: any);
51
- let entangledListeners;
52
- if (currentEntangledListeners === null) {
53
- // There's no outer async action scope. Create a new one.
54
- entangledListeners = currentEntangledListeners = [];
55
- currentEntangledPendingCount = 0;
56
- currentEntangledLane = requestTransitionLane();
57
- } else {
58
- entangledListeners = currentEntangledListeners;
59
- }
48
+ const thenable: Thenable<S> = (actionReturnValue: any);
49
+ let entangledListeners;
50
+ if (currentEntangledListeners === null) {
51
+ // There's no outer async action scope. Create a new one.
52
+ entangledListeners = currentEntangledListeners = [];
53
+ currentEntangledPendingCount = 0;
54
+ currentEntangledLane = requestTransitionLane();
55
+ } else {
56
+ entangledListeners = currentEntangledListeners;
57
+ }
58
61
- currentEntangledPendingCount++;
62
- let resultStatus = 'pending';
63
- let rejectedReason;
64
- thenable.then(
65
- () => {
66
- resultStatus = 'fulfilled';
67
- pingEngtangledActionScope();
68
- },
69
- error => {
70
- resultStatus = 'rejected';
71
- rejectedReason = error;
72
- pingEngtangledActionScope();
73
- },
74
- );
59
+ currentEntangledPendingCount++;
60
76
- // Create a thenable that represents the result of this action, but doesn't
77
- // resolve until the entire entangled scope has finished.
78
- //
79
- // Expressed using promises:
80
- // const [thisResult] = await Promise.all([thisAction, entangledAction]);
81
- // return thisResult;
82
- const resultThenable = createResultThenable<S>(entangledListeners);
61
+ // Create a thenable that represents the result of this action, but doesn't
62
+ // resolve until the entire entangled scope has finished.
63
+ //
64
+ // Expressed using promises:
65
+ // const [thisResult] = await Promise.all([thisAction, entangledAction]);
66
+ // return thisResult;
67
+ const resultThenable = createResultThenable<S>(entangledListeners);
68
84
- // Attach a listener to fill in the result.
85
- entangledListeners.push(() => {
86
- switch (resultStatus) {
87
- case 'fulfilled': {
88
- const fulfilledThenable: FulfilledThenable<S> = (resultThenable: any);
89
- fulfilledThenable.status = 'fulfilled';
90
- fulfilledThenable.value = finishedState;
91
- break;
92
- }
93
- case 'rejected': {
94
- const rejectedThenable: RejectedThenable<S> = (resultThenable: any);
95
- rejectedThenable.status = 'rejected';
96
- rejectedThenable.reason = rejectedReason;
97
- break;
98
- }
99
- case 'pending':
100
- default: {
101
- // The listener above should have been called first, so `resultStatus`
102
- // should already be set to the correct value.
103
- throw new Error(
104
- 'Thenable should have already resolved. This ' +
105
- 'is a bug in React.',
106
- );
107
- }
108
- }
109
- });
69
+ let resultStatus = 'pending';
70
+ let resultValue;
71
+ let rejectedReason;
72
+ thenable.then(
73
+ (value: S) => {
74
+ resultStatus = 'fulfilled';
75
+ resultValue = overrideReturnValue !== null ? overrideReturnValue : value;
76
+ pingEngtangledActionScope();
77
+ },
78
+ error => {
79
+ resultStatus = 'rejected';
80
+ rejectedReason = error;
81
+ pingEngtangledActionScope();
82
+ },
83
+ );
84
111
- return resultThenable;
112
- } else {
113
- // This is not an async action, but it may be part of an outer async action.
114
- if (currentEntangledListeners === null) {
115
- return finishedState;
116
- } else {
117
- // Return a thenable that does not resolve until the entangled actions
118
- // have finished.
119
- const entangledListeners = currentEntangledListeners;
120
- const resultThenable = createResultThenable<S>(entangledListeners);
121
- entangledListeners.push(() => {
85
+ // Attach a listener to fill in the result.
86
+ entangledListeners.push(() => {
87
+ switch (resultStatus) {
88
+ case 'fulfilled': {
89
const fulfilledThenable: FulfilledThenable<S> = (resultThenable: any);
90
fulfilledThenable.status = 'fulfilled';
124
- fulfilledThenable.value = finishedState;
125
- });
126
- return resultThenable;
91
+ fulfilledThenable.value = resultValue;
92
+ break;
93
+ }
94
+ case 'rejected': {
95
+ const rejectedThenable: RejectedThenable<S> = (resultThenable: any);
96
+ rejectedThenable.status = 'rejected';
97
+ rejectedThenable.reason = rejectedReason;
98
+ break;
99
+ }
100
+ case 'pending':
101
+ default: {
102
+ // The listener above should have been called first, so `resultStatus`
103
+ // should already be set to the correct value.
104
+ throw new Error(
105
+ 'Thenable should have already resolved. This ' + 'is a bug in React.',
106
+ );
107
+ }
108
}
109
+ });
110
+
111
+ return resultThenable;
112
+}
113
+
114
+export function requestSyncActionContext<S>(
115
+ actionReturnValue: mixed,
116
+ // If this is provided, this resulting thenable resolves to this value instead
117
+ // of the return value of the action. This is a perf trick to avoid composing
118
+ // an extra async function.
119
+ overrideReturnValue: S | null,
120
+): Thenable<S> | S {
121
+ const resultValue: S =
122
+ overrideReturnValue !== null
123
+ ? overrideReturnValue
124
+ : (actionReturnValue: any);
125
+ // This is not an async action, but it may be part of an outer async action.
126
+ if (currentEntangledListeners === null) {
127
+ return resultValue;
128
+ } else {
129
+ // Return a thenable that does not resolve until the entangled actions
130
+ // have finished.
131
+ const entangledListeners = currentEntangledListeners;
132
+ const resultThenable = createResultThenable<S>(entangledListeners);
133
+ entangledListeners.push(() => {
134
+ const fulfilledThenable: FulfilledThenable<S> = (resultThenable: any);
135
+ fulfilledThenable.status = 'fulfilled';
136
+ fulfilledThenable.value = resultValue;
137
+ });
138
+ return resultThenable;
139
}
140
}
141
packages/react-reconciler/src/ReactFiberHooks.js
+322
-28
@@ -137,7 +137,10 @@ import {
137
} from './ReactFiberThenable';
138
import type {ThenableState} from './ReactFiberThenable';
139
import type {BatchConfigTransition} from './ReactFiberTracingMarkerComponent';
140
-import {requestAsyncActionContext} from './ReactFiberAsyncAction';
140
+import {
141
+ requestAsyncActionContext,
142
+ requestSyncActionContext,
143
+} from './ReactFiberAsyncAction';
144
import {HostTransitionContext} from './ReactFiberHostContext';
145
import {requestTransitionLane} from './ReactFiberRootScheduler';
146
@@ -1854,35 +1857,304 @@ function rerenderOptimistic<S, A>(
1857
return [passthrough, dispatch];
1858
}
1859
1857
-function TODO_formStateDispatch() {
1858
- throw new Error('Not implemented.');
1860
+// useFormState actions run sequentially, because each action receives the
1861
+// previous state as an argument. We store pending actions on a queue.
1862
+type FormStateActionQueue<S, P> = {
1863
+ // This is the most recent state returned from an action. It's updated as
1864
+ // soon as the action finishes running.
1865
+ state: S,
1866
+ // A stable dispatch method, passed to the user.
1867
+ dispatch: Dispatch<P>,
1868
+ // This is the most recent action function that was rendered. It's updated
1869
+ // during the commit phase.
1870
+ action: (S, P) => Promise<S>,
1871
+ // This is a circular linked list of pending action payloads. It incudes the
1872
+ // action that is currently running.
1873
+ pending: FormStateActionQueueNode<P> | null,
1874
+};
1875
+
1876
+type FormStateActionQueueNode<P> = {
1877
+ payload: P,
1878
+ // This is never null because it's part of a circular linked list.
1879
+ next: FormStateActionQueueNode<P>,
1880
+};
1881
+
1882
+function dispatchFormState<S, P>(
1883
+ fiber: Fiber,
1884
+ actionQueue: FormStateActionQueue<S, P>,
1885
+ setState: Dispatch<Thenable<S>>,
1886
+ payload: P,
1887
+): void {
1888
+ if (isRenderPhaseUpdate(fiber)) {
1889
+ throw new Error('Cannot update form state while rendering.');
1890
+ }
1891
+ const last = actionQueue.pending;
1892
+ if (last === null) {
1893
+ // There are no pending actions; this is the first one. We can run
1894
+ // it immediately.
1895
+ const newLast: FormStateActionQueueNode<P> = {
1896
+ payload,
1897
+ next: (null: any), // circular
1898
+ };
1899
+ newLast.next = actionQueue.pending = newLast;
1900
+
1901
+ runFormStateAction(actionQueue, setState, payload);
1902
+ } else {
1903
+ // There's already an action running. Add to the queue.
1904
+ const first = last.next;
1905
+ const newLast: FormStateActionQueueNode<P> = {
1906
+ payload,
1907
+ next: first,
1908
+ };
1909
+ last.next = newLast;
1910
+ }
1911
+}
1912
+
1913
+function runFormStateAction<S, P>(
1914
+ actionQueue: FormStateActionQueue<S, P>,
1915
+ setState: Dispatch<Thenable<S>>,
1916
+ payload: P,
1917
+) {
1918
+ const action = actionQueue.action;
1919
+ const prevState = actionQueue.state;
1920
+
1921
+ // This is a fork of startTransition
1922
+ const prevTransition = ReactCurrentBatchConfig.transition;
1923
+ ReactCurrentBatchConfig.transition = ({}: BatchConfigTransition);
1924
+ const currentTransition = ReactCurrentBatchConfig.transition;
1925
+ if (__DEV__) {
1926
+ ReactCurrentBatchConfig.transition._updatedFibers = new Set();
1927
+ }
1928
+ try {
1929
+ const promise = action(prevState, payload);
1930
+
1931
+ if (__DEV__) {
1932
+ if (
1933
+ promise === null ||
1934
+ typeof promise !== 'object' ||
1935
+ typeof (promise: any).then !== 'function'
1936
+ ) {
1937
+ console.error(
1938
+ 'The action passed to useFormState must be an async function.',
1939
+ );
1940
+ }
1941
+ }
1942
+
1943
+ // Attach a listener to read the return state of the action. As soon as this
1944
+ // resolves, we can run the next action in the sequence.
1945
+ promise.then(
1946
+ (nextState: S) => {
1947
+ actionQueue.state = nextState;
1948
+ finishRunningFormStateAction(actionQueue, setState);
1949
+ },
1950
+ () => finishRunningFormStateAction(actionQueue, setState),
1951
+ );
1952
+
1953
+ // Create a thenable that resolves once the current async action scope has
1954
+ // finished. Then stash that thenable in state. We'll unwrap it with the
1955
+ // `use` algorithm during render. This is the same logic used
1956
+ // by startTransition.
1957
+ const entangledThenable: Thenable<S> = requestAsyncActionContext(
1958
+ promise,
1959
+ null,
1960
+ );
1961
+ setState(entangledThenable);
1962
+ } finally {
1963
+ ReactCurrentBatchConfig.transition = prevTransition;
1964
+
1965
+ if (__DEV__) {
1966
+ if (prevTransition === null && currentTransition._updatedFibers) {
1967
+ const updatedFibersCount = currentTransition._updatedFibers.size;
1968
+ currentTransition._updatedFibers.clear();
1969
+ if (updatedFibersCount > 10) {
1970
+ console.warn(
1971
+ 'Detected a large number of updates inside startTransition. ' +
1972
+ 'If this is due to a subscription please re-write it to use React provided hooks. ' +
1973
+ 'Otherwise concurrent mode guarantees are off the table.',
1974
+ );
1975
+ }
1976
+ }
1977
+ }
1978
+ }
1979
+}
1980
+
1981
+function finishRunningFormStateAction<S, P>(
1982
+ actionQueue: FormStateActionQueue<S, P>,
1983
+ setState: Dispatch<Thenable<S>>,
1984
+) {
1985
+ // The action finished running. Pop it from the queue and run the next pending
1986
+ // action, if there are any.
1987
+ const last = actionQueue.pending;
1988
+ if (last !== null) {
1989
+ const first = last.next;
1990
+ if (first === last) {
1991
+ // This was the last action in the queue.
1992
+ actionQueue.pending = null;
1993
+ } else {
1994
+ // Remove the first node from the circular queue.
1995
+ const next = first.next;
1996
+ last.next = next;
1997
+
1998
+ // Run the next action.
1999
+ runFormStateAction(actionQueue, setState, next.payload);
2000
+ }
2001
+ }
2002
+}
2003
+
2004
+function formStateReducer<S>(oldState: S, newState: S): S {
2005
+ return newState;
2006
}
2007
2008
function mountFormState<S, P>(
1862
- action: (S, P) => S,
2009
+ action: (S, P) => Promise<S>,
2010
initialState: S,
2011
url?: string,
2012
): [S, (P) => void] {
1866
- // TODO: Not yet implemented
1867
- return [initialState, TODO_formStateDispatch];
2013
+ // State hook. The state is stored in a thenable which is then unwrapped by
2014
+ // the `use` algorithm during render.
2015
+ const stateHook = mountWorkInProgressHook();
2016
+ stateHook.memoizedState = stateHook.baseState = {
2017
+ status: 'fulfilled',
2018
+ value: initialState,
2019
+ };
2020
+ const stateQueue: UpdateQueue<Thenable<S>, Thenable<S>> = {
2021
+ pending: null,
2022
+ lanes: NoLanes,
2023
+ dispatch: null,
2024
+ lastRenderedReducer: formStateReducer,
2025
+ lastRenderedState: (initialState: any),
2026
+ };
2027
+ stateHook.queue = stateQueue;
2028
+ const setState: Dispatch<Thenable<S>> = (dispatchSetState.bind(
2029
+ null,
2030
+ currentlyRenderingFiber,
2031
+ stateQueue,
2032
+ ): any);
2033
+ stateQueue.dispatch = setState;
2034
+
2035
+ // Action queue hook. This is used to queue pending actions. The queue is
2036
+ // shared between all instances of the hook. Similar to a regular state queue,
2037
+ // but different because the actions are run sequentially, and they run in
2038
+ // an event instead of during render.
2039
+ const actionQueueHook = mountWorkInProgressHook();
2040
+ const actionQueue: FormStateActionQueue<S, P> = {
2041
+ state: initialState,
2042
+ dispatch: (null: any), // circular
2043
+ action,
2044
+ pending: null,
2045
+ };
2046
+ actionQueueHook.queue = actionQueue;
2047
+ const dispatch = dispatchFormState.bind(
2048
+ null,
2049
+ currentlyRenderingFiber,
2050
+ actionQueue,
2051
+ setState,
2052
+ );
2053
+ actionQueue.dispatch = dispatch;
2054
+
2055
+ // Stash the action function on the memoized state of the hook. We'll use this
2056
+ // to detect when the action function changes so we can update it in
2057
+ // an effect.
2058
+ actionQueueHook.memoizedState = action;
2059
+
2060
+ return [initialState, dispatch];
2061
}
2062
2063
function updateFormState<S, P>(
1871
- action: (S, P) => S,
2064
+ action: (S, P) => Promise<S>,
2065
initialState: S,
2066
url?: string,
2067
): [S, (P) => void] {
1875
- // TODO: Not yet implemented
1876
- return [initialState, TODO_formStateDispatch];
2068
+ const stateHook = updateWorkInProgressHook();
2069
+ const currentStateHook = ((currentHook: any): Hook);
2070
+ return updateFormStateImpl(
2071
+ stateHook,
2072
+ currentStateHook,
2073
+ action,
2074
+ initialState,
2075
+ url,
2076
+ );
2077
+}
2078
+
2079
+function updateFormStateImpl<S, P>(
2080
+ stateHook: Hook,
2081
+ currentStateHook: Hook,
2082
+ action: (S, P) => Promise<S>,
2083
+ initialState: S,
2084
+ url?: string,
2085
+): [S, (P) => void] {
2086
+ const [thenable] = updateReducerImpl<Thenable<S>, Thenable<S>>(
2087
+ stateHook,
2088
+ currentStateHook,
2089
+ formStateReducer,
2090
+ );
2091
+
2092
+ // This will suspend until the action finishes.
2093
+ const state = useThenable(thenable);
2094
+
2095
+ const actionQueueHook = updateWorkInProgressHook();
2096
+ const actionQueue = actionQueueHook.queue;
2097
+ const dispatch = actionQueue.dispatch;
2098
+
2099
+ // Check if a new action was passed. If so, update it in an effect.
2100
+ const prevAction = actionQueueHook.memoizedState;
2101
+ if (action !== prevAction) {
2102
+ currentlyRenderingFiber.flags |= PassiveEffect;
2103
+ pushEffect(
2104
+ HookHasEffect | HookPassive,
2105
+ formStateActionEffect.bind(null, actionQueue, action),
2106
+ createEffectInstance(),
2107
+ null,
2108
+ );
2109
+ }
2110
+
2111
+ return [state, dispatch];
2112
+}
2113
+
2114
+function formStateActionEffect<S, P>(
2115
+ actionQueue: FormStateActionQueue<S, P>,
2116
+ action: (S, P) => Promise<S>,
2117
+): void {
2118
+ actionQueue.action = action;
2119
}
2120
2121
function rerenderFormState<S, P>(
1880
- action: (S, P) => S,
2122
+ action: (S, P) => Promise<S>,
2123
initialState: S,
2124
url?: string,
2125
): [S, (P) => void] {
1884
- // TODO: Not yet implemented
1885
- return [initialState, TODO_formStateDispatch];
2126
+ // Unlike useState, useFormState doesn't support render phase updates.
2127
+ // Also unlike useState, we need to replay all pending updates again in case
2128
+ // the passthrough value changed.
2129
+ //
2130
+ // So instead of a forked re-render implementation that knows how to handle
2131
+ // render phase udpates, we can use the same implementation as during a
2132
+ // regular mount or update.
2133
+ const stateHook = updateWorkInProgressHook();
2134
+ const currentStateHook = currentHook;
2135
+
2136
+ if (currentStateHook !== null) {
2137
+ // This is an update. Process the update queue.
2138
+ return updateFormStateImpl(
2139
+ stateHook,
2140
+ currentStateHook,
2141
+ action,
2142
+ initialState,
2143
+ url,
2144
+ );
2145
+ }
2146
+
2147
+ // This is a mount. No updates to process.
2148
+ const state = stateHook.memoizedState;
2149
+
2150
+ const actionQueueHook = updateWorkInProgressHook();
2151
+ const actionQueue = actionQueueHook.queue;
2152
+ const dispatch = actionQueue.dispatch;
2153
+
2154
+ // This may have changed during the rerender.
2155
+ actionQueueHook.memoizedState = action;
2156
+
2157
+ return [state, dispatch];
2158
}
2159
2160
function pushEffect(
@@ -2459,15 +2731,37 @@ function startTransition<S>(
2731
if (enableAsyncActions) {
2732
const returnValue = callback();
2733
2462
- // This is either `finishedState` or a thenable that resolves to
2463
- // `finishedState`, depending on whether the action scope is an async
2464
- // function. In the async case, the resulting render will suspend until
2465
- // the async action scope has finished.
2466
- const maybeThenable = requestAsyncActionContext(
2467
- returnValue,
2468
- finishedState,
2469
- );
2470
- dispatchSetState(fiber, queue, maybeThenable);
2734
+ // Check if we're inside an async action scope. If so, we'll entangle
2735
+ // this new action with the existing scope.
2736
+ //
2737
+ // If we're not already inside an async action scope, and this action is
2738
+ // async, then we'll create a new async scope.
2739
+ //
2740
+ // In the async case, the resulting render will suspend until the async
2741
+ // action scope has finished.
2742
+ if (
2743
+ returnValue !== null &&
2744
+ typeof returnValue === 'object' &&
2745
+ typeof returnValue.then === 'function'
2746
+ ) {
2747
+ const thenable = ((returnValue: any): Thenable<mixed>);
2748
+ // This is a thenable that resolves to `finishedState` once the async
2749
+ // action scope has finished.
2750
+ const entangledResult = requestAsyncActionContext(
2751
+ thenable,
2752
+ finishedState,
2753
+ );
2754
+ dispatchSetState(fiber, queue, entangledResult);
2755
+ } else {
2756
+ // This is either `finishedState` or a thenable that resolves to
2757
+ // `finishedState`, depending on whether we're inside an async
2758
+ // action scope.
2759
+ const entangledResult = requestSyncActionContext(
2760
+ returnValue,
2761
+ finishedState,
2762
+ );
2763
+ dispatchSetState(fiber, queue, entangledResult);
2764
+ }
2765
} else {
2766
// Async actions are not enabled.
2767
dispatchSetState(fiber, queue, finishedState);
@@ -3332,7 +3626,7 @@ if (__DEV__) {
3626
useHostTransitionStatus;
3627
(HooksDispatcherOnMountInDEV: Dispatcher).useFormState =
3628
function useFormState<S, P>(
3335
- action: (S, P) => S,
3629
+ action: (S, P) => Promise<S>,
3630
initialState: S,
3631
url?: string,
3632
): [S, (P) => void] {
@@ -3502,7 +3796,7 @@ if (__DEV__) {
3796
useHostTransitionStatus;
3797
(HooksDispatcherOnMountWithHookTypesInDEV: Dispatcher).useFormState =
3798
function useFormState<S, P>(
3505
- action: (S, P) => S,
3799
+ action: (S, P) => Promise<S>,
3800
initialState: S,
3801
url?: string,
3802
): [S, (P) => void] {
@@ -3674,7 +3968,7 @@ if (__DEV__) {
3968
useHostTransitionStatus;
3969
(HooksDispatcherOnUpdateInDEV: Dispatcher).useFormState =
3970
function useFormState<S, P>(
3677
- action: (S, P) => S,
3971
+ action: (S, P) => Promise<S>,
3972
initialState: S,
3973
url?: string,
3974
): [S, (P) => void] {
@@ -3846,7 +4140,7 @@ if (__DEV__) {
4140
useHostTransitionStatus;
4141
(HooksDispatcherOnRerenderInDEV: Dispatcher).useFormState =
4142
function useFormState<S, P>(
3849
- action: (S, P) => S,
4143
+ action: (S, P) => Promise<S>,
4144
initialState: S,
4145
url?: string,
4146
): [S, (P) => void] {
@@ -4039,7 +4333,7 @@ if (__DEV__) {
4333
useHostTransitionStatus;
4334
(InvalidNestedHooksDispatcherOnMountInDEV: Dispatcher).useFormState =
4335
function useFormState<S, P>(
4042
- action: (S, P) => S,
4336
+ action: (S, P) => Promise<S>,
4337
initialState: S,
4338
url?: string,
4339
): [S, (P) => void] {
@@ -4237,7 +4531,7 @@ if (__DEV__) {
4531
useHostTransitionStatus;
4532
(InvalidNestedHooksDispatcherOnUpdateInDEV: Dispatcher).useFormState =
4533
function useFormState<S, P>(
4240
- action: (S, P) => S,
4534
+ action: (S, P) => Promise<S>,
4535
initialState: S,
4536
url?: string,
4537
): [S, (P) => void] {
@@ -4435,7 +4729,7 @@ if (__DEV__) {
4729
useHostTransitionStatus;
4730
(InvalidNestedHooksDispatcherOnRerenderInDEV: Dispatcher).useFormState =
4731
function useFormState<S, P>(
4438
- action: (S, P) => S,
4732
+ action: (S, P) => Promise<S>,
4733
initialState: S,
4734
url?: string,
4735
): [S, (P) => void] {
packages/react-reconciler/src/ReactInternalTypes.js
+1
-1
@@ -415,7 +415,7 @@ export type Dispatcher = {
415
reducer: ?(S, A) => S,
416
) => [S, (A) => void],
417
useFormState?: <S, P>(
418
- action: (S, P) => S,
418
+ action: (S, P) => Promise<S>,
419
initialState: S,
420
url?: string,
421
) => [S, (P) => void],
packages/react-server/src/ReactFizzHooks.js
+1
-1
@@ -555,7 +555,7 @@ function useOptimistic<S, A>(
555
}
556
557
function useFormState<S, P>(
558
- action: (S, P) => S,
558
+ action: (S, P) => Promise<S>,
559
initialState: S,
560
url?: string,
561
): [S, (P) => void] {