Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Client implementation of useFormState #27278

Merged
merged 4 commits into from
Aug 28, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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