Skip to content

Commit

Permalink
Implement experimental_useFormStatus
Browse files Browse the repository at this point in the history
This hook reads the status of its ancestor form component, if it exists.

  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.
  • Loading branch information
acdlite committed Apr 26, 2023
1 parent 432bcfb commit cffd0c5
Show file tree
Hide file tree
Showing 12 changed files with 350 additions and 36 deletions.
7 changes: 7 additions & 0 deletions packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ import type {
PrecomputedChunk,
} from 'react-server/src/ReactServerStreamConfig';

import type {FormStatus} from '../shared/ReactDOMFormActions';

import {
writeChunk,
writeChunkAndReturn,
Expand Down Expand Up @@ -82,6 +84,8 @@ import {
describeDifferencesForPreloadOverImplicitPreload,
} from '../shared/ReactDOMResourceValidation';

import {NotPending} from '../shared/ReactDOMFormActions';

import ReactDOMSharedInternals from 'shared/ReactDOMSharedInternals';
const ReactDOMCurrentDispatcher = ReactDOMSharedInternals.Dispatcher;

Expand Down Expand Up @@ -5562,3 +5566,6 @@ function getAsResourceDEV(
);
}
}

export type TransitionStatus = FormStatus;
export const NotPendingTransition: TransitionStatus = NotPending;
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ import type {
PrecomputedChunk,
} from 'react-server/src/ReactServerStreamConfig';

import type {FormStatus} from '../shared/ReactDOMFormActions';

import {NotPending} from '../shared/ReactDOMFormActions';

export const isPrimaryRenderer = false;

export type ResponseState = {
Expand Down Expand Up @@ -226,3 +230,6 @@ export function writeEndClientRenderedSuspenseBoundary(
}
return writeEndClientRenderedSuspenseBoundaryImpl(destination, responseState);
}

export type TransitionStatus = FormStatus;
export const NotPendingTransition: TransitionStatus = NotPending;
34 changes: 30 additions & 4 deletions packages/react-dom-bindings/src/shared/ReactDOMFormActions.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@
* @flow
*/

import type {Dispatcher} from 'react-reconciler/src/ReactInternalTypes';

import {enableAsyncActions, enableFormActions} from 'shared/ReactFeatureFlags';
import ReactSharedInternals from 'shared/ReactSharedInternals';

const ReactCurrentDispatcher = ReactSharedInternals.ReactCurrentDispatcher;

type FormStatusNotPending = {|
pending: false,
Expand Down Expand Up @@ -38,13 +43,34 @@ export const NotPending: FormStatus = __DEV__
? Object.freeze(sharedNotPendingObject)
: sharedNotPendingObject;

function resolveDispatcher() {
// Copied from react/src/ReactHooks.js. It's the same thing but in a
// different package.
const dispatcher = ReactCurrentDispatcher.current;
if (__DEV__) {
if (dispatcher === null) {
console.error(
'Invalid hook call. Hooks can only be called inside of the body of a function component. This could happen for' +
' one of the following reasons:\n' +
'1. You might have mismatching versions of React and the renderer (such as React DOM)\n' +
'2. You might be breaking the Rules of Hooks\n' +
'3. You might have more than one copy of React in the same app\n' +
'See https://reactjs.org/link/invalid-hook-call for tips about how to debug and fix this problem.',
);
}
}
// Will result in a null access error if accessed outside render phase. We
// intentionally don't throw our own error because this is in a hot path.
// Also helps ensure this is inlined.
return ((dispatcher: any): Dispatcher);
}

export function useFormStatus(): FormStatus {
if (!(enableFormActions && enableAsyncActions)) {
throw new Error('Not implemented.');
} else {
// TODO: This isn't fully implemented yet but we return a correctly typed
// value so we can test that the API is exposed and gated correctly. The
// real implementation will access the status via the dispatcher.
return NotPending;
const dispatcher = resolveDispatcher();
// $FlowFixMe We know this exists because of the feature check above.
return dispatcher.useHostTransitionStatus();
}
}
86 changes: 72 additions & 14 deletions packages/react-dom/src/__tests__/ReactDOMForm-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -647,10 +647,16 @@ describe('ReactDOMForm', () => {
it('form actions are transitions', async () => {
const formRef = React.createRef();

function Status() {
const {pending} = useFormStatus();
return pending ? <Text text="Pending..." /> : null;
}

function App() {
const [state, setState] = useState('Initial');
return (
<form action={() => setState('Updated')} ref={formRef}>
<Status />
<Suspense fallback={<Text text="Loading..." />}>
<AsyncText text={state} />
</Suspense>
Expand All @@ -667,8 +673,8 @@ describe('ReactDOMForm', () => {
// This should suspend because form actions are implicitly wrapped
// in startTransition.
await submit(formRef.current);
assertLog(['Suspend! [Updated]', 'Loading...']);
expect(container.textContent).toBe('Initial');
assertLog(['Pending...', 'Suspend! [Updated]', 'Loading...']);
expect(container.textContent).toBe('Pending...Initial');

await act(() => resolveText('Updated'));
assertLog(['Updated']);
Expand All @@ -680,10 +686,16 @@ describe('ReactDOMForm', () => {
it('multiple form actions', async () => {
const formRef = React.createRef();

function Status() {
const {pending} = useFormStatus();
return pending ? <Text text="Pending..." /> : null;
}

function App() {
const [state, setState] = useState(0);
return (
<form action={() => setState(n => n + 1)} ref={formRef}>
<Status />
<Suspense fallback={<Text text="Loading..." />}>
<AsyncText text={'Count: ' + state} />
</Suspense>
Expand All @@ -699,17 +711,17 @@ describe('ReactDOMForm', () => {

// Update
await submit(formRef.current);
assertLog(['Suspend! [Count: 1]', 'Loading...']);
expect(container.textContent).toBe('Count: 0');
assertLog(['Pending...', 'Suspend! [Count: 1]', 'Loading...']);
expect(container.textContent).toBe('Pending...Count: 0');

await act(() => resolveText('Count: 1'));
assertLog(['Count: 1']);
expect(container.textContent).toBe('Count: 1');

// Update again
await submit(formRef.current);
assertLog(['Suspend! [Count: 2]', 'Loading...']);
expect(container.textContent).toBe('Count: 1');
assertLog(['Pending...', 'Suspend! [Count: 2]', 'Loading...']);
expect(container.textContent).toBe('Pending...Count: 1');

await act(() => resolveText('Count: 2'));
assertLog(['Count: 2']);
Expand All @@ -720,6 +732,11 @@ describe('ReactDOMForm', () => {
it('form actions can be asynchronous', async () => {
const formRef = React.createRef();

function Status() {
const {pending} = useFormStatus();
return pending ? <Text text="Pending..." /> : null;
}

function App() {
const [state, setState] = useState('Initial');
return (
Expand All @@ -730,6 +747,7 @@ describe('ReactDOMForm', () => {
startTransition(() => setState('Updated'));
}}
ref={formRef}>
<Status />
<Suspense fallback={<Text text="Loading..." />}>
<AsyncText text={state} />
</Suspense>
Expand All @@ -744,11 +762,15 @@ describe('ReactDOMForm', () => {
expect(container.textContent).toBe('Initial');

await submit(formRef.current);
assertLog(['Async action started']);
assertLog(['Async action started', 'Pending...']);

await act(() => resolveText('Wait'));
assertLog(['Suspend! [Updated]', 'Loading...']);
expect(container.textContent).toBe('Initial');
expect(container.textContent).toBe('Pending...Initial');

await act(() => resolveText('Updated'));
assertLog(['Updated']);
expect(container.textContent).toBe('Updated');
});

it('sync errors in form actions can be captured by an error boundary', async () => {
Expand Down Expand Up @@ -851,17 +873,53 @@ describe('ReactDOMForm', () => {

// @gate enableFormActions
// @gate enableAsyncActions
it('useFormStatus exists', async () => {
// This API isn't fully implemented yet. This just tests that it's wired
// up correctly.
it('useFormStatus reads the status of a pending form action', async () => {
const formRef = React.createRef();

function Status() {
const {pending, data, action, method} = useFormStatus();
if (!pending) {
return <Text text="No pending action" />;
} else {
const foo = data.get('foo');
return (
<Text
text={`Pending action ${action.name}: foo is ${foo}, method is ${method}`}
/>
);
}
}

async function myAction() {
Scheduler.log('Async action started');
await getText('Wait');
Scheduler.log('Async action finished');
}

function App() {
const {pending} = useFormStatus();
return 'Pending: ' + pending;
return (
<form action={myAction} ref={formRef}>
<input type="text" name="foo" defaultValue="bar" />
<Status />
</form>
);
}

const root = ReactDOMClient.createRoot(container);
await act(() => root.render(<App />));
expect(container.textContent).toBe('Pending: false');
assertLog(['No pending action']);
expect(container.textContent).toBe('No pending action');

await submit(formRef.current);
assertLog([
'Async action started',
'Pending action myAction: foo is bar, method is get',
]);
expect(container.textContent).toBe(
'Pending action myAction: foo is bar, method is get',
);

await act(() => resolveText('Wait'));
assertLog(['Async action finished', 'No pending action']);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -354,3 +354,6 @@ export function writeResourcesForBoundary(
): boolean {
return true;
}

export type TransitionStatus = mixed;
export const NotPendingTransition: TransitionStatus = null;
43 changes: 42 additions & 1 deletion packages/react-reconciler/src/ReactFiberBeginWork.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ import type {
import type {UpdateQueue} from './ReactFiberClassUpdateQueue';
import type {RootState} from './ReactFiberRoot';
import type {TracingMarkerInstance} from './ReactFiberTracingMarkerComponent';
import type {TransitionStatus} from './ReactFiberConfig';
import type {Hook} from './ReactFiberHooks';

import checkPropTypes from 'shared/checkPropTypes';
import {
Expand Down Expand Up @@ -176,6 +178,7 @@ import {
pushHostContext,
pushHostContainer,
getRootHostContainer,
HostTransitionContext,
} from './ReactFiberHostContext';
import {
suspenseStackCursor,
Expand Down Expand Up @@ -1632,11 +1635,49 @@ function updateHostComponent(
//
// Once a fiber is upgraded to be stateful, it remains stateful for the
// rest of its lifetime.
renderTransitionAwareHostComponentWithHooks(
const newState = renderTransitionAwareHostComponentWithHooks(
current,
workInProgress,
renderLanes,
);

// If the transition state changed, propagate the change to all the
// descendents. We use Context as an implementation detail for this.
//
// This is intentionally set here instead of pushHostContext because
// pushHostContext gets called before we process the state hook, to avoid
// a state mismatch in the event that something suspends.
//
// NOTE: This assumes that there cannot be nested transition providers,
// because the only renderer that implements this feature is React DOM,
// and forms cannot be nested. If we did support nested providers, then
// we would need to push a context value even for host fibers that
// haven't been upgraded yet.
if (isPrimaryRenderer) {
HostTransitionContext._currentValue = newState;
} else {
HostTransitionContext._currentValue2 = newState;
}
if (enableLazyContextPropagation) {
// In the lazy propagation implementation, we don't scan for matching
// consumers until something bails out.
} else {
if (didReceiveUpdate) {
if (current !== null) {
const oldStateHook: Hook = current.memoizedState;
const oldState: TransitionStatus = oldStateHook.memoizedState;
// This uses regular equality instead of Object.is because we assume
// that host transition state doesn't include NaN as a valid type.
if (oldState !== newState) {
propagateContextChange(
workInProgress,
HostTransitionContext,
renderLanes,
);
}
}
}
}
}
}

Expand Down
Loading

0 comments on commit cffd0c5

Please sign in to comment.