Skip to content

Commit

Permalink
Client implementation of useFormState (facebook#27278)
Browse files Browse the repository at this point in the history
This implements useFormState in Fiber. (It does not include any
progressive enhancement features; those will be added later.)

useFormState is a hook for tracking state produced by async actions. It
has a signature similar to useReducer, but instead of a reducer, it
accepts an async action function.

```js
async function action(prevState, payload) {
  // ..
}
const [state, dispatch] = useFormState(action, initialState)
```

Calling dispatch runs the async action and updates the state to the
returned value.

Async actions run before React's render cycle, so unlike reducers, they
can contain arbitrary side effects.
  • Loading branch information
acdlite authored and AndyPengc12 committed Apr 15, 2024
1 parent 961c048 commit 8be230d
Show file tree
Hide file tree
Showing 6 changed files with 543 additions and 120 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ export function useFormStatus(): FormStatus {
}

export function useFormState<S, P>(
action: (S, P) => S,
action: (S, P) => Promise<S>,
initialState: S,
url?: string,
): [S, (P) => void] {
Expand Down
130 changes: 124 additions & 6 deletions packages/react-dom/src/__tests__/ReactDOMForm-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ describe('ReactDOMForm', () => {
let ReactDOMClient;
let Scheduler;
let assertLog;
let waitForThrow;
let useState;
let Suspense;
let startTransition;
Expand All @@ -50,6 +51,7 @@ describe('ReactDOMForm', () => {
Scheduler = require('scheduler');
act = require('internal-test-utils').act;
assertLog = require('internal-test-utils').assertLog;
waitForThrow = require('internal-test-utils').waitForThrow;
useState = React.useState;
Suspense = React.Suspense;
startTransition = React.startTransition;
Expand Down Expand Up @@ -974,21 +976,137 @@ describe('ReactDOMForm', () => {

// @gate enableFormActions
// @gate enableAsyncActions
test('useFormState exists', async () => {
// TODO: Not yet implemented. This just tests that the API is wired up.

async function action(state) {
return state;
test('useFormState updates state asynchronously and queues multiple actions', async () => {
let actionCounter = 0;
async function action(state, type) {
actionCounter++;

Scheduler.log(`Async action started [${actionCounter}]`);
await getText(`Wait [${actionCounter}]`);

switch (type) {
case 'increment':
return state + 1;
case 'decrement':
return state - 1;
default:
return state;
}
}

let dispatch;
function App() {
const [state] = useFormState(action, 0);
const [state, _dispatch] = useFormState(action, 0);
dispatch = _dispatch;
return <Text text={state} />;
}

const root = ReactDOMClient.createRoot(container);
await act(() => root.render(<App />));
assertLog([0]);
expect(container.textContent).toBe('0');

await act(() => dispatch('increment'));
assertLog(['Async action started [1]']);
expect(container.textContent).toBe('0');

// Dispatch a few more actions. None of these will start until the previous
// one finishes.
await act(() => dispatch('increment'));
await act(() => dispatch('decrement'));
await act(() => dispatch('increment'));
assertLog([]);

// Each action starts as soon as the previous one finishes.
// NOTE: React does not render in between these actions because they all
// update the same queue, which means they get entangled together. This is
// intentional behavior.
await act(() => resolveText('Wait [1]'));
assertLog(['Async action started [2]']);
await act(() => resolveText('Wait [2]'));
assertLog(['Async action started [3]']);
await act(() => resolveText('Wait [3]'));
assertLog(['Async action started [4]']);
await act(() => resolveText('Wait [4]'));

// Finally the last action finishes and we can render the result.
assertLog([2]);
expect(container.textContent).toBe('2');
});

// @gate enableFormActions
// @gate enableAsyncActions
test('useFormState supports inline actions', async () => {
let increment;
function App({stepSize}) {
const [state, dispatch] = useFormState(async prevState => {
return prevState + stepSize;
}, 0);
increment = dispatch;
return <Text text={state} />;
}

// Initial render
const root = ReactDOMClient.createRoot(container);
await act(() => root.render(<App stepSize={1} />));
assertLog([0]);

// Perform an action. This will increase the state by 1, as defined by the
// stepSize prop.
await act(() => increment());
assertLog([1]);

// Now increase the stepSize prop to 10. Subsequent steps will increase
// by this amount.
await act(() => root.render(<App stepSize={10} />));
assertLog([1]);

// Increment again. The state should increase by 10.
await act(() => increment());
assertLog([11]);
});

// @gate enableFormActions
// @gate enableAsyncActions
test('useFormState: dispatch throws if called during render', async () => {
function App() {
const [state, dispatch] = useFormState(async () => {}, 0);
dispatch();
return <Text text={state} />;
}

const root = ReactDOMClient.createRoot(container);
await act(async () => {
root.render(<App />);
await waitForThrow('Cannot update form state while rendering.');
});
});

// @gate enableFormActions
// @gate enableAsyncActions
test('useFormState: warns if action is not async', async () => {
let dispatch;
function App() {
const [state, _dispatch] = useFormState(() => {}, 0);
dispatch = _dispatch;
return <Text text={state} />;
}

const root = ReactDOMClient.createRoot(container);
await act(async () => {
root.render(<App />);
});
assertLog([0]);

expect(() => {
// This throws because React expects the action to return a promise.
expect(() => dispatch()).toThrow('Cannot read properties of undefined');
}).toErrorDev(
[
// In dev we also log a warning.
'The action passed to useFormState must be an async function',
],
{withoutStack: true},
);
});
});
177 changes: 94 additions & 83 deletions packages/react-reconciler/src/ReactFiberAsyncAction.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,97 +34,108 @@ let currentEntangledPendingCount: number = 0;
let currentEntangledLane: Lane = NoLane;

export function requestAsyncActionContext<S>(
actionReturnValue: mixed,
finishedState: S,
): Thenable<S> | S {
if (
actionReturnValue !== null &&
typeof actionReturnValue === 'object' &&
typeof actionReturnValue.then === 'function'
) {
// This is an async action.
//
// Return a thenable that resolves once the action scope (i.e. the async
// function passed to startTransition) has finished running.
actionReturnValue: Thenable<mixed>,
// If this is provided, this resulting thenable resolves to this value instead
// of the return value of the action. This is a perf trick to avoid composing
// an extra async function.
overrideReturnValue: S | null,
): Thenable<S> {
// This is an async action.
//
// Return a thenable that resolves once the action scope (i.e. the async
// function passed to startTransition) has finished running.

const thenable: Thenable<mixed> = (actionReturnValue: any);
let entangledListeners;
if (currentEntangledListeners === null) {
// There's no outer async action scope. Create a new one.
entangledListeners = currentEntangledListeners = [];
currentEntangledPendingCount = 0;
currentEntangledLane = requestTransitionLane();
} else {
entangledListeners = currentEntangledListeners;
}
const thenable: Thenable<S> = (actionReturnValue: any);
let entangledListeners;
if (currentEntangledListeners === null) {
// There's no outer async action scope. Create a new one.
entangledListeners = currentEntangledListeners = [];
currentEntangledPendingCount = 0;
currentEntangledLane = requestTransitionLane();
} else {
entangledListeners = currentEntangledListeners;
}

currentEntangledPendingCount++;
let resultStatus = 'pending';
let rejectedReason;
thenable.then(
() => {
resultStatus = 'fulfilled';
pingEngtangledActionScope();
},
error => {
resultStatus = 'rejected';
rejectedReason = error;
pingEngtangledActionScope();
},
);
currentEntangledPendingCount++;

// Create a thenable that represents the result of this action, but doesn't
// resolve until the entire entangled scope has finished.
//
// Expressed using promises:
// const [thisResult] = await Promise.all([thisAction, entangledAction]);
// return thisResult;
const resultThenable = createResultThenable<S>(entangledListeners);
// Create a thenable that represents the result of this action, but doesn't
// resolve until the entire entangled scope has finished.
//
// Expressed using promises:
// const [thisResult] = await Promise.all([thisAction, entangledAction]);
// return thisResult;
const resultThenable = createResultThenable<S>(entangledListeners);

// Attach a listener to fill in the result.
entangledListeners.push(() => {
switch (resultStatus) {
case 'fulfilled': {
const fulfilledThenable: FulfilledThenable<S> = (resultThenable: any);
fulfilledThenable.status = 'fulfilled';
fulfilledThenable.value = finishedState;
break;
}
case 'rejected': {
const rejectedThenable: RejectedThenable<S> = (resultThenable: any);
rejectedThenable.status = 'rejected';
rejectedThenable.reason = rejectedReason;
break;
}
case 'pending':
default: {
// The listener above should have been called first, so `resultStatus`
// should already be set to the correct value.
throw new Error(
'Thenable should have already resolved. This ' +
'is a bug in React.',
);
}
}
});
let resultStatus = 'pending';
let resultValue;
let rejectedReason;
thenable.then(
(value: S) => {
resultStatus = 'fulfilled';
resultValue = overrideReturnValue !== null ? overrideReturnValue : value;
pingEngtangledActionScope();
},
error => {
resultStatus = 'rejected';
rejectedReason = error;
pingEngtangledActionScope();
},
);

return resultThenable;
} else {
// This is not an async action, but it may be part of an outer async action.
if (currentEntangledListeners === null) {
return finishedState;
} else {
// Return a thenable that does not resolve until the entangled actions
// have finished.
const entangledListeners = currentEntangledListeners;
const resultThenable = createResultThenable<S>(entangledListeners);
entangledListeners.push(() => {
// Attach a listener to fill in the result.
entangledListeners.push(() => {
switch (resultStatus) {
case 'fulfilled': {
const fulfilledThenable: FulfilledThenable<S> = (resultThenable: any);
fulfilledThenable.status = 'fulfilled';
fulfilledThenable.value = finishedState;
});
return resultThenable;
fulfilledThenable.value = resultValue;
break;
}
case 'rejected': {
const rejectedThenable: RejectedThenable<S> = (resultThenable: any);
rejectedThenable.status = 'rejected';
rejectedThenable.reason = rejectedReason;
break;
}
case 'pending':
default: {
// The listener above should have been called first, so `resultStatus`
// should already be set to the correct value.
throw new Error(
'Thenable should have already resolved. This ' + 'is a bug in React.',
);
}
}
});

return resultThenable;
}

export function requestSyncActionContext<S>(
actionReturnValue: mixed,
// If this is provided, this resulting thenable resolves to this value instead
// of the return value of the action. This is a perf trick to avoid composing
// an extra async function.
overrideReturnValue: S | null,
): Thenable<S> | S {
const resultValue: S =
overrideReturnValue !== null
? overrideReturnValue
: (actionReturnValue: any);
// This is not an async action, but it may be part of an outer async action.
if (currentEntangledListeners === null) {
return resultValue;
} else {
// Return a thenable that does not resolve until the entangled actions
// have finished.
const entangledListeners = currentEntangledListeners;
const resultThenable = createResultThenable<S>(entangledListeners);
entangledListeners.push(() => {
const fulfilledThenable: FulfilledThenable<S> = (resultThenable: any);
fulfilledThenable.status = 'fulfilled';
fulfilledThenable.value = resultValue;
});
return resultThenable;
}
}

Expand Down
Loading

0 comments on commit 8be230d

Please sign in to comment.