Skip to content

Commit 7cdda4a

Browse files
acdliteAndyPengc12
authored andcommitted
Implement experimental_useFormStatus (facebook#26722)
This hook reads the status of its ancestor form component, if it exists. ```js const {pending, data, action, method} = useFormStatus(); ``` It can be used to implement a loading indicator, for example. You can think of it as a shortcut for implementing a loading state with the useTransition hook. For now, it's only available in the experimental channel. We'll share docs once its closer to being stable. There are additional APIs that will ship alongside it. Internally it's implemented using startTransition + a context object. That's a good way to think about its behavior, but the actual implementation details may change in the future. Because form elements cannot be nested, the implementation in the reconciler does not bother to keep track of multiple nested "transition providers". So although it's implemented using generic Fiber config methods, it does currently make some assumptions based on React DOM's requirements.
1 parent 0b914a4 commit 7cdda4a

23 files changed

+474
-121
lines changed

packages/react-art/src/ReactFiberConfigART.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -479,3 +479,5 @@ export function suspendInstance(type, props) {}
479479
export function waitForCommitToBeReady() {
480480
return null;
481481
}
482+
483+
export const NotPendingTransition = null;

packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@ import type {
1818
} from 'react-reconciler/src/ReactTestSelectors';
1919
import type {ReactScopeInstance} from 'shared/ReactTypes';
2020
import type {AncestorInfoDev} from './validateDOMNesting';
21+
import type {FormStatus} from 'react-dom-bindings/src/shared/ReactDOMFormActions';
2122

23+
import {NotPending} from 'react-dom-bindings/src/shared/ReactDOMFormActions';
2224
import {getCurrentRootHostContainer} from 'react-reconciler/src/ReactFiberHostContext';
2325
import {DefaultEventPriority} from 'react-reconciler/src/ReactEventPriorities';
2426
// TODO: Remove this deep import when we delete the legacy root API
@@ -164,6 +166,8 @@ export type TimeoutHandle = TimeoutID;
164166
export type NoTimeout = -1;
165167
export type RendererInspectionConfig = $ReadOnly<{}>;
166168

169+
export type TransitionStatus = FormStatus;
170+
167171
type SelectionInformation = {
168172
focusedElem: null | HTMLElement,
169173
selectionRange: mixed,
@@ -3448,3 +3452,5 @@ function insertStylesheetIntoRoot(
34483452
}
34493453
resource.state.loading |= Inserted;
34503454
}
3455+
3456+
export const NotPendingTransition: TransitionStatus = NotPending;

packages/react-dom-bindings/src/events/ReactDOMEventReplaying.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -473,7 +473,7 @@ function replayUnblockedFormActions(formReplayingQueue: FormReplayingQueue) {
473473
// We're ready to replay this. Let's delete it from the queue.
474474
formReplayingQueue.splice(i, 3);
475475
i -= 3;
476-
dispatchReplayedFormAction(formInst, submitterOrAction, formData);
476+
dispatchReplayedFormAction(formInst, form, submitterOrAction, formData);
477477
// Continue without incrementing the index.
478478
continue;
479479
}

packages/react-dom-bindings/src/events/plugins/FormActionEventPlugin.js

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import type {DOMEventName} from '../DOMEventNames';
1212
import type {DispatchQueue} from '../DOMPluginEventSystem';
1313
import type {EventSystemFlags} from '../EventSystemFlags';
1414
import type {Fiber} from 'react-reconciler/src/ReactInternalTypes';
15+
import type {FormStatus} from 'react-dom-bindings/src/shared/ReactDOMFormActions';
1516

1617
import {getFiberCurrentPropsFromNode} from '../../client/ReactDOMComponentTree';
1718
import {startHostTransition} from 'react-reconciler/src/ReactFiberReconciler';
@@ -98,7 +99,16 @@ function extractEvents(
9899
formData = new FormData(form);
99100
}
100101

101-
startHostTransition(formInst, action, formData);
102+
const pendingState: FormStatus = {
103+
pending: true,
104+
data: formData,
105+
method: form.method,
106+
action: action,
107+
};
108+
if (__DEV__) {
109+
Object.freeze(pendingState);
110+
}
111+
startHostTransition(formInst, pendingState, action, formData);
102112
}
103113

104114
dispatchQueue.push({
@@ -117,8 +127,18 @@ export {extractEvents};
117127

118128
export function dispatchReplayedFormAction(
119129
formInst: Fiber,
130+
form: HTMLFormElement,
120131
action: FormData => void | Promise<void>,
121132
formData: FormData,
122133
): void {
123-
startHostTransition(formInst, action, formData);
134+
const pendingState: FormStatus = {
135+
pending: true,
136+
data: formData,
137+
method: form.method,
138+
action: action,
139+
};
140+
if (__DEV__) {
141+
Object.freeze(pendingState);
142+
}
143+
startHostTransition(formInst, pendingState, action, formData);
124144
}

packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ import type {
3131
PrecomputedChunk,
3232
} from 'react-server/src/ReactServerStreamConfig';
3333

34+
import type {FormStatus} from '../shared/ReactDOMFormActions';
35+
3436
import {
3537
writeChunk,
3638
writeChunkAndReturn,
@@ -82,6 +84,8 @@ import {
8284
describeDifferencesForPreloadOverImplicitPreload,
8385
} from '../shared/ReactDOMResourceValidation';
8486

87+
import {NotPending} from '../shared/ReactDOMFormActions';
88+
8589
import ReactDOMSharedInternals from 'shared/ReactDOMSharedInternals';
8690
const ReactDOMCurrentDispatcher = ReactDOMSharedInternals.Dispatcher;
8791

@@ -5562,3 +5566,6 @@ function getAsResourceDEV(
55625566
);
55635567
}
55645568
}
5569+
5570+
export type TransitionStatus = FormStatus;
5571+
export const NotPendingTransition: TransitionStatus = NotPending;

packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@ import type {
3131
PrecomputedChunk,
3232
} from 'react-server/src/ReactServerStreamConfig';
3333

34+
import type {FormStatus} from '../shared/ReactDOMFormActions';
35+
36+
import {NotPending} from '../shared/ReactDOMFormActions';
37+
3438
export const isPrimaryRenderer = false;
3539

3640
export type ResponseState = {
@@ -226,3 +230,6 @@ export function writeEndClientRenderedSuspenseBoundary(
226230
}
227231
return writeEndClientRenderedSuspenseBoundaryImpl(destination, responseState);
228232
}
233+
234+
export type TransitionStatus = FormStatus;
235+
export const NotPendingTransition: TransitionStatus = NotPending;
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
* @flow
8+
*/
9+
10+
import type {Dispatcher} from 'react-reconciler/src/ReactInternalTypes';
11+
12+
import {enableAsyncActions, enableFormActions} from 'shared/ReactFeatureFlags';
13+
import ReactSharedInternals from 'shared/ReactSharedInternals';
14+
15+
const ReactCurrentDispatcher = ReactSharedInternals.ReactCurrentDispatcher;
16+
17+
type FormStatusNotPending = {|
18+
pending: false,
19+
data: null,
20+
method: null,
21+
action: null,
22+
|};
23+
24+
type FormStatusPending = {|
25+
pending: true,
26+
data: FormData,
27+
method: string,
28+
action: string | (FormData => void | Promise<void>),
29+
|};
30+
31+
export type FormStatus = FormStatusPending | FormStatusNotPending;
32+
33+
// Since the "not pending" value is always the same, we can reuse the
34+
// same object across all transitions.
35+
const sharedNotPendingObject = {
36+
pending: false,
37+
data: null,
38+
method: null,
39+
action: null,
40+
};
41+
42+
export const NotPending: FormStatus = __DEV__
43+
? Object.freeze(sharedNotPendingObject)
44+
: sharedNotPendingObject;
45+
46+
function resolveDispatcher() {
47+
// Copied from react/src/ReactHooks.js. It's the same thing but in a
48+
// different package.
49+
const dispatcher = ReactCurrentDispatcher.current;
50+
if (__DEV__) {
51+
if (dispatcher === null) {
52+
console.error(
53+
'Invalid hook call. Hooks can only be called inside of the body of a function component. This could happen for' +
54+
' one of the following reasons:\n' +
55+
'1. You might have mismatching versions of React and the renderer (such as React DOM)\n' +
56+
'2. You might be breaking the Rules of Hooks\n' +
57+
'3. You might have more than one copy of React in the same app\n' +
58+
'See https://reactjs.org/link/invalid-hook-call for tips about how to debug and fix this problem.',
59+
);
60+
}
61+
}
62+
// Will result in a null access error if accessed outside render phase. We
63+
// intentionally don't throw our own error because this is in a hot path.
64+
// Also helps ensure this is inlined.
65+
return ((dispatcher: any): Dispatcher);
66+
}
67+
68+
export function useFormStatus(): FormStatus {
69+
if (!(enableFormActions && enableAsyncActions)) {
70+
throw new Error('Not implemented.');
71+
} else {
72+
const dispatcher = resolveDispatcher();
73+
// $FlowFixMe We know this exists because of the feature check above.
74+
return dispatcher.useHostTransitionStatus();
75+
}
76+
}

packages/react-dom/src/ReactDOMFormActions.js

Lines changed: 0 additions & 50 deletions
This file was deleted.

0 commit comments

Comments
 (0)