@samitouri / QOS-React / commits / 540bab085d

Implement experimental_useFormStatus (#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.

Andrew Clark committed Apr 26, 2023 at 18:19 UTC 540bab085d571789f4562565eebfd0db9f36345c
23 files changed +474 -121
packages/react-art/src/ReactFiberConfigART.js
+2
@@ -479,3 +479,5 @@ export function suspendInstance(type, props) {}
479 export function waitForCommitToBeReady() {
480 return null;
481 }
482 +
483 +export const NotPendingTransition = null;
packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js
+6
@@ -18,7 +18,9 @@ import type {
18 } from 'react-reconciler/src/ReactTestSelectors';
19 import type {ReactScopeInstance} from 'shared/ReactTypes';
20 import type {AncestorInfoDev} from './validateDOMNesting';
21 +import type {FormStatus} from 'react-dom-bindings/src/shared/ReactDOMFormActions';
22
23 +import {NotPending} from 'react-dom-bindings/src/shared/ReactDOMFormActions';
24 import {getCurrentRootHostContainer} from 'react-reconciler/src/ReactFiberHostContext';
25 import {DefaultEventPriority} from 'react-reconciler/src/ReactEventPriorities';
26 // TODO: Remove this deep import when we delete the legacy root API
@@ -164,6 +166,8 @@ export type TimeoutHandle = TimeoutID;
166 export type NoTimeout = -1;
167 export type RendererInspectionConfig = $ReadOnly<{}>;
168
169 +export type TransitionStatus = FormStatus;
170 +
171 type SelectionInformation = {
172 focusedElem: null | HTMLElement,
173 selectionRange: mixed,
@@ -3448,3 +3452,5 @@ function insertStylesheetIntoRoot(
3452 }
3453 resource.state.loading |= Inserted;
3454 }
3455 +
3456 +export const NotPendingTransition: TransitionStatus = NotPending;
packages/react-dom-bindings/src/events/ReactDOMEventReplaying.js
+1 -1
@@ -473,7 +473,7 @@ function replayUnblockedFormActions(formReplayingQueue: FormReplayingQueue) {
473 // We're ready to replay this. Let's delete it from the queue.
474 formReplayingQueue.splice(i, 3);
475 i -= 3;
476 - dispatchReplayedFormAction(formInst, submitterOrAction, formData);
476 + dispatchReplayedFormAction(formInst, form, submitterOrAction, formData);
477 // Continue without incrementing the index.
478 continue;
479 }
packages/react-dom-bindings/src/events/plugins/FormActionEventPlugin.js
+22 -2
@@ -12,6 +12,7 @@ import type {DOMEventName} from '../DOMEventNames';
12 import type {DispatchQueue} from '../DOMPluginEventSystem';
13 import type {EventSystemFlags} from '../EventSystemFlags';
14 import type {Fiber} from 'react-reconciler/src/ReactInternalTypes';
15 +import type {FormStatus} from 'react-dom-bindings/src/shared/ReactDOMFormActions';
16
17 import {getFiberCurrentPropsFromNode} from '../../client/ReactDOMComponentTree';
18 import {startHostTransition} from 'react-reconciler/src/ReactFiberReconciler';
@@ -98,7 +99,16 @@ function extractEvents(
99 formData = new FormData(form);
100 }
101
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);
112 }
113
114 dispatchQueue.push({
@@ -117,8 +127,18 @@ export {extractEvents};
127
128 export function dispatchReplayedFormAction(
129 formInst: Fiber,
130 + form: HTMLFormElement,
131 action: FormData => void | Promise<void>,
132 formData: FormData,
133 ): 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);
144 }
packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js
+7
@@ -31,6 +31,8 @@ import type {
31 PrecomputedChunk,
32 } from 'react-server/src/ReactServerStreamConfig';
33
34 +import type {FormStatus} from '../shared/ReactDOMFormActions';
35 +
36 import {
37 writeChunk,
38 writeChunkAndReturn,
@@ -82,6 +84,8 @@ import {
84 describeDifferencesForPreloadOverImplicitPreload,
85 } from '../shared/ReactDOMResourceValidation';
86
87 +import {NotPending} from '../shared/ReactDOMFormActions';
88 +
89 import ReactDOMSharedInternals from 'shared/ReactDOMSharedInternals';
90 const ReactDOMCurrentDispatcher = ReactDOMSharedInternals.Dispatcher;
91
@@ -5562,3 +5566,6 @@ function getAsResourceDEV(
5566 );
5567 }
5568 }
5569 +
5570 +export type TransitionStatus = FormStatus;
5571 +export const NotPendingTransition: TransitionStatus = NotPending;
packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js
+7
@@ -31,6 +31,10 @@ import type {
31 PrecomputedChunk,
32 } from 'react-server/src/ReactServerStreamConfig';
33
34 +import type {FormStatus} from '../shared/ReactDOMFormActions';
35 +
36 +import {NotPending} from '../shared/ReactDOMFormActions';
37 +
38 export const isPrimaryRenderer = false;
39
40 export type ResponseState = {
@@ -226,3 +230,6 @@ export function writeEndClientRenderedSuspenseBoundary(
230 }
231 return writeEndClientRenderedSuspenseBoundaryImpl(destination, responseState);
232 }
233 +
234 +export type TransitionStatus = FormStatus;
235 +export const NotPendingTransition: TransitionStatus = NotPending;
packages/react-dom-bindings/src/shared/ReactDOMFormActions.js new
+76
@@ -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 deleted
-50
@@ -1,50 +0,0 @@
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 {enableAsyncActions, enableFormActions} from 'shared/ReactFeatureFlags';
11 -
12 -type FormStatusNotPending = {|
13 - pending: false,
14 - data: null,
15 - method: null,
16 - action: null,
17 -|};
18 -
19 -type FormStatusPending = {|
20 - pending: true,
21 - data: FormData,
22 - method: string,
23 - action: string | (FormData => void | Promise<void>),
24 -|};
25 -
26 -export type FormStatus = FormStatusPending | FormStatusNotPending;
27 -
28 -// Since the "not pending" value is always the same, we can reuse the
29 -// same object across all transitions.
30 -const sharedNotPendingObject = {
31 - pending: false,
32 - data: null,
33 - method: null,
34 - action: null,
35 -};
36 -
37 -const NotPending: FormStatus = __DEV__
38 - ? Object.freeze(sharedNotPendingObject)
39 - : sharedNotPendingObject;
40 -
41 -export function useFormStatus(): FormStatus {
42 - if (!(enableFormActions && enableAsyncActions)) {
43 - throw new Error('Not implemented.');
44 - } else {
45 - // TODO: This isn't fully implemented yet but we return a correctly typed
46 - // value so we can test that the API is exposed and gated correctly. The
47 - // real implementation will access the status via the dispatcher.
48 - return NotPending;
49 - }
50 -}
packages/react-dom/src/__tests__/ReactDOMForm-test.js
+72 -14
@@ -647,10 +647,16 @@ describe('ReactDOMForm', () => {
647 it('form actions are transitions', async () => {
648 const formRef = React.createRef();
649
650 + function Status() {
651 + const {pending} = useFormStatus();
652 + return pending ? <Text text="Pending..." /> : null;
653 + }
654 +
655 function App() {
656 const [state, setState] = useState('Initial');
657 return (
658 <form action={() => setState('Updated')} ref={formRef}>
659 + <Status />
660 <Suspense fallback={<Text text="Loading..." />}>
661 <AsyncText text={state} />
662 </Suspense>
@@ -667,8 +673,8 @@ describe('ReactDOMForm', () => {
673 // This should suspend because form actions are implicitly wrapped
674 // in startTransition.
675 await submit(formRef.current);
670 - assertLog(['Suspend! [Updated]', 'Loading...']);
671 - expect(container.textContent).toBe('Initial');
676 + assertLog(['Pending...', 'Suspend! [Updated]', 'Loading...']);
677 + expect(container.textContent).toBe('Pending...Initial');
678
679 await act(() => resolveText('Updated'));
680 assertLog(['Updated']);
@@ -680,10 +686,16 @@ describe('ReactDOMForm', () => {
686 it('multiple form actions', async () => {
687 const formRef = React.createRef();
688
689 + function Status() {
690 + const {pending} = useFormStatus();
691 + return pending ? <Text text="Pending..." /> : null;
692 + }
693 +
694 function App() {
695 const [state, setState] = useState(0);
696 return (
697 <form action={() => setState(n => n + 1)} ref={formRef}>
698 + <Status />
699 <Suspense fallback={<Text text="Loading..." />}>
700 <AsyncText text={'Count: ' + state} />
701 </Suspense>
@@ -699,8 +711,8 @@ describe('ReactDOMForm', () => {
711
712 // Update
713 await submit(formRef.current);
702 - assertLog(['Suspend! [Count: 1]', 'Loading...']);
703 - expect(container.textContent).toBe('Count: 0');
714 + assertLog(['Pending...', 'Suspend! [Count: 1]', 'Loading...']);
715 + expect(container.textContent).toBe('Pending...Count: 0');
716
717 await act(() => resolveText('Count: 1'));
718 assertLog(['Count: 1']);
@@ -708,8 +720,8 @@ describe('ReactDOMForm', () => {
720
721 // Update again
722 await submit(formRef.current);
711 - assertLog(['Suspend! [Count: 2]', 'Loading...']);
712 - expect(container.textContent).toBe('Count: 1');
723 + assertLog(['Pending...', 'Suspend! [Count: 2]', 'Loading...']);
724 + expect(container.textContent).toBe('Pending...Count: 1');
725
726 await act(() => resolveText('Count: 2'));
727 assertLog(['Count: 2']);
@@ -720,6 +732,11 @@ describe('ReactDOMForm', () => {
732 it('form actions can be asynchronous', async () => {
733 const formRef = React.createRef();
734
735 + function Status() {
736 + const {pending} = useFormStatus();
737 + return pending ? <Text text="Pending..." /> : null;
738 + }
739 +
740 function App() {
741 const [state, setState] = useState('Initial');
742 return (
@@ -730,6 +747,7 @@ describe('ReactDOMForm', () => {
747 startTransition(() => setState('Updated'));
748 }}
749 ref={formRef}>
750 + <Status />
751 <Suspense fallback={<Text text="Loading..." />}>
752 <AsyncText text={state} />
753 </Suspense>
@@ -744,11 +762,15 @@ describe('ReactDOMForm', () => {
762 expect(container.textContent).toBe('Initial');
763
764 await submit(formRef.current);
747 - assertLog(['Async action started']);
765 + assertLog(['Async action started', 'Pending...']);
766
767 await act(() => resolveText('Wait'));
768 assertLog(['Suspend! [Updated]', 'Loading...']);
751 - expect(container.textContent).toBe('Initial');
769 + expect(container.textContent).toBe('Pending...Initial');
770 +
771 + await act(() => resolveText('Updated'));
772 + assertLog(['Updated']);
773 + expect(container.textContent).toBe('Updated');
774 });
775
776 it('sync errors in form actions can be captured by an error boundary', async () => {
@@ -851,17 +873,53 @@ describe('ReactDOMForm', () => {
873
874 // @gate enableFormActions
875 // @gate enableAsyncActions
854 - it('useFormStatus exists', async () => {
855 - // This API isn't fully implemented yet. This just tests that it's wired
856 - // up correctly.
876 + it('useFormStatus reads the status of a pending form action', async () => {
877 + const formRef = React.createRef();
878 +
879 + function Status() {
880 + const {pending, data, action, method} = useFormStatus();
881 + if (!pending) {
882 + return <Text text="No pending action" />;
883 + } else {
884 + const foo = data.get('foo');
885 + return (
886 + <Text
887 + text={`Pending action ${action.name}: foo is ${foo}, method is ${method}`}
888 + />
889 + );
890 + }
891 + }
892 +
893 + async function myAction() {
894 + Scheduler.log('Async action started');
895 + await getText('Wait');
896 + Scheduler.log('Async action finished');
897 + }
898
899 function App() {
859 - const {pending} = useFormStatus();
860 - return 'Pending: ' + pending;
900 + return (
901 + <form action={myAction} ref={formRef}>
902 + <input type="text" name="foo" defaultValue="bar" />
903 + <Status />
904 + </form>
905 + );
906 }
907
908 const root = ReactDOMClient.createRoot(container);
909 await act(() => root.render(<App />));
865 - expect(container.textContent).toBe('Pending: false');
910 + assertLog(['No pending action']);
911 + expect(container.textContent).toBe('No pending action');
912 +
913 + await submit(formRef.current);
914 + assertLog([
915 + 'Async action started',
916 + 'Pending action myAction: foo is bar, method is get',
917 + ]);
918 + expect(container.textContent).toBe(
919 + 'Pending action myAction: foo is bar, method is get',
920 + );
921 +
922 + await act(() => resolveText('Wait'));
923 + assertLog(['Async action finished', 'No pending action']);
924 });
925 });
packages/react-dom/src/client/ReactDOM.js
+1 -1
@@ -56,7 +56,7 @@ import {
56 import Internals from '../ReactDOMSharedInternals';
57
58 export {prefetchDNS, preconnect, preload, preinit} from '../ReactDOMFloat';
59 -export {useFormStatus} from '../ReactDOMFormActions';
59 +export {useFormStatus} from 'react-dom-bindings/src/shared/ReactDOMFormActions';
60
61 if (__DEV__) {
62 if (
packages/react-native-renderer/src/ReactFiberConfigFabric.js
+3
@@ -90,6 +90,7 @@ export type UpdatePayload = Object;
90
91 export type TimeoutHandle = TimeoutID;
92 export type NoTimeout = -1;
93 +export type TransitionStatus = mixed;
94
95 export type RendererInspectionConfig = $ReadOnly<{
96 // Deprecated. Replaced with getInspectorDataForViewAtPoint.
@@ -489,3 +490,5 @@ export function suspendInstance(type: Type, props: Props): void {}
490 export function waitForCommitToBeReady(): null {
491 return null;
492 }
493 +
494 +export const NotPendingTransition: TransitionStatus = null;
packages/react-native-renderer/src/ReactFiberConfigNative.js
+3
@@ -43,6 +43,7 @@ export type ChildSet = void; // Unused
43
44 export type TimeoutHandle = TimeoutID;
45 export type NoTimeout = -1;
46 +export type TransitionStatus = mixed;
47
48 export type RendererInspectionConfig = $ReadOnly<{
49 // Deprecated. Replaced with getInspectorDataForViewAtPoint.
@@ -542,3 +543,5 @@ export function suspendInstance(type: Type, props: Props): void {}
543 export function waitForCommitToBeReady(): null {
544 return null;
545 }
546 +
547 +export const NotPendingTransition: TransitionStatus = null;
packages/react-native-renderer/src/server/ReactFizzConfigNative.js
+3
@@ -354,3 +354,6 @@ export function writeResourcesForBoundary(
354 ): boolean {
355 return true;
356 }
357 +
358 +export type TransitionStatus = mixed;
359 +export const NotPendingTransition: TransitionStatus = null;
packages/react-noop-renderer/src/createReactNoop.js
+4
@@ -78,6 +78,8 @@ type SuspenseyCommitSubscription = {
78 commit: null | (() => void),
79 };
80
81 +export type TransitionStatus = mixed;
82 +
83 const NO_CONTEXT = {};
84 const UPPERCASE_CONTEXT = {};
85 const UPDATE_SIGNAL = {};
@@ -629,6 +631,8 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
631 },
632
633 waitForCommitToBeReady,
634 +
635 + NotPendingTransition: (null: TransitionStatus),
636 };
637
638 const hostConfig = useMutation
packages/react-reconciler/src/ReactFiberBeginWork.js
+42 -1
@@ -38,6 +38,8 @@ import type {
38 import type {UpdateQueue} from './ReactFiberClassUpdateQueue';
39 import type {RootState} from './ReactFiberRoot';
40 import type {TracingMarkerInstance} from './ReactFiberTracingMarkerComponent';
41 +import type {TransitionStatus} from './ReactFiberConfig';
42 +import type {Hook} from './ReactFiberHooks';
43
44 import checkPropTypes from 'shared/checkPropTypes';
45 import {
@@ -176,6 +178,7 @@ import {
178 pushHostContext,
179 pushHostContainer,
180 getRootHostContainer,
181 + HostTransitionContext,
182 } from './ReactFiberHostContext';
183 import {
184 suspenseStackCursor,
@@ -1632,11 +1635,49 @@ function updateHostComponent(
1635 //
1636 // Once a fiber is upgraded to be stateful, it remains stateful for the
1637 // rest of its lifetime.
1635 - renderTransitionAwareHostComponentWithHooks(
1638 + const newState = renderTransitionAwareHostComponentWithHooks(
1639 current,
1640 workInProgress,
1641 renderLanes,
1642 );
1643 +
1644 + // If the transition state changed, propagate the change to all the
1645 + // descendents. We use Context as an implementation detail for this.
1646 + //
1647 + // This is intentionally set here instead of pushHostContext because
1648 + // pushHostContext gets called before we process the state hook, to avoid
1649 + // a state mismatch in the event that something suspends.
1650 + //
1651 + // NOTE: This assumes that there cannot be nested transition providers,
1652 + // because the only renderer that implements this feature is React DOM,
1653 + // and forms cannot be nested. If we did support nested providers, then
1654 + // we would need to push a context value even for host fibers that
1655 + // haven't been upgraded yet.
1656 + if (isPrimaryRenderer) {
1657 + HostTransitionContext._currentValue = newState;
1658 + } else {
1659 + HostTransitionContext._currentValue2 = newState;
1660 + }
1661 + if (enableLazyContextPropagation) {
1662 + // In the lazy propagation implementation, we don't scan for matching
1663 + // consumers until something bails out.
1664 + } else {
1665 + if (didReceiveUpdate) {
1666 + if (current !== null) {
1667 + const oldStateHook: Hook = current.memoizedState;
1668 + const oldState: TransitionStatus = oldStateHook.memoizedState;
1669 + // This uses regular equality instead of Object.is because we assume
1670 + // that host transition state doesn't include NaN as a valid type.
1671 + if (oldState !== newState) {
1672 + propagateContextChange(
1673 + workInProgress,
1674 + HostTransitionContext,
1675 + renderLanes,
1676 + );
1677 + }
1678 + }
1679 + }
1680 + }
1681 }
1682 }
1683
packages/react-reconciler/src/ReactFiberHooks.js
+83 -35
@@ -27,7 +27,9 @@ import type {
27 import type {Lanes, Lane} from './ReactFiberLane';
28 import type {HookFlags} from './ReactHookEffectTags';
29 import type {Flags} from './ReactFiberFlags';
30 +import type {TransitionStatus} from './ReactFiberConfig';
31
32 +import {NotPendingTransition as NoPendingHostTransition} from './ReactFiberConfig';
33 import ReactSharedInternals from 'shared/ReactSharedInternals';
34 import {
35 enableDebugTracing,
@@ -146,6 +148,7 @@ import {
148 import type {ThenableState} from './ReactFiberThenable';
149 import type {BatchConfigTransition} from './ReactFiberTracingMarkerComponent';
150 import {requestAsyncActionContext} from './ReactFiberAsyncAction';
151 +import {HostTransitionContext} from './ReactFiberHostContext';
152
153 const {ReactCurrentDispatcher, ReactCurrentBatchConfig} = ReactSharedInternals;
154
@@ -757,9 +760,9 @@ export function renderTransitionAwareHostComponentWithHooks(
760 current: Fiber | null,
761 workInProgress: Fiber,
762 lanes: Lanes,
760 -): boolean {
763 +): TransitionStatus {
764 if (!(enableFormActions && enableAsyncActions)) {
762 - return false;
765 + throw new Error('Not implemented.');
766 }
767 return renderWithHooks(
768 current,
@@ -771,13 +774,19 @@ export function renderTransitionAwareHostComponentWithHooks(
774 );
775 }
776
774 -export function TransitionAwareHostComponent(): boolean {
777 +export function TransitionAwareHostComponent(): TransitionStatus {
778 if (!(enableFormActions && enableAsyncActions)) {
776 - return false;
779 + throw new Error('Not implemented.');
780 }
781 const dispatcher = ReactCurrentDispatcher.current;
779 - const [isPending] = dispatcher.useTransition();
780 - return isPending;
782 + const [maybeThenable] = dispatcher.useState();
783 + if (typeof maybeThenable.then === 'function') {
784 + const thenable: Thenable<TransitionStatus> = (maybeThenable: any);
785 + return useThenable(thenable);
786 + } else {
787 + const status: TransitionStatus = maybeThenable;
788 + return status;
789 + }
790 }
791
792 export function checkDidRenderIdHook(): boolean {
@@ -2517,6 +2526,7 @@ function startTransition<S>(
2526
2527 export function startHostTransition<F>(
2528 formFiber: Fiber,
2529 + pendingState: TransitionStatus,
2530 callback: F => mixed,
2531 formData: F,
2532 ): void {
@@ -2545,46 +2555,32 @@ export function startHostTransition<F>(
2555 // it was stateful all along so we can reuse most of the implementation
2556 // for function components and useTransition.
2557 //
2548 - // Create the initial hooks used by useTransition. This is essentially an
2549 - // inlined version of mountTransition.
2558 + // Create the state hook used by TransitionAwareHostComponent. This is
2559 + // essentially an inlined version of mountState.
2560 const queue: UpdateQueue<
2551 - Thenable<boolean> | boolean,
2552 - Thenable<boolean> | boolean,
2561 + Thenable<TransitionStatus> | TransitionStatus,
2562 + Thenable<TransitionStatus> | TransitionStatus,
2563 > = {
2564 pending: null,
2565 lanes: NoLanes,
2566 dispatch: null,
2567 lastRenderedReducer: basicStateReducer,
2558 - lastRenderedState: false,
2568 + lastRenderedState: NoPendingHostTransition,
2569 };
2570 const stateHook: Hook = {
2561 - memoizedState: false,
2562 - baseState: false,
2571 + memoizedState: NoPendingHostTransition,
2572 + baseState: NoPendingHostTransition,
2573 baseQueue: null,
2574 queue: queue,
2575 next: null,
2576 };
2577
2568 - const dispatch: (Thenable<boolean> | boolean) => void =
2578 + const dispatch: (Thenable<TransitionStatus> | TransitionStatus) => void =
2579 (dispatchSetState.bind(null, formFiber, queue): any);
2580 setPending = queue.dispatch = dispatch;
2581
2572 - // TODO: The only reason this second hook exists is to save a reference to
2573 - // the `dispatch` function. But we already store this on the state hook. So
2574 - // we can cheat and read it from there. Need to make this change to the
2575 - // regular `useTransition` implementation, too.
2576 - const transitionHook: Hook = {
2577 - memoizedState: dispatch,
2578 - baseState: null,
2579 - baseQueue: null,
2580 - queue: null,
2581 - next: null,
2582 - };
2583 -
2584 - stateHook.next = transitionHook;
2585 -
2586 - // Add the initial list of hooks to both fiber alternates. The idea is that
2587 - // the fiber had these hooks all along.
2582 + // Add the state hook to both fiber alternates. The idea is that the fiber
2583 + // had this hook all along.
2584 formFiber.memoizedState = stateHook;
2585 const alternate = formFiber.alternate;
2586 if (alternate !== null) {
@@ -2592,15 +2588,15 @@ export function startHostTransition<F>(
2588 }
2589 } else {
2590 // This fiber was already upgraded to be stateful.
2595 - const transitionHook: Hook = formFiber.memoizedState.next;
2596 - const dispatch: (Thenable<boolean> | boolean) => void =
2597 - transitionHook.memoizedState;
2591 + const stateHook: Hook = formFiber.memoizedState;
2592 + const dispatch: (Thenable<TransitionStatus> | TransitionStatus) => void =
2593 + stateHook.queue.dispatch;
2594 setPending = dispatch;
2595 }
2596
2597 startTransition(
2602 - true,
2603 - false,
2598 + pendingState,
2599 + NoPendingHostTransition,
2600 setPending,
2601 // TODO: We can avoid this extra wrapper, somehow. Figure out layering
2602 // once more of this function is implemented.
@@ -2650,6 +2646,14 @@ function rerenderTransition(): [
2646 return [isPending, start];
2647 }
2648
2649 +function useHostTransitionStatus(): TransitionStatus {
2650 + if (!(enableFormActions && enableAsyncActions)) {
2651 + throw new Error('Not implemented.');
2652 + }
2653 + const status: TransitionStatus | null = readContext(HostTransitionContext);
2654 + return status !== null ? status : NoPendingHostTransition;
2655 +}
2656 +
2657 function mountId(): string {
2658 const hook = mountWorkInProgressHook();
2659
@@ -2977,6 +2981,10 @@ if (enableUseMemoCacheHook) {
2981 if (enableUseEffectEventHook) {
2982 (ContextOnlyDispatcher: Dispatcher).useEffectEvent = throwInvalidHookError;
2983 }
2984 +if (enableFormActions && enableAsyncActions) {
2985 + (ContextOnlyDispatcher: Dispatcher).useHostTransitionStatus =
2986 + throwInvalidHookError;
2987 +}
2988
2989 const HooksDispatcherOnMount: Dispatcher = {
2990 readContext,
@@ -3008,6 +3016,10 @@ if (enableUseMemoCacheHook) {
3016 if (enableUseEffectEventHook) {
3017 (HooksDispatcherOnMount: Dispatcher).useEffectEvent = mountEvent;
3018 }
3019 +if (enableFormActions && enableAsyncActions) {
3020 + (HooksDispatcherOnMount: Dispatcher).useHostTransitionStatus =
3021 + useHostTransitionStatus;
3022 +}
3023 const HooksDispatcherOnUpdate: Dispatcher = {
3024 readContext,
3025
@@ -3038,6 +3050,10 @@ if (enableUseMemoCacheHook) {
3050 if (enableUseEffectEventHook) {
3051 (HooksDispatcherOnUpdate: Dispatcher).useEffectEvent = updateEvent;
3052 }
3053 +if (enableFormActions && enableAsyncActions) {
3054 + (HooksDispatcherOnUpdate: Dispatcher).useHostTransitionStatus =
3055 + useHostTransitionStatus;
3056 +}
3057
3058 const HooksDispatcherOnRerender: Dispatcher = {
3059 readContext,
@@ -3069,6 +3085,10 @@ if (enableUseMemoCacheHook) {
3085 if (enableUseEffectEventHook) {
3086 (HooksDispatcherOnRerender: Dispatcher).useEffectEvent = updateEvent;
3087 }
3088 +if (enableFormActions && enableAsyncActions) {
3089 + (HooksDispatcherOnRerender: Dispatcher).useHostTransitionStatus =
3090 + useHostTransitionStatus;
3091 +}
3092
3093 let HooksDispatcherOnMountInDEV: Dispatcher | null = null;
3094 let HooksDispatcherOnMountWithHookTypesInDEV: Dispatcher | null = null;
@@ -3255,6 +3275,10 @@ if (__DEV__) {
3275 return mountEvent(callback);
3276 };
3277 }
3278 + if (enableFormActions && enableAsyncActions) {
3279 + (HooksDispatcherOnMountInDEV: Dispatcher).useHostTransitionStatus =
3280 + useHostTransitionStatus;
3281 + }
3282
3283 HooksDispatcherOnMountWithHookTypesInDEV = {
3284 readContext<T>(context: ReactContext<T>): T {
@@ -3409,6 +3433,10 @@ if (__DEV__) {
3433 return mountEvent(callback);
3434 };
3435 }
3436 + if (enableFormActions && enableAsyncActions) {
3437 + (HooksDispatcherOnMountWithHookTypesInDEV: Dispatcher).useHostTransitionStatus =
3438 + useHostTransitionStatus;
3439 + }
3440
3441 HooksDispatcherOnUpdateInDEV = {
3442 readContext<T>(context: ReactContext<T>): T {
@@ -3565,6 +3593,10 @@ if (__DEV__) {
3593 return updateEvent(callback);
3594 };
3595 }
3596 + if (enableFormActions && enableAsyncActions) {
3597 + (HooksDispatcherOnUpdateInDEV: Dispatcher).useHostTransitionStatus =
3598 + useHostTransitionStatus;
3599 + }
3600
3601 HooksDispatcherOnRerenderInDEV = {
3602 readContext<T>(context: ReactContext<T>): T {
@@ -3721,6 +3753,10 @@ if (__DEV__) {
3753 return updateEvent(callback);
3754 };
3755 }
3756 + if (enableFormActions && enableAsyncActions) {
3757 + (HooksDispatcherOnRerenderInDEV: Dispatcher).useHostTransitionStatus =
3758 + useHostTransitionStatus;
3759 + }
3760
3761 InvalidNestedHooksDispatcherOnMountInDEV = {
3762 readContext<T>(context: ReactContext<T>): T {
@@ -3899,6 +3935,10 @@ if (__DEV__) {
3935 return mountEvent(callback);
3936 };
3937 }
3938 + if (enableFormActions && enableAsyncActions) {
3939 + (InvalidNestedHooksDispatcherOnMountInDEV: Dispatcher).useHostTransitionStatus =
3940 + useHostTransitionStatus;
3941 + }
3942
3943 InvalidNestedHooksDispatcherOnUpdateInDEV = {
3944 readContext<T>(context: ReactContext<T>): T {
@@ -4080,6 +4120,10 @@ if (__DEV__) {
4120 return updateEvent(callback);
4121 };
4122 }
4123 + if (enableFormActions && enableAsyncActions) {
4124 + (InvalidNestedHooksDispatcherOnUpdateInDEV: Dispatcher).useHostTransitionStatus =
4125 + useHostTransitionStatus;
4126 + }
4127
4128 InvalidNestedHooksDispatcherOnRerenderInDEV = {
4129 readContext<T>(context: ReactContext<T>): T {
@@ -4261,4 +4305,8 @@ if (__DEV__) {
4305 return updateEvent(callback);
4306 };
4307 }
4308 + if (enableFormActions && enableAsyncActions) {
4309 + (InvalidNestedHooksDispatcherOnRerenderInDEV: Dispatcher).useHostTransitionStatus =
4310 + useHostTransitionStatus;
4311 + }
4312 }
packages/react-reconciler/src/ReactFiberHostContext.js
+83 -16
@@ -9,16 +9,52 @@
9
10 import type {Fiber} from './ReactInternalTypes';
11 import type {StackCursor} from './ReactFiberStack';
12 -import type {Container, HostContext} from './ReactFiberConfig';
13 -
14 -import {getChildHostContext, getRootHostContext} from './ReactFiberConfig';
12 +import type {
13 + Container,
14 + HostContext,
15 + TransitionStatus,
16 +} from './ReactFiberConfig';
17 +import type {Hook} from './ReactFiberHooks';
18 +import type {ReactContext} from 'shared/ReactTypes';
19 +
20 +import {
21 + getChildHostContext,
22 + getRootHostContext,
23 + isPrimaryRenderer,
24 +} from './ReactFiberConfig';
25 import {createCursor, push, pop} from './ReactFiberStack';
26 +import {REACT_CONTEXT_TYPE} from 'shared/ReactSymbols';
27 +import {enableAsyncActions, enableFormActions} from 'shared/ReactFeatureFlags';
28
29 const contextStackCursor: StackCursor<HostContext | null> = createCursor(null);
30 const contextFiberStackCursor: StackCursor<Fiber | null> = createCursor(null);
31 const rootInstanceStackCursor: StackCursor<Container | null> =
32 createCursor(null);
33
34 +// Represents the nearest host transition provider (in React DOM, a <form />)
35 +// NOTE: Since forms cannot be nested, and this feature is only implemented by
36 +// React DOM, we don't technically need this to be a stack. It could be a single
37 +// module variable instead.
38 +const hostTransitionProviderCursor: StackCursor<Fiber | null> =
39 + createCursor(null);
40 +
41 +// TODO: This should initialize to NotPendingTransition, a constant
42 +// imported from the fiber config. However, because of a cycle in the module
43 +// graph, that value isn't defined during this module's initialization. I can't
44 +// think of a way to work around this without moving that value out of the
45 +// fiber config. For now, the "no provider" case is handled when reading,
46 +// inside useHostTransitionStatus.
47 +export const HostTransitionContext: ReactContext<TransitionStatus | null> = {
48 + $$typeof: REACT_CONTEXT_TYPE,
49 + _currentValue: null,
50 + _currentValue2: null,
51 + _threadCount: 0,
52 + Provider: (null: any),
53 + Consumer: (null: any),
54 + _defaultValue: (null: any),
55 + _globalName: (null: any),
56 +};
57 +
58 function requiredContext<Value>(c: Value | null): Value {
59 if (__DEV__) {
60 if (c === null) {
@@ -40,6 +76,10 @@ function getRootHostContainer(): Container {
76 return rootInstance;
77 }
78
79 +export function getHostTransitionProvider(): Fiber | null {
80 + return hostTransitionProviderCursor.current;
81 +}
82 +
83 function pushHostContainer(fiber: Fiber, nextRootInstance: Container): void {
84 // Push current root instance onto the stack;
85 // This allows us to reset root when portals are popped.
@@ -72,29 +112,56 @@ function getHostContext(): HostContext {
112 }
113
114 function pushHostContext(fiber: Fiber): void {
115 + if (enableFormActions && enableAsyncActions) {
116 + const stateHook: Hook | null = fiber.memoizedState;
117 + if (stateHook !== null) {
118 + // Only provide context if this fiber has been upgraded by a host
119 + // transition. We use the same optimization for regular host context below.
120 + push(hostTransitionProviderCursor, fiber, fiber);
121 + }
122 + }
123 +
124 const context: HostContext = requiredContext(contextStackCursor.current);
125 const nextContext = getChildHostContext(context, fiber.type);
126
127 // Don't push this Fiber's context unless it's unique.
79 - if (context === nextContext) {
80 - return;
128 + if (context !== nextContext) {
129 + // Track the context and the Fiber that provided it.
130 + // This enables us to pop only Fibers that provide unique contexts.
131 + push(contextFiberStackCursor, fiber, fiber);
132 + push(contextStackCursor, nextContext, fiber);
133 }
82 -
83 - // Track the context and the Fiber that provided it.
84 - // This enables us to pop only Fibers that provide unique contexts.
85 - push(contextFiberStackCursor, fiber, fiber);
86 - push(contextStackCursor, nextContext, fiber);
134 }
135
136 function popHostContext(fiber: Fiber): void {
90 - // Do not pop unless this Fiber provided the current context.
91 - // pushHostContext() only pushes Fibers that provide unique contexts.
92 - if (contextFiberStackCursor.current !== fiber) {
93 - return;
137 + if (contextFiberStackCursor.current === fiber) {
138 + // Do not pop unless this Fiber provided the current context.
139 + // pushHostContext() only pushes Fibers that provide unique contexts.
140 + pop(contextStackCursor, fiber);
141 + pop(contextFiberStackCursor, fiber);
142 }
143
96 - pop(contextStackCursor, fiber);
97 - pop(contextFiberStackCursor, fiber);
144 + if (enableFormActions && enableAsyncActions) {
145 + if (hostTransitionProviderCursor.current === fiber) {
146 + // Do not pop unless this Fiber provided the current context. This is mostly
147 + // a performance optimization, but conveniently it also prevents a potential
148 + // data race where a host provider is upgraded (i.e. memoizedState becomes
149 + // non-null) during a concurrent event. This is a bit of a flaw in the way
150 + // we upgrade host components, but because we're accounting for it here, it
151 + // should be fine.
152 + pop(hostTransitionProviderCursor, fiber);
153 +
154 + // When popping the transition provider, we reset the context value back
155 + // to `null`. We can do this because you're not allowd to nest forms. If
156 + // we allowed for multiple nested host transition providers, then we'd
157 + // need to reset this to the parent provider's status.
158 + if (isPrimaryRenderer) {
159 + HostTransitionContext._currentValue = null;
160 + } else {
161 + HostTransitionContext._currentValue2 = null;
162 + }
163 + }
164 + }
165 }
166
167 export {
packages/react-reconciler/src/ReactFiberNewContext.js
+35
@@ -16,6 +16,8 @@ import type {
16 import type {StackCursor} from './ReactFiberStack';
17 import type {Lanes} from './ReactFiberLane';
18 import type {SharedQueue} from './ReactFiberClassUpdateQueue';
19 +import type {TransitionStatus} from './ReactFiberConfig';
20 +import type {Hook} from './ReactFiberHooks';
21
22 import {isPrimaryRenderer} from './ReactFiberConfig';
23 import {createCursor, push, pop} from './ReactFiberStack';
@@ -43,8 +45,14 @@ import {markWorkInProgressReceivedUpdate} from './ReactFiberBeginWork';
45 import {
46 enableLazyContextPropagation,
47 enableServerContext,
48 + enableFormActions,
49 + enableAsyncActions,
50 } from 'shared/ReactFeatureFlags';
51 import {REACT_SERVER_CONTEXT_DEFAULT_VALUE_NOT_LOADED} from 'shared/ReactSymbols';
52 +import {
53 + getHostTransitionProvider,
54 + HostTransitionContext,
55 +} from './ReactFiberHostContext';
56
57 const valueCursor: StackCursor<mixed> = createCursor(null);
58
@@ -585,6 +593,33 @@ function propagateParentContextChanges(
593 }
594 }
595 }
596 + } else if (
597 + enableFormActions &&
598 + enableAsyncActions &&
599 + parent === getHostTransitionProvider()
600 + ) {
601 + // During a host transition, a host component can act like a context
602 + // provider. E.g. in React DOM, this would be a <form />.
603 + const currentParent = parent.alternate;
604 + if (currentParent === null) {
605 + throw new Error('Should have a current fiber. This is a bug in React.');
606 + }
607 +
608 + const oldStateHook: Hook = currentParent.memoizedState;
609 + const oldState: TransitionStatus = oldStateHook.memoizedState;
610 +
611 + const newStateHook: Hook = parent.memoizedState;
612 + const newState: TransitionStatus = newStateHook.memoizedState;
613 +
614 + // This uses regular equality instead of Object.is because we assume that
615 + // host transition state doesn't include NaN as a valid type.
616 + if (oldState !== newState) {
617 + if (contexts !== null) {
618 + contexts.push(HostTransitionContext);
619 + } else {
620 + contexts = [HostTransitionContext];
621 + }
622 + }
623 }
624 parent = parent.return;
625 }
packages/react-reconciler/src/ReactInternalTypes.js
+4
@@ -29,6 +29,7 @@ import type {
29 TimeoutHandle,
30 NoTimeout,
31 SuspenseInstance,
32 + TransitionStatus,
33 } from './ReactFiberConfig';
34 import type {Cache} from './ReactFiberCacheComponent';
35 import type {
@@ -421,6 +422,9 @@ export type Dispatcher = {
422 useId(): string,
423 useCacheRefresh?: () => <T>(?() => T, ?T) => void,
424 useMemoCache?: (size: number) => Array<any>,
425 + useHostTransitionStatus?: (
426 + initialStatus: TransitionStatus,
427 + ) => TransitionStatus,
428 };
429
430 export type CacheDispatcher = {
packages/react-reconciler/src/forks/ReactFiberConfig.custom.js
+2
@@ -38,6 +38,7 @@ export opaque type ChildSet = mixed; // eslint-disable-line no-undef
38 export opaque type TimeoutHandle = mixed; // eslint-disable-line no-undef
39 export opaque type NoTimeout = mixed; // eslint-disable-line no-undef
40 export opaque type RendererInspectionConfig = mixed; // eslint-disable-line no-undef
41 +export opaque type TransitionStatus = mixed; // eslint-disable-line no-undef
42 export type EventResponder = any;
43
44 export const getPublicInstance = $$$config.getPublicInstance;
@@ -75,6 +76,7 @@ export const preloadInstance = $$$config.preloadInstance;
76 export const startSuspendingCommit = $$$config.startSuspendingCommit;
77 export const suspendInstance = $$$config.suspendInstance;
78 export const waitForCommitToBeReady = $$$config.waitForCommitToBeReady;
79 +export const NotPendingTransition = $$$config.NotPendingTransition;
80
81 // -------------------
82 // Microtasks
packages/react-server/src/ReactFizzHooks.js
+12 -1
@@ -22,17 +22,20 @@ import type {
22 import type {ResponseState} from './ReactFizzConfig';
23 import type {Task} from './ReactFizzServer';
24 import type {ThenableState} from './ReactFizzThenable';
25 +import type {TransitionStatus} from './ReactFizzConfig';
26
27 import {readContext as readContextImpl} from './ReactFizzNewContext';
28 import {getTreeId} from './ReactFizzTreeContext';
29 import {createThenableState, trackUsedThenable} from './ReactFizzThenable';
30
30 -import {makeId} from './ReactFizzConfig';
31 +import {makeId, NotPendingTransition} from './ReactFizzConfig';
32
33 import {
34 enableCache,
35 enableUseEffectEventHook,
36 enableUseMemoCacheHook,
37 + enableAsyncActions,
38 + enableFormActions,
39 } from 'shared/ReactFeatureFlags';
40 import is from 'shared/objectIs';
41 import {
@@ -545,6 +548,11 @@ function useTransition(): [
548 return [false, unsupportedStartTransition];
549 }
550
551 +function useHostTransitionStatus(): TransitionStatus {
552 + resolveCurrentlyRenderingComponent();
553 + return NotPendingTransition;
554 +}
555 +
556 function useId(): string {
557 const task: Task = (currentlyRenderingTask: any);
558 const treeId = getTreeId(task.treeContext);
@@ -641,6 +649,9 @@ if (enableUseEffectEventHook) {
649 if (enableUseMemoCacheHook) {
650 HooksDispatcher.useMemoCache = useMemoCache;
651 }
652 +if (enableFormActions && enableAsyncActions) {
653 + HooksDispatcher.useHostTransitionStatus = useHostTransitionStatus;
654 +}
655
656 export let currentResponseState: null | ResponseState = (null: any);
657 export function setCurrentResponseState(
packages/react-server/src/forks/ReactFizzConfig.custom.js
+3
@@ -24,6 +24,7 @@
24 // really an argument to a top-level wrapping function.
25
26 import type {Request} from 'react-server/src/ReactFizzServer';
27 +import type {TransitionStatus} from 'react-reconciler/src/ReactFiberConfig';
28
29 declare var $$$config: any;
30 export opaque type Destination = mixed; // eslint-disable-line no-undef
@@ -32,6 +33,7 @@ export opaque type Resources = mixed;
33 export opaque type BoundaryResources = mixed;
34 export opaque type FormatContext = mixed;
35 export opaque type SuspenseBoundaryID = mixed;
36 +export type {TransitionStatus};
37
38 export const isPrimaryRenderer = false;
39
@@ -74,6 +76,7 @@ export const writeCompletedBoundaryInstruction =
76 export const writeClientRenderBoundaryInstruction =
77 $$$config.writeClientRenderBoundaryInstruction;
78 export const prepareHostDispatcher = $$$config.prepareHostDispatcher;
79 +export const NotPendingTransition = $$$config.NotPendingTransition;
80
81 // -------------------------
82 // Resources
packages/react-test-renderer/src/ReactFiberConfigTestHost.js
+3
@@ -41,6 +41,7 @@ export type NoTimeout = -1;
41 export type EventResponder = any;
42
43 export type RendererInspectionConfig = $ReadOnly<{}>;
44 +export type TransitionStatus = mixed;
45
46 export * from 'react-reconciler/src/ReactFiberConfigWithNoPersistence';
47 export * from 'react-reconciler/src/ReactFiberConfigWithNoHydration';
@@ -343,3 +344,5 @@ export function suspendInstance(type: Type, props: Props): void {}
344 export function waitForCommitToBeReady(): null {
345 return null;
346 }
347 +
348 +export const NotPendingTransition: TransitionStatus = null;