diff --git a/packages/react-debug-tools/src/ReactDebugHooks.js b/packages/react-debug-tools/src/ReactDebugHooks.js
index 68ac2587be3d0..20e3d89e1bf64 100644
--- a/packages/react-debug-tools/src/ReactDebugHooks.js
+++ b/packages/react-debug-tools/src/ReactDebugHooks.js
@@ -521,8 +521,9 @@ function useFormState(
action: (Awaited, P) => S,
initialState: Awaited,
permalink?: string,
-): [Awaited, (P) => void] {
+): [Awaited, (P) => void, boolean] {
const hook = nextHook(); // FormState
+ nextHook(); // PendingState
nextHook(); // ActionQueue
const stackError = new Error();
let value;
@@ -580,7 +581,9 @@ function useFormState(
// value being a Thenable is equivalent to error being not null
// i.e. we only reach this point with Awaited
const state = ((value: any): Awaited);
- return [state, (payload: P) => {}];
+
+ // TODO: support displaying pending value
+ return [state, (payload: P) => {}, false];
}
const Dispatcher: DispatcherType = {
diff --git a/packages/react-dom-bindings/src/shared/ReactDOMFormActions.js b/packages/react-dom-bindings/src/shared/ReactDOMFormActions.js
index 492f2065edd77..80d544cd28f44 100644
--- a/packages/react-dom-bindings/src/shared/ReactDOMFormActions.js
+++ b/packages/react-dom-bindings/src/shared/ReactDOMFormActions.js
@@ -80,7 +80,7 @@ export function useFormState(
action: (Awaited, P) => S,
initialState: Awaited,
permalink?: string,
-): [Awaited, (P) => void] {
+): [Awaited, (P) => void, boolean] {
if (!(enableFormActions && enableAsyncActions)) {
throw new Error('Not implemented.');
} else {
diff --git a/packages/react-dom/index.experimental.js b/packages/react-dom/index.experimental.js
index 7a6e52121f75b..539dbddb5a89f 100644
--- a/packages/react-dom/index.experimental.js
+++ b/packages/react-dom/index.experimental.js
@@ -45,7 +45,7 @@ export function experimental_useFormState(
action: (Awaited, P) => S,
initialState: Awaited,
permalink?: string,
-): [Awaited, (P) => void] {
+): [Awaited, (P) => void, boolean] {
if (__DEV__) {
console.error(
'useFormState is now in canary. Remove the experimental_ prefix. ' +
diff --git a/packages/react-dom/server-rendering-stub.js b/packages/react-dom/server-rendering-stub.js
index 71d4124ddabfa..db427710333a5 100644
--- a/packages/react-dom/server-rendering-stub.js
+++ b/packages/react-dom/server-rendering-stub.js
@@ -50,7 +50,7 @@ export function experimental_useFormState(
action: (Awaited, P) => S,
initialState: Awaited,
permalink?: string,
-): [Awaited, (P) => void] {
+): [Awaited, (P) => void, boolean] {
if (__DEV__) {
console.error(
'useFormState is now in canary. Remove the experimental_ prefix. ' +
diff --git a/packages/react-dom/src/__tests__/ReactDOMForm-test.js b/packages/react-dom/src/__tests__/ReactDOMForm-test.js
index 000cfbf70c246..977439b099971 100644
--- a/packages/react-dom/src/__tests__/ReactDOMForm-test.js
+++ b/packages/react-dom/src/__tests__/ReactDOMForm-test.js
@@ -63,21 +63,6 @@ describe('ReactDOMForm', () => {
textCache = new Map();
});
- function resolveText(text) {
- const record = textCache.get(text);
- if (record === undefined) {
- const newRecord = {
- status: 'resolved',
- value: text,
- };
- textCache.set(text, newRecord);
- } else if (record.status === 'pending') {
- const thenable = record.value;
- record.status = 'resolved';
- record.value = text;
- thenable.pings.forEach(t => t());
- }
- }
function resolveText(text) {
const record = textCache.get(text);
if (record === undefined) {
@@ -997,19 +982,20 @@ describe('ReactDOMForm', () => {
let dispatch;
function App() {
- const [state, _dispatch] = useFormState(action, 0);
+ const [state, _dispatch, isPending] = useFormState(action, 0);
dispatch = _dispatch;
- return
= {
function dispatchFormState(
fiber: Fiber,
actionQueue: FormStateActionQueue,
+ setPendingState: boolean => void,
setState: Dispatch>,
payload: P,
): void {
@@ -1931,7 +1932,12 @@ function dispatchFormState(
};
newLast.next = actionQueue.pending = newLast;
- runFormStateAction(actionQueue, (setState: any), payload);
+ runFormStateAction(
+ actionQueue,
+ (setPendingState: any),
+ (setState: any),
+ payload,
+ );
} else {
// There's already an action running. Add to the queue.
const first = last.next;
@@ -1945,6 +1951,7 @@ function dispatchFormState(
function runFormStateAction(
actionQueue: FormStateActionQueue,
+ setPendingState: boolean => void,
setState: Dispatch>,
payload: P,
) {
@@ -1960,6 +1967,11 @@ function runFormStateAction(
if (__DEV__) {
ReactCurrentBatchConfig.transition._updatedFibers = new Set();
}
+
+ // Optimistically update the pending state, similar to useTransition.
+ // This will be reverted automatically when all actions are finished.
+ setPendingState(true);
+
try {
const returnValue = action(prevState, payload);
if (
@@ -1976,9 +1988,18 @@ function runFormStateAction(
thenable.then(
(nextState: Awaited) => {
actionQueue.state = nextState;
- finishRunningFormStateAction(actionQueue, (setState: any));
+ finishRunningFormStateAction(
+ actionQueue,
+ (setPendingState: any),
+ (setState: any),
+ );
},
- () => finishRunningFormStateAction(actionQueue, (setState: any)),
+ () =>
+ finishRunningFormStateAction(
+ actionQueue,
+ (setPendingState: any),
+ (setState: any),
+ ),
);
setState((thenable: any));
@@ -1987,7 +2008,11 @@ function runFormStateAction(
const nextState = ((returnValue: any): Awaited);
actionQueue.state = nextState;
- finishRunningFormStateAction(actionQueue, (setState: any));
+ finishRunningFormStateAction(
+ actionQueue,
+ (setPendingState: any),
+ (setState: any),
+ );
}
} catch (error) {
// This is a trick to get the `useFormState` hook to rethrow the error.
@@ -2000,7 +2025,11 @@ function runFormStateAction(
// $FlowFixMe: Not sure why this doesn't work
}: RejectedThenable(
function finishRunningFormStateAction(
actionQueue: FormStateActionQueue,
+ setPendingState: Dispatch>,
setState: Dispatch>,
) {
// The action finished running. Pop it from the queue and run the next pending
@@ -2038,7 +2068,12 @@ function finishRunningFormStateAction(
last.next = next;
// Run the next action.
- runFormStateAction(actionQueue, (setState: any), next.payload);
+ runFormStateAction(
+ actionQueue,
+ (setPendingState: any),
+ (setState: any),
+ next.payload,
+ );
}
}
}
@@ -2051,7 +2086,7 @@ function mountFormState(
action: (Awaited, P) => S,
initialStateProp: Awaited,
permalink?: string,
-): [Awaited, (P) => void] {
+): [Awaited, (P) => void, boolean] {
let initialState: Awaited = initialStateProp;
if (getIsHydrating()) {
const root: FiberRoot = (getWorkInProgressRoot(): any);
@@ -2090,6 +2125,19 @@ function mountFormState(
): any);
stateQueue.dispatch = setState;
+ // Pending state. This is used to store the pending state of the action.
+ // Tracked optimistically, like a transition pending state.
+ const pendingStateHook = mountStateImpl((false: Thenable,
+ S | Awaited,
+ >),
+ ): any);
+
// Action queue hook. This is used to queue pending actions. The queue is
// shared between all instances of the hook. Similar to a regular state queue,
// but different because the actions are run sequentially, and they run in
@@ -2106,6 +2154,7 @@ function mountFormState(
null,
currentlyRenderingFiber,
actionQueue,
+ setPendingState,
setState,
);
actionQueue.dispatch = dispatch;
@@ -2115,14 +2164,14 @@ function mountFormState(
// an effect.
actionQueueHook.memoizedState = action;
- return [initialState, dispatch];
+ return [initialState, dispatch, false];
}
function updateFormState(
action: (Awaited, P) => S,
initialState: Awaited,
permalink?: string,
-): [Awaited, (P) => void] {
+): [Awaited, (P) => void, boolean] {
const stateHook = updateWorkInProgressHook();
const currentStateHook = ((currentHook: any): Hook);
return updateFormStateImpl(
@@ -2140,13 +2189,15 @@ function updateFormStateImpl(
action: (Awaited, P) => S,
initialState: Awaited,
permalink?: string,
-): [Awaited, (P) => void] {
+): [Awaited, (P) => void, boolean] {
const [actionResult] = updateReducerImpl, S | Thenable>(
stateHook,
currentStateHook,
formStateReducer,
);
+ const [isPending] = updateState(false);
+
// This will suspend until the action finishes.
const state: Awaited =
typeof actionResult === 'object' &&
@@ -2172,7 +2223,7 @@ function updateFormStateImpl(
);
}
- return [state, dispatch];
+ return [state, dispatch, isPending];
}
function formStateActionEffect(
@@ -2186,7 +2237,7 @@ function rerenderFormState(
action: (Awaited, P) => S,
initialState: Awaited,
permalink?: string,
-): [Awaited, (P) => void] {
+): [Awaited, (P) => void, boolean] {
// Unlike useState, useFormState doesn't support render phase updates.
// Also unlike useState, we need to replay all pending updates again in case
// the passthrough value changed.
@@ -2218,7 +2269,8 @@ function rerenderFormState(
// This may have changed during the rerender.
actionQueueHook.memoizedState = action;
- return [state, dispatch];
+ // For mount, pending is always false.
+ return [state, dispatch, false];
}
function pushEffect(
@@ -3765,7 +3817,7 @@ if (__DEV__) {
action: (Awaited, P) => S,
initialState: Awaited,
permalink?: string,
- ): [Awaited, (P) => void] {
+ ): [Awaited, (P) => void, boolean] {
currentHookNameInDev = 'useFormState';
mountHookTypesDev();
return mountFormState(action, initialState, permalink);
@@ -3935,7 +3987,7 @@ if (__DEV__) {
action: (Awaited, P) => S,
initialState: Awaited,
permalink?: string,
- ): [Awaited, (P) => void] {
+ ): [Awaited, (P) => void, boolean] {
currentHookNameInDev = 'useFormState';
updateHookTypesDev();
return mountFormState(action, initialState, permalink);
@@ -4107,7 +4159,7 @@ if (__DEV__) {
action: (Awaited, P) => S,
initialState: Awaited,
permalink?: string,
- ): [Awaited, (P) => void] {
+ ): [Awaited, (P) => void, boolean] {
currentHookNameInDev = 'useFormState';
updateHookTypesDev();
return updateFormState(action, initialState, permalink);
@@ -4279,7 +4331,7 @@ if (__DEV__) {
action: (Awaited, P) => S,
initialState: Awaited,
permalink?: string,
- ): [Awaited, (P) => void] {
+ ): [Awaited, (P) => void, boolean] {
currentHookNameInDev = 'useFormState';
updateHookTypesDev();
return rerenderFormState(action, initialState, permalink);
@@ -4472,7 +4524,7 @@ if (__DEV__) {
action: (Awaited, P) => S,
initialState: Awaited,
permalink?: string,
- ): [Awaited, (P) => void] {
+ ): [Awaited, (P) => void, boolean] {
currentHookNameInDev = 'useFormState';
warnInvalidHookAccess();
mountHookTypesDev();
@@ -4670,7 +4722,7 @@ if (__DEV__) {
action: (Awaited, P) => S,
initialState: Awaited,
permalink?: string,
- ): [Awaited, (P) => void] {
+ ): [Awaited, (P) => void, boolean] {
currentHookNameInDev = 'useFormState';
warnInvalidHookAccess();
updateHookTypesDev();
@@ -4868,7 +4920,7 @@ if (__DEV__) {
action: (Awaited, P) => S,
initialState: Awaited,
permalink?: string,
- ): [Awaited, (P) => void] {
+ ): [Awaited, (P) => void, boolean] {
currentHookNameInDev = 'useFormState';
warnInvalidHookAccess();
updateHookTypesDev();
diff --git a/packages/react-reconciler/src/ReactInternalTypes.js b/packages/react-reconciler/src/ReactInternalTypes.js
index 0227970a77b33..8d3e99b986cfb 100644
--- a/packages/react-reconciler/src/ReactInternalTypes.js
+++ b/packages/react-reconciler/src/ReactInternalTypes.js
@@ -413,7 +413,7 @@ export type Dispatcher = {
action: (Awaited, P) => S,
initialState: Awaited,
permalink?: string,
- ) => [Awaited, (P) => void],
+ ) => [Awaited, (P) => void, boolean],
};
export type CacheDispatcher = {
diff --git a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMForm-test.js b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMForm-test.js
index dcab688b10c83..4ac9663237af6 100644
--- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMForm-test.js
+++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMForm-test.js
@@ -358,14 +358,16 @@ describe('ReactFlightDOMForm', () => {
const initialState = {count: 1};
function Client({action}) {
- const [state, dispatch] = useFormState(action, initialState);
+ const [state, dispatch, isPending] = useFormState(action, initialState);
return (
);
}
+
const ClientRef = await clientExports(Client);
const rscStream = ReactServerDOMServer.renderToReadableStream(
@@ -382,8 +384,10 @@ describe('ReactFlightDOMForm', () => {
await readIntoContainer(ssrStream);
const form = container.getElementsByTagName('form')[0];
- const span = container.getElementsByTagName('span')[0];
- expect(span.textContent).toBe('Count: 1');
+ const pendingSpan = container.getElementsByTagName('span')[0];
+ const stateSpan = container.getElementsByTagName('span')[1];
+ expect(pendingSpan.textContent).toBe('');
+ expect(stateSpan.textContent).toBe('Count: 1');
const {returnValue} = await submit(form);
expect(await returnValue).toEqual({count: 6});
@@ -399,8 +403,13 @@ describe('ReactFlightDOMForm', () => {
);
function Form({action}) {
- const [count, dispatch] = useFormState(action, 1);
- return ;
+ const [count, dispatch, isPending] = useFormState(action, 1);
+ return (
+
+ );
}
function Client({action}) {
@@ -487,8 +496,13 @@ describe('ReactFlightDOMForm', () => {
);
function Form({action}) {
- const [count, dispatch] = useFormState(action, 1);
- return ;
+ const [count, dispatch, isPending] = useFormState(action, 1);
+ return (
+
+ );
}
function Client({action}) {
@@ -607,8 +621,13 @@ describe('ReactFlightDOMForm', () => {
);
function Form({action}) {
- const [count, dispatch] = useFormState(action, 1);
- return ;
+ const [count, dispatch, isPending] = useFormState(action, 1);
+ return (
+
+ );
}
function Client({action}) {
@@ -682,8 +701,13 @@ describe('ReactFlightDOMForm', () => {
);
function Form({action, permalink}) {
- const [count, dispatch] = useFormState(action, 1, permalink);
- return ;
+ const [count, dispatch, isPending] = useFormState(action, 1, permalink);
+ return (
+
+ );
}
function Page1({action, permalink}) {
@@ -783,17 +807,19 @@ describe('ReactFlightDOMForm', () => {
const initialState = {count: 1};
function Client({action}) {
- const [state, dispatch] = useFormState(
+ const [state, dispatch, isPending] = useFormState(
action,
initialState,
'/permalink',
);
return (
);
}
+
const ClientRef = await clientExports(Client);
const rscStream = ReactServerDOMServer.renderToReadableStream(
@@ -810,8 +836,10 @@ describe('ReactFlightDOMForm', () => {
await readIntoContainer(ssrStream);
const form = container.getElementsByTagName('form')[0];
- const span = container.getElementsByTagName('span')[0];
- expect(span.textContent).toBe('Count: 1');
+ const pendingSpan = container.getElementsByTagName('span')[0];
+ const stateSpan = container.getElementsByTagName('span')[1];
+ expect(pendingSpan.textContent).toBe('');
+ expect(stateSpan.textContent).toBe('Count: 1');
expect(form.action).toBe('http://localhost/permalink');
});
@@ -833,13 +861,19 @@ describe('ReactFlightDOMForm', () => {
const initialState = {count: 1};
function Client({action}) {
- const [state, dispatch] = useFormState(action, initialState, permalink);
+ const [state, dispatch, isPending] = useFormState(
+ action,
+ initialState,
+ permalink,
+ );
return (
);
}
+
const ClientRef = await clientExports(Client);
const rscStream = ReactServerDOMServer.renderToReadableStream(
@@ -856,8 +890,10 @@ describe('ReactFlightDOMForm', () => {
await readIntoContainer(ssrStream);
const form = container.getElementsByTagName('form')[0];
- const span = container.getElementsByTagName('span')[0];
- expect(span.textContent).toBe('Count: 1');
+ const pendingSpan = container.getElementsByTagName('span')[0];
+ const stateSpan = container.getElementsByTagName('span')[1];
+ expect(pendingSpan.textContent).toBe('');
+ expect(stateSpan.textContent).toBe('Count: 1');
expect(form.action).toBe('http://localhost/permalink');
});
diff --git a/packages/react-server/src/ReactFizzHooks.js b/packages/react-server/src/ReactFizzHooks.js
index 36da5e6539b2a..efcb05bcba941 100644
--- a/packages/react-server/src/ReactFizzHooks.js
+++ b/packages/react-server/src/ReactFizzHooks.js
@@ -615,7 +615,7 @@ function useFormState(
action: (Awaited, P) => S,
initialState: Awaited,
permalink?: string,
-): [Awaited, (P) => void] {
+): [Awaited, (P) => void, boolean] {
resolveCurrentlyRenderingComponent();
// Count the number of useFormState hooks per component. We also use this to
@@ -708,7 +708,7 @@ function useFormState(
};
}
- return [state, dispatch];
+ return [state, dispatch, false];
} else {
// This is not a server action, so the implementation is much simpler.
@@ -718,7 +718,7 @@ function useFormState(
const dispatch = (payload: P): void => {
boundAction(payload);
};
- return [initialState, dispatch];
+ return [initialState, dispatch, false];
}
}