Add pending state to useFormState (#28514)
## Overview Adds a `pending` state to useFormState, which will be replaced by `useActionState` in the next diff. We will keep `useFormState` around for backwards compatibility, but functionally it will work the same as `useActionState`, which has an `isPending` state returned.
Ricky committed
Mar 12, 2024 at 15:50 UTC
17eaacaac167addf0c4358b4983f054073a0626d
9 files changed
+190
-93
packages/react-debug-tools/src/ReactDebugHooks.js
+5
-2
@@ -521,8 +521,9 @@ function useFormState<S, P>(
521
action: (Awaited<S>, P) => S,
522
initialState: Awaited<S>,
523
permalink?: string,
524
-): [Awaited<S>, (P) => void] {
524
+): [Awaited<S>, (P) => void, boolean] {
525
const hook = nextHook(); // FormState
526
+ nextHook(); // PendingState
527
nextHook(); // ActionQueue
528
const stackError = new Error();
529
let value;
@@ -580,7 +581,9 @@ function useFormState<S, P>(
581
// value being a Thenable is equivalent to error being not null
582
// i.e. we only reach this point with Awaited<S>
583
const state = ((value: any): Awaited<S>);
583
- return [state, (payload: P) => {}];
584
+
585
+ // TODO: support displaying pending value
586
+ return [state, (payload: P) => {}, false];
587
}
588
589
const Dispatcher: DispatcherType = {
packages/react-dom-bindings/src/shared/ReactDOMFormActions.js
+1
-1
@@ -80,7 +80,7 @@ export function useFormState<S, P>(
80
action: (Awaited<S>, P) => S,
81
initialState: Awaited<S>,
82
permalink?: string,
83
-): [Awaited<S>, (P) => void] {
83
+): [Awaited<S>, (P) => void, boolean] {
84
if (!(enableFormActions && enableAsyncActions)) {
85
throw new Error('Not implemented.');
86
} else {
packages/react-dom/index.experimental.js
+1
-1
@@ -45,7 +45,7 @@ export function experimental_useFormState<S, P>(
45
action: (Awaited<S>, P) => S,
46
initialState: Awaited<S>,
47
permalink?: string,
48
-): [Awaited<S>, (P) => void] {
48
+): [Awaited<S>, (P) => void, boolean] {
49
if (__DEV__) {
50
console.error(
51
'useFormState is now in canary. Remove the experimental_ prefix. ' +
packages/react-dom/server-rendering-stub.js
+1
-1
@@ -50,7 +50,7 @@ export function experimental_useFormState<S, P>(
50
action: (Awaited<S>, P) => S,
51
initialState: Awaited<S>,
52
permalink?: string,
53
-): [Awaited<S>, (P) => void] {
53
+): [Awaited<S>, (P) => void, boolean] {
54
if (__DEV__) {
55
console.error(
56
'useFormState is now in canary. Remove the experimental_ prefix. ' +
packages/react-dom/src/__tests__/ReactDOMForm-test.js
+53
-47
@@ -63,21 +63,6 @@ describe('ReactDOMForm', () => {
63
textCache = new Map();
64
});
65
66
- function resolveText(text) {
67
- const record = textCache.get(text);
68
- if (record === undefined) {
69
- const newRecord = {
70
- status: 'resolved',
71
- value: text,
72
- };
73
- textCache.set(text, newRecord);
74
- } else if (record.status === 'pending') {
75
- const thenable = record.value;
76
- record.status = 'resolved';
77
- record.value = text;
78
- thenable.pings.forEach(t => t());
79
- }
80
- }
66
function resolveText(text) {
67
const record = textCache.get(text);
68
if (record === undefined) {
@@ -997,19 +982,20 @@ describe('ReactDOMForm', () => {
982
983
let dispatch;
984
function App() {
1000
- const [state, _dispatch] = useFormState(action, 0);
985
+ const [state, _dispatch, isPending] = useFormState(action, 0);
986
dispatch = _dispatch;
1002
- return <Text text={state} />;
987
+ const pending = isPending ? 'Pending ' : '';
988
+ return <Text text={pending + state} />;
989
}
990
991
const root = ReactDOMClient.createRoot(container);
992
await act(() => root.render(<App />));
1007
- assertLog([0]);
993
+ assertLog(['0']);
994
expect(container.textContent).toBe('0');
995
996
await act(() => dispatch('increment'));
1011
- assertLog(['Async action started [1]']);
1012
- expect(container.textContent).toBe('0');
997
+ assertLog(['Async action started [1]', 'Pending 0']);
998
+ expect(container.textContent).toBe('Pending 0');
999
1000
// Dispatch a few more actions. None of these will start until the previous
1001
// one finishes.
@@ -1031,7 +1017,7 @@ describe('ReactDOMForm', () => {
1017
await act(() => resolveText('Wait [4]'));
1018
1019
// Finally the last action finishes and we can render the result.
1034
- assertLog([2]);
1020
+ assertLog(['2']);
1021
expect(container.textContent).toBe('2');
1022
});
1023
@@ -1040,40 +1026,42 @@ describe('ReactDOMForm', () => {
1026
test('useFormState supports inline actions', async () => {
1027
let increment;
1028
function App({stepSize}) {
1043
- const [state, dispatch] = useFormState(async prevState => {
1029
+ const [state, dispatch, isPending] = useFormState(async prevState => {
1030
return prevState + stepSize;
1031
}, 0);
1032
increment = dispatch;
1047
- return <Text text={state} />;
1033
+ const pending = isPending ? 'Pending ' : '';
1034
+ return <Text text={pending + state} />;
1035
}
1036
1037
// Initial render
1038
const root = ReactDOMClient.createRoot(container);
1039
await act(() => root.render(<App stepSize={1} />));
1053
- assertLog([0]);
1040
+ assertLog(['0']);
1041
1042
// Perform an action. This will increase the state by 1, as defined by the
1043
// stepSize prop.
1044
await act(() => increment());
1058
- assertLog([1]);
1045
+ assertLog(['Pending 0', '1']);
1046
1047
// Now increase the stepSize prop to 10. Subsequent steps will increase
1048
// by this amount.
1049
await act(() => root.render(<App stepSize={10} />));
1063
- assertLog([1]);
1050
+ assertLog(['1']);
1051
1052
// Increment again. The state should increase by 10.
1053
await act(() => increment());
1067
- assertLog([11]);
1054
+ assertLog(['Pending 1', '11']);
1055
});
1056
1057
// @gate enableFormActions
1058
// @gate enableAsyncActions
1059
test('useFormState: dispatch throws if called during render', async () => {
1060
function App() {
1074
- const [state, dispatch] = useFormState(async () => {}, 0);
1061
+ const [state, dispatch, isPending] = useFormState(async () => {}, 0);
1062
dispatch();
1076
- return <Text text={state} />;
1063
+ const pending = isPending ? 'Pending ' : '';
1064
+ return <Text text={pending + state} />;
1065
}
1066
1067
const root = ReactDOMClient.createRoot(container);
@@ -1088,12 +1076,13 @@ describe('ReactDOMForm', () => {
1076
test('queues multiple actions and runs them in order', async () => {
1077
let action;
1078
function App() {
1091
- const [state, dispatch] = useFormState(
1079
+ const [state, dispatch, isPending] = useFormState(
1080
async (s, a) => await getText(a),
1081
'A',
1082
);
1083
action = dispatch;
1096
- return <Text text={state} />;
1084
+ const pending = isPending ? 'Pending ' : '';
1085
+ return <Text text={pending + state} />;
1086
}
1087
1088
const root = ReactDOMClient.createRoot(container);
@@ -1101,8 +1090,11 @@ describe('ReactDOMForm', () => {
1090
assertLog(['A']);
1091
1092
await act(() => action('B'));
1093
+ // The first dispatch will update the pending state.
1094
+ assertLog(['Pending A']);
1095
await act(() => action('C'));
1096
await act(() => action('D'));
1097
+ assertLog([]);
1098
1099
await act(() => resolveText('B'));
1100
await act(() => resolveText('C'));
@@ -1117,31 +1109,32 @@ describe('ReactDOMForm', () => {
1109
test('useFormState: works if action is sync', async () => {
1110
let increment;
1111
function App({stepSize}) {
1120
- const [state, dispatch] = useFormState(prevState => {
1112
+ const [state, dispatch, isPending] = useFormState(prevState => {
1113
return prevState + stepSize;
1114
}, 0);
1115
increment = dispatch;
1124
- return <Text text={state} />;
1116
+ const pending = isPending ? 'Pending ' : '';
1117
+ return <Text text={pending + state} />;
1118
}
1119
1120
// Initial render
1121
const root = ReactDOMClient.createRoot(container);
1122
await act(() => root.render(<App stepSize={1} />));
1130
- assertLog([0]);
1123
+ assertLog(['0']);
1124
1125
// Perform an action. This will increase the state by 1, as defined by the
1126
// stepSize prop.
1127
await act(() => increment());
1135
- assertLog([1]);
1128
+ assertLog(['Pending 0', '1']);
1129
1130
// Now increase the stepSize prop to 10. Subsequent steps will increase
1131
// by this amount.
1132
await act(() => root.render(<App stepSize={10} />));
1140
- assertLog([1]);
1133
+ assertLog(['1']);
1134
1135
// Increment again. The state should increase by 10.
1136
await act(() => increment());
1144
- assertLog([11]);
1137
+ assertLog(['Pending 1', '11']);
1138
});
1139
1140
// @gate enableFormActions
@@ -1149,9 +1142,10 @@ describe('ReactDOMForm', () => {
1142
test('useFormState: can mix sync and async actions', async () => {
1143
let action;
1144
function App() {
1152
- const [state, dispatch] = useFormState((s, a) => a, 'A');
1145
+ const [state, dispatch, isPending] = useFormState((s, a) => a, 'A');
1146
action = dispatch;
1154
- return <Text text={state} />;
1147
+ const pending = isPending ? 'Pending ' : '';
1148
+ return <Text text={pending + state} />;
1149
}
1150
1151
const root = ReactDOMClient.createRoot(container);
@@ -1159,9 +1153,12 @@ describe('ReactDOMForm', () => {
1153
assertLog(['A']);
1154
1155
await act(() => action(getText('B')));
1156
+ // The first dispatch will update the pending state.
1157
+ assertLog(['Pending A']);
1158
await act(() => action('C'));
1159
await act(() => action(getText('D')));
1160
await act(() => action('E'));
1161
+ assertLog([]);
1162
1163
await act(() => resolveText('B'));
1164
await act(() => resolveText('D'));
@@ -1189,14 +1186,15 @@ describe('ReactDOMForm', () => {
1186
1187
let action;
1188
function App() {
1192
- const [state, dispatch] = useFormState((s, a) => {
1189
+ const [state, dispatch, isPending] = useFormState((s, a) => {
1190
if (a.endsWith('!')) {
1191
throw new Error(a);
1192
}
1193
return a;
1194
}, 'A');
1195
action = dispatch;
1199
- return <Text text={state} />;
1196
+ const pending = isPending ? 'Pending ' : '';
1197
+ return <Text text={pending + state} />;
1198
}
1199
1200
const root = ReactDOMClient.createRoot(container);
@@ -1210,7 +1208,13 @@ describe('ReactDOMForm', () => {
1208
assertLog(['A']);
1209
1210
await act(() => action('Oops!'));
1213
- assertLog(['Caught an error: Oops!', 'Caught an error: Oops!']);
1211
+ assertLog([
1212
+ // Action begins, error has not thrown yet.
1213
+ 'Pending A',
1214
+ // Now the action runs and throws.
1215
+ 'Caught an error: Oops!',
1216
+ 'Caught an error: Oops!',
1217
+ ]);
1218
expect(container.textContent).toBe('Caught an error: Oops!');
1219
1220
// Reset the error boundary
@@ -1223,7 +1227,7 @@ describe('ReactDOMForm', () => {
1227
action('Oops!');
1228
action('B');
1229
});
1226
- assertLog(['B']);
1230
+ assertLog(['Pending A', 'B']);
1231
expect(container.textContent).toBe('B');
1232
});
1233
@@ -1247,7 +1251,7 @@ describe('ReactDOMForm', () => {
1251
1252
let action;
1253
function App() {
1250
- const [state, dispatch] = useFormState(async (s, a) => {
1254
+ const [state, dispatch, isPending] = useFormState(async (s, a) => {
1255
const text = await getText(a);
1256
if (text.endsWith('!')) {
1257
throw new Error(text);
@@ -1255,7 +1259,8 @@ describe('ReactDOMForm', () => {
1259
return text;
1260
}, 'A');
1261
action = dispatch;
1258
- return <Text text={state} />;
1262
+ const pending = isPending ? 'Pending ' : '';
1263
+ return <Text text={pending + state} />;
1264
}
1265
1266
const root = ReactDOMClient.createRoot(container);
@@ -1269,7 +1274,8 @@ describe('ReactDOMForm', () => {
1274
assertLog(['A']);
1275
1276
await act(() => action('Oops!'));
1272
- assertLog([]);
1277
+ // The first dispatch will update the pending state.
1278
+ assertLog(['Pending A']);
1279
await act(() => resolveText('Oops!'));
1280
assertLog(['Caught an error: Oops!', 'Caught an error: Oops!']);
1281
expect(container.textContent).toBe('Caught an error: Oops!');
@@ -1284,7 +1290,7 @@ describe('ReactDOMForm', () => {
1290
action('Oops!');
1291
action('B');
1292
});
1287
- assertLog([]);
1293
+ assertLog(['Pending A']);
1294
await act(() => resolveText('B'));
1295
assertLog(['B']);
1296
expect(container.textContent).toBe('B');
packages/react-reconciler/src/ReactFiberHooks.js
+72
-20
@@ -1915,6 +1915,7 @@ type FormStateActionQueueNode<P> = {
1915
function dispatchFormState<S, P>(
1916
fiber: Fiber,
1917
actionQueue: FormStateActionQueue<S, P>,
1918
+ setPendingState: boolean => void,
1919
setState: Dispatch<S | Awaited<S>>,
1920
payload: P,
1921
): void {
@@ -1931,7 +1932,12 @@ function dispatchFormState<S, P>(
1932
};
1933
newLast.next = actionQueue.pending = newLast;
1934
1934
- runFormStateAction(actionQueue, (setState: any), payload);
1935
+ runFormStateAction(
1936
+ actionQueue,
1937
+ (setPendingState: any),
1938
+ (setState: any),
1939
+ payload,
1940
+ );
1941
} else {
1942
// There's already an action running. Add to the queue.
1943
const first = last.next;
@@ -1945,6 +1951,7 @@ function dispatchFormState<S, P>(
1951
1952
function runFormStateAction<S, P>(
1953
actionQueue: FormStateActionQueue<S, P>,
1954
+ setPendingState: boolean => void,
1955
setState: Dispatch<S | Awaited<S>>,
1956
payload: P,
1957
) {
@@ -1960,6 +1967,11 @@ function runFormStateAction<S, P>(
1967
if (__DEV__) {
1968
ReactCurrentBatchConfig.transition._updatedFibers = new Set();
1969
}
1970
+
1971
+ // Optimistically update the pending state, similar to useTransition.
1972
+ // This will be reverted automatically when all actions are finished.
1973
+ setPendingState(true);
1974
+
1975
try {
1976
const returnValue = action(prevState, payload);
1977
if (
@@ -1976,9 +1988,18 @@ function runFormStateAction<S, P>(
1988
thenable.then(
1989
(nextState: Awaited<S>) => {
1990
actionQueue.state = nextState;
1979
- finishRunningFormStateAction(actionQueue, (setState: any));
1991
+ finishRunningFormStateAction(
1992
+ actionQueue,
1993
+ (setPendingState: any),
1994
+ (setState: any),
1995
+ );
1996
},
1981
- () => finishRunningFormStateAction(actionQueue, (setState: any)),
1997
+ () =>
1998
+ finishRunningFormStateAction(
1999
+ actionQueue,
2000
+ (setPendingState: any),
2001
+ (setState: any),
2002
+ ),
2003
);
2004
2005
setState((thenable: any));
@@ -1987,7 +2008,11 @@ function runFormStateAction<S, P>(
2008
2009
const nextState = ((returnValue: any): Awaited<S>);
2010
actionQueue.state = nextState;
1990
- finishRunningFormStateAction(actionQueue, (setState: any));
2011
+ finishRunningFormStateAction(
2012
+ actionQueue,
2013
+ (setPendingState: any),
2014
+ (setState: any),
2015
+ );
2016
}
2017
} catch (error) {
2018
// This is a trick to get the `useFormState` hook to rethrow the error.
@@ -2000,7 +2025,11 @@ function runFormStateAction<S, P>(
2025
// $FlowFixMe: Not sure why this doesn't work
2026
}: RejectedThenable<Awaited<S>>);
2027
setState(rejectedThenable);
2003
- finishRunningFormStateAction(actionQueue, (setState: any));
2028
+ finishRunningFormStateAction(
2029
+ actionQueue,
2030
+ (setPendingState: any),
2031
+ (setState: any),
2032
+ );
2033
} finally {
2034
ReactCurrentBatchConfig.transition = prevTransition;
2035
@@ -2022,6 +2051,7 @@ function runFormStateAction<S, P>(
2051
2052
function finishRunningFormStateAction<S, P>(
2053
actionQueue: FormStateActionQueue<S, P>,
2054
+ setPendingState: Dispatch<S | Awaited<S>>,
2055
setState: Dispatch<S | Awaited<S>>,
2056
) {
2057
// The action finished running. Pop it from the queue and run the next pending
@@ -2038,7 +2068,12 @@ function finishRunningFormStateAction<S, P>(
2068
last.next = next;
2069
2070
// Run the next action.
2041
- runFormStateAction(actionQueue, (setState: any), next.payload);
2071
+ runFormStateAction(
2072
+ actionQueue,
2073
+ (setPendingState: any),
2074
+ (setState: any),
2075
+ next.payload,
2076
+ );
2077
}
2078
}
2079
}
@@ -2051,7 +2086,7 @@ function mountFormState<S, P>(
2086
action: (Awaited<S>, P) => S,
2087
initialStateProp: Awaited<S>,
2088
permalink?: string,
2054
-): [Awaited<S>, (P) => void] {
2089
+): [Awaited<S>, (P) => void, boolean] {
2090
let initialState: Awaited<S> = initialStateProp;
2091
if (getIsHydrating()) {
2092
const root: FiberRoot = (getWorkInProgressRoot(): any);
@@ -2090,6 +2125,19 @@ function mountFormState<S, P>(
2125
): any);
2126
stateQueue.dispatch = setState;
2127
2128
+ // Pending state. This is used to store the pending state of the action.
2129
+ // Tracked optimistically, like a transition pending state.
2130
+ const pendingStateHook = mountStateImpl((false: Thenable<boolean> | boolean));
2131
+ const setPendingState: boolean => void = (dispatchOptimisticSetState.bind(
2132
+ null,
2133
+ currentlyRenderingFiber,
2134
+ false,
2135
+ ((pendingStateHook.queue: any): UpdateQueue<
2136
+ S | Awaited<S>,
2137
+ S | Awaited<S>,
2138
+ >),
2139
+ ): any);
2140
+
2141
// Action queue hook. This is used to queue pending actions. The queue is
2142
// shared between all instances of the hook. Similar to a regular state queue,
2143
// but different because the actions are run sequentially, and they run in
@@ -2106,6 +2154,7 @@ function mountFormState<S, P>(
2154
null,
2155
currentlyRenderingFiber,
2156
actionQueue,
2157
+ setPendingState,
2158
setState,
2159
);
2160
actionQueue.dispatch = dispatch;
@@ -2115,14 +2164,14 @@ function mountFormState<S, P>(
2164
// an effect.
2165
actionQueueHook.memoizedState = action;
2166
2118
- return [initialState, dispatch];
2167
+ return [initialState, dispatch, false];
2168
}
2169
2170
function updateFormState<S, P>(
2171
action: (Awaited<S>, P) => S,
2172
initialState: Awaited<S>,
2173
permalink?: string,
2125
-): [Awaited<S>, (P) => void] {
2174
+): [Awaited<S>, (P) => void, boolean] {
2175
const stateHook = updateWorkInProgressHook();
2176
const currentStateHook = ((currentHook: any): Hook);
2177
return updateFormStateImpl(
@@ -2140,13 +2189,15 @@ function updateFormStateImpl<S, P>(
2189
action: (Awaited<S>, P) => S,
2190
initialState: Awaited<S>,
2191
permalink?: string,
2143
-): [Awaited<S>, (P) => void] {
2192
+): [Awaited<S>, (P) => void, boolean] {
2193
const [actionResult] = updateReducerImpl<S | Thenable<S>, S | Thenable<S>>(
2194
stateHook,
2195
currentStateHook,
2196
formStateReducer,
2197
);
2198
2199
+ const [isPending] = updateState(false);
2200
+
2201
// This will suspend until the action finishes.
2202
const state: Awaited<S> =
2203
typeof actionResult === 'object' &&
@@ -2172,7 +2223,7 @@ function updateFormStateImpl<S, P>(
2223
);
2224
}
2225
2175
- return [state, dispatch];
2226
+ return [state, dispatch, isPending];
2227
}
2228
2229
function formStateActionEffect<S, P>(
@@ -2186,7 +2237,7 @@ function rerenderFormState<S, P>(
2237
action: (Awaited<S>, P) => S,
2238
initialState: Awaited<S>,
2239
permalink?: string,
2189
-): [Awaited<S>, (P) => void] {
2240
+): [Awaited<S>, (P) => void, boolean] {
2241
// Unlike useState, useFormState doesn't support render phase updates.
2242
// Also unlike useState, we need to replay all pending updates again in case
2243
// the passthrough value changed.
@@ -2218,7 +2269,8 @@ function rerenderFormState<S, P>(
2269
// This may have changed during the rerender.
2270
actionQueueHook.memoizedState = action;
2271
2221
- return [state, dispatch];
2272
+ // For mount, pending is always false.
2273
+ return [state, dispatch, false];
2274
}
2275
2276
function pushEffect(
@@ -3765,7 +3817,7 @@ if (__DEV__) {
3817
action: (Awaited<S>, P) => S,
3818
initialState: Awaited<S>,
3819
permalink?: string,
3768
- ): [Awaited<S>, (P) => void] {
3820
+ ): [Awaited<S>, (P) => void, boolean] {
3821
currentHookNameInDev = 'useFormState';
3822
mountHookTypesDev();
3823
return mountFormState(action, initialState, permalink);
@@ -3935,7 +3987,7 @@ if (__DEV__) {
3987
action: (Awaited<S>, P) => S,
3988
initialState: Awaited<S>,
3989
permalink?: string,
3938
- ): [Awaited<S>, (P) => void] {
3990
+ ): [Awaited<S>, (P) => void, boolean] {
3991
currentHookNameInDev = 'useFormState';
3992
updateHookTypesDev();
3993
return mountFormState(action, initialState, permalink);
@@ -4107,7 +4159,7 @@ if (__DEV__) {
4159
action: (Awaited<S>, P) => S,
4160
initialState: Awaited<S>,
4161
permalink?: string,
4110
- ): [Awaited<S>, (P) => void] {
4162
+ ): [Awaited<S>, (P) => void, boolean] {
4163
currentHookNameInDev = 'useFormState';
4164
updateHookTypesDev();
4165
return updateFormState(action, initialState, permalink);
@@ -4279,7 +4331,7 @@ if (__DEV__) {
4331
action: (Awaited<S>, P) => S,
4332
initialState: Awaited<S>,
4333
permalink?: string,
4282
- ): [Awaited<S>, (P) => void] {
4334
+ ): [Awaited<S>, (P) => void, boolean] {
4335
currentHookNameInDev = 'useFormState';
4336
updateHookTypesDev();
4337
return rerenderFormState(action, initialState, permalink);
@@ -4472,7 +4524,7 @@ if (__DEV__) {
4524
action: (Awaited<S>, P) => S,
4525
initialState: Awaited<S>,
4526
permalink?: string,
4475
- ): [Awaited<S>, (P) => void] {
4527
+ ): [Awaited<S>, (P) => void, boolean] {
4528
currentHookNameInDev = 'useFormState';
4529
warnInvalidHookAccess();
4530
mountHookTypesDev();
@@ -4670,7 +4722,7 @@ if (__DEV__) {
4722
action: (Awaited<S>, P) => S,
4723
initialState: Awaited<S>,
4724
permalink?: string,
4673
- ): [Awaited<S>, (P) => void] {
4725
+ ): [Awaited<S>, (P) => void, boolean] {
4726
currentHookNameInDev = 'useFormState';
4727
warnInvalidHookAccess();
4728
updateHookTypesDev();
@@ -4868,7 +4920,7 @@ if (__DEV__) {
4920
action: (Awaited<S>, P) => S,
4921
initialState: Awaited<S>,
4922
permalink?: string,
4871
- ): [Awaited<S>, (P) => void] {
4923
+ ): [Awaited<S>, (P) => void, boolean] {
4924
currentHookNameInDev = 'useFormState';
4925
warnInvalidHookAccess();
4926
updateHookTypesDev();
packages/react-reconciler/src/ReactInternalTypes.js
+1
-1
@@ -413,7 +413,7 @@ export type Dispatcher = {
413
action: (Awaited<S>, P) => S,
414
initialState: Awaited<S>,
415
permalink?: string,
416
- ) => [Awaited<S>, (P) => void],
416
+ ) => [Awaited<S>, (P) => void, boolean],
417
};
418
419
export type CacheDispatcher = {
packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMForm-test.js
+53
-17
@@ -358,14 +358,16 @@ describe('ReactFlightDOMForm', () => {
358
359
const initialState = {count: 1};
360
function Client({action}) {
361
- const [state, dispatch] = useFormState(action, initialState);
361
+ const [state, dispatch, isPending] = useFormState(action, initialState);
362
return (
363
<form action={dispatch}>
364
+ <span>{isPending ? 'Pending...' : ''}</span>
365
<span>Count: {state.count}</span>
366
<input type="text" name="incrementAmount" defaultValue="5" />
367
</form>
368
);
369
}
370
+
371
const ClientRef = await clientExports(Client);
372
373
const rscStream = ReactServerDOMServer.renderToReadableStream(
@@ -382,8 +384,10 @@ describe('ReactFlightDOMForm', () => {
384
await readIntoContainer(ssrStream);
385
386
const form = container.getElementsByTagName('form')[0];
385
- const span = container.getElementsByTagName('span')[0];
386
- expect(span.textContent).toBe('Count: 1');
387
+ const pendingSpan = container.getElementsByTagName('span')[0];
388
+ const stateSpan = container.getElementsByTagName('span')[1];
389
+ expect(pendingSpan.textContent).toBe('');
390
+ expect(stateSpan.textContent).toBe('Count: 1');
391
392
const {returnValue} = await submit(form);
393
expect(await returnValue).toEqual({count: 6});
@@ -399,8 +403,13 @@ describe('ReactFlightDOMForm', () => {
403
);
404
405
function Form({action}) {
402
- const [count, dispatch] = useFormState(action, 1);
403
- return <form action={dispatch}>{count}</form>;
406
+ const [count, dispatch, isPending] = useFormState(action, 1);
407
+ return (
408
+ <form action={dispatch}>
409
+ {isPending ? 'Pending...' : ''}
410
+ {count}
411
+ </form>
412
+ );
413
}
414
415
function Client({action}) {
@@ -487,8 +496,13 @@ describe('ReactFlightDOMForm', () => {
496
);
497
498
function Form({action}) {
490
- const [count, dispatch] = useFormState(action, 1);
491
- return <form action={dispatch}>{count}</form>;
499
+ const [count, dispatch, isPending] = useFormState(action, 1);
500
+ return (
501
+ <form action={dispatch}>
502
+ {isPending ? 'Pending...' : ''}
503
+ {count}
504
+ </form>
505
+ );
506
}
507
508
function Client({action}) {
@@ -607,8 +621,13 @@ describe('ReactFlightDOMForm', () => {
621
);
622
623
function Form({action}) {
610
- const [count, dispatch] = useFormState(action, 1);
611
- return <form action={dispatch}>{count}</form>;
624
+ const [count, dispatch, isPending] = useFormState(action, 1);
625
+ return (
626
+ <form action={dispatch}>
627
+ {isPending ? 'Pending...' : ''}
628
+ {count}
629
+ </form>
630
+ );
631
}
632
633
function Client({action}) {
@@ -682,8 +701,13 @@ describe('ReactFlightDOMForm', () => {
701
);
702
703
function Form({action, permalink}) {
685
- const [count, dispatch] = useFormState(action, 1, permalink);
686
- return <form action={dispatch}>{count}</form>;
704
+ const [count, dispatch, isPending] = useFormState(action, 1, permalink);
705
+ return (
706
+ <form action={dispatch}>
707
+ {isPending ? 'Pending...' : ''}
708
+ {count}
709
+ </form>
710
+ );
711
}
712
713
function Page1({action, permalink}) {
@@ -783,17 +807,19 @@ describe('ReactFlightDOMForm', () => {
807
808
const initialState = {count: 1};
809
function Client({action}) {
786
- const [state, dispatch] = useFormState(
810
+ const [state, dispatch, isPending] = useFormState(
811
action,
812
initialState,
813
'/permalink',
814
);
815
return (
816
<form action={dispatch}>
817
+ <span>{isPending ? 'Pending...' : ''}</span>
818
<span>Count: {state.count}</span>
819
</form>
820
);
821
}
822
+
823
const ClientRef = await clientExports(Client);
824
825
const rscStream = ReactServerDOMServer.renderToReadableStream(
@@ -810,8 +836,10 @@ describe('ReactFlightDOMForm', () => {
836
await readIntoContainer(ssrStream);
837
838
const form = container.getElementsByTagName('form')[0];
813
- const span = container.getElementsByTagName('span')[0];
814
- expect(span.textContent).toBe('Count: 1');
839
+ const pendingSpan = container.getElementsByTagName('span')[0];
840
+ const stateSpan = container.getElementsByTagName('span')[1];
841
+ expect(pendingSpan.textContent).toBe('');
842
+ expect(stateSpan.textContent).toBe('Count: 1');
843
844
expect(form.action).toBe('http://localhost/permalink');
845
});
@@ -833,13 +861,19 @@ describe('ReactFlightDOMForm', () => {
861
862
const initialState = {count: 1};
863
function Client({action}) {
836
- const [state, dispatch] = useFormState(action, initialState, permalink);
864
+ const [state, dispatch, isPending] = useFormState(
865
+ action,
866
+ initialState,
867
+ permalink,
868
+ );
869
return (
870
<form action={dispatch}>
871
+ <span>{isPending ? 'Pending...' : ''}</span>
872
<span>Count: {state.count}</span>
873
</form>
874
);
875
}
876
+
877
const ClientRef = await clientExports(Client);
878
879
const rscStream = ReactServerDOMServer.renderToReadableStream(
@@ -856,8 +890,10 @@ describe('ReactFlightDOMForm', () => {
890
await readIntoContainer(ssrStream);
891
892
const form = container.getElementsByTagName('form')[0];
859
- const span = container.getElementsByTagName('span')[0];
860
- expect(span.textContent).toBe('Count: 1');
893
+ const pendingSpan = container.getElementsByTagName('span')[0];
894
+ const stateSpan = container.getElementsByTagName('span')[1];
895
+ expect(pendingSpan.textContent).toBe('');
896
+ expect(stateSpan.textContent).toBe('Count: 1');
897
898
expect(form.action).toBe('http://localhost/permalink');
899
});
packages/react-server/src/ReactFizzHooks.js
+3
-3
@@ -615,7 +615,7 @@ function useFormState<S, P>(
615
action: (Awaited<S>, P) => S,
616
initialState: Awaited<S>,
617
permalink?: string,
618
-): [Awaited<S>, (P) => void] {
618
+): [Awaited<S>, (P) => void, boolean] {
619
resolveCurrentlyRenderingComponent();
620
621
// Count the number of useFormState hooks per component. We also use this to
@@ -708,7 +708,7 @@ function useFormState<S, P>(
708
};
709
}
710
711
- return [state, dispatch];
711
+ return [state, dispatch, false];
712
} else {
713
// This is not a server action, so the implementation is much simpler.
714
@@ -718,7 +718,7 @@ function useFormState<S, P>(
718
const dispatch = (payload: P): void => {
719
boundAction(payload);
720
};
721
- return [initialState, dispatch];
721
+ return [initialState, dispatch, false];
722
}
723
}
724