@samitouri / QOS-React-1 / commits / 17eaacaac1

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