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

Add pending state to useFormState #28514

Merged
merged 1 commit into from
Mar 12, 2024
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
7 changes: 5 additions & 2 deletions packages/react-debug-tools/src/ReactDebugHooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -521,8 +521,9 @@ function useFormState<S, P>(
action: (Awaited<S>, P) => S,
initialState: Awaited<S>,
permalink?: string,
): [Awaited<S>, (P) => void] {
): [Awaited<S>, (P) => void, boolean] {
const hook = nextHook(); // FormState
nextHook(); // PendingState
nextHook(); // ActionQueue
const stackError = new Error();
let value;
Expand Down Expand Up @@ -580,7 +581,9 @@ function useFormState<S, P>(
// value being a Thenable is equivalent to error being not null
// i.e. we only reach this point with Awaited<S>
const state = ((value: any): Awaited<S>);
return [state, (payload: P) => {}];

// TODO: support displaying pending value
return [state, (payload: P) => {}, false];
}

const Dispatcher: DispatcherType = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ export function useFormState<S, P>(
action: (Awaited<S>, P) => S,
initialState: Awaited<S>,
permalink?: string,
): [Awaited<S>, (P) => void] {
): [Awaited<S>, (P) => void, boolean] {
if (!(enableFormActions && enableAsyncActions)) {
throw new Error('Not implemented.');
} else {
Expand Down
2 changes: 1 addition & 1 deletion packages/react-dom/index.experimental.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export function experimental_useFormState<S, P>(
action: (Awaited<S>, P) => S,
initialState: Awaited<S>,
permalink?: string,
): [Awaited<S>, (P) => void] {
): [Awaited<S>, (P) => void, boolean] {
if (__DEV__) {
console.error(
'useFormState is now in canary. Remove the experimental_ prefix. ' +
Expand Down
2 changes: 1 addition & 1 deletion packages/react-dom/server-rendering-stub.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export function experimental_useFormState<S, P>(
action: (Awaited<S>, P) => S,
initialState: Awaited<S>,
permalink?: string,
): [Awaited<S>, (P) => void] {
): [Awaited<S>, (P) => void, boolean] {
if (__DEV__) {
console.error(
'useFormState is now in canary. Remove the experimental_ prefix. ' +
Expand Down
100 changes: 53 additions & 47 deletions packages/react-dom/src/__tests__/ReactDOMForm-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -63,21 +63,6 @@ describe('ReactDOMForm', () => {
textCache = new Map();
});

function resolveText(text) {
const record = textCache.get(text);
if (record === undefined) {
const newRecord = {
status: 'resolved',
value: text,
};
textCache.set(text, newRecord);
} else if (record.status === 'pending') {
const thenable = record.value;
record.status = 'resolved';
record.value = text;
thenable.pings.forEach(t => t());
}
}
function resolveText(text) {
const record = textCache.get(text);
if (record === undefined) {
Expand Down Expand Up @@ -997,19 +982,20 @@ describe('ReactDOMForm', () => {

let dispatch;
function App() {
const [state, _dispatch] = useFormState(action, 0);
const [state, _dispatch, isPending] = useFormState(action, 0);
dispatch = _dispatch;
return <Text text={state} />;
const pending = isPending ? 'Pending ' : '';
return <Text text={pending + state} />;
}

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

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

// Dispatch a few more actions. None of these will start until the previous
// one finishes.
Expand All @@ -1031,7 +1017,7 @@ describe('ReactDOMForm', () => {
await act(() => resolveText('Wait [4]'));

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

Expand All @@ -1040,40 +1026,42 @@ describe('ReactDOMForm', () => {
test('useFormState supports inline actions', async () => {
let increment;
function App({stepSize}) {
const [state, dispatch] = useFormState(async prevState => {
const [state, dispatch, isPending] = useFormState(async prevState => {
return prevState + stepSize;
}, 0);
increment = dispatch;
return <Text text={state} />;
const pending = isPending ? 'Pending ' : '';
return <Text text={pending + state} />;
}

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

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

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

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

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

const root = ReactDOMClient.createRoot(container);
Expand All @@ -1088,21 +1076,25 @@ describe('ReactDOMForm', () => {
test('queues multiple actions and runs them in order', async () => {
let action;
function App() {
const [state, dispatch] = useFormState(
const [state, dispatch, isPending] = useFormState(
async (s, a) => await getText(a),
'A',
);
action = dispatch;
return <Text text={state} />;
const pending = isPending ? 'Pending ' : '';
return <Text text={pending + state} />;
}

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

await act(() => action('B'));
// The first dispatch will update the pending state.
assertLog(['Pending A']);
await act(() => action('C'));
await act(() => action('D'));
assertLog([]);

await act(() => resolveText('B'));
await act(() => resolveText('C'));
Expand All @@ -1117,51 +1109,56 @@ describe('ReactDOMForm', () => {
test('useFormState: works if action is sync', async () => {
let increment;
function App({stepSize}) {
const [state, dispatch] = useFormState(prevState => {
const [state, dispatch, isPending] = useFormState(prevState => {
return prevState + stepSize;
}, 0);
increment = dispatch;
return <Text text={state} />;
const pending = isPending ? 'Pending ' : '';
return <Text text={pending + state} />;
}

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

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

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

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

// @gate enableFormActions
// @gate enableAsyncActions
test('useFormState: can mix sync and async actions', async () => {
let action;
function App() {
const [state, dispatch] = useFormState((s, a) => a, 'A');
const [state, dispatch, isPending] = useFormState((s, a) => a, 'A');
action = dispatch;
return <Text text={state} />;
const pending = isPending ? 'Pending ' : '';
return <Text text={pending + state} />;
}

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

await act(() => action(getText('B')));
// The first dispatch will update the pending state.
assertLog(['Pending A']);
await act(() => action('C'));
await act(() => action(getText('D')));
await act(() => action('E'));
assertLog([]);

await act(() => resolveText('B'));
await act(() => resolveText('D'));
Expand Down Expand Up @@ -1189,14 +1186,15 @@ describe('ReactDOMForm', () => {

let action;
function App() {
const [state, dispatch] = useFormState((s, a) => {
const [state, dispatch, isPending] = useFormState((s, a) => {
if (a.endsWith('!')) {
throw new Error(a);
}
return a;
}, 'A');
action = dispatch;
return <Text text={state} />;
const pending = isPending ? 'Pending ' : '';
return <Text text={pending + state} />;
}

const root = ReactDOMClient.createRoot(container);
Expand All @@ -1210,7 +1208,13 @@ describe('ReactDOMForm', () => {
assertLog(['A']);

await act(() => action('Oops!'));
assertLog(['Caught an error: Oops!', 'Caught an error: Oops!']);
assertLog([
// Action begins, error has not thrown yet.
'Pending A',
// Now the action runs and throws.
'Caught an error: Oops!',
'Caught an error: Oops!',
]);
expect(container.textContent).toBe('Caught an error: Oops!');

// Reset the error boundary
Expand All @@ -1223,7 +1227,7 @@ describe('ReactDOMForm', () => {
action('Oops!');
action('B');
});
assertLog(['B']);
assertLog(['Pending A', 'B']);
expect(container.textContent).toBe('B');
});

Expand All @@ -1247,15 +1251,16 @@ describe('ReactDOMForm', () => {

let action;
function App() {
const [state, dispatch] = useFormState(async (s, a) => {
const [state, dispatch, isPending] = useFormState(async (s, a) => {
const text = await getText(a);
if (text.endsWith('!')) {
throw new Error(text);
}
return text;
}, 'A');
action = dispatch;
return <Text text={state} />;
const pending = isPending ? 'Pending ' : '';
return <Text text={pending + state} />;
}

const root = ReactDOMClient.createRoot(container);
Expand All @@ -1269,7 +1274,8 @@ describe('ReactDOMForm', () => {
assertLog(['A']);

await act(() => action('Oops!'));
assertLog([]);
// The first dispatch will update the pending state.
assertLog(['Pending A']);
await act(() => resolveText('Oops!'));
assertLog(['Caught an error: Oops!', 'Caught an error: Oops!']);
expect(container.textContent).toBe('Caught an error: Oops!');
Expand All @@ -1284,7 +1290,7 @@ describe('ReactDOMForm', () => {
action('Oops!');
action('B');
});
assertLog([]);
assertLog(['Pending A']);
await act(() => resolveText('B'));
assertLog(['B']);
expect(container.textContent).toBe('B');
Expand Down
Loading