Skip to content

Commit

Permalink
Add pending state to useFormState
Browse files Browse the repository at this point in the history
  • Loading branch information
rickhanlonii committed Mar 6, 2024
1 parent 0066e0b commit 97a9ef2
Show file tree
Hide file tree
Showing 9 changed files with 190 additions and 93 deletions.
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

0 comments on commit 97a9ef2

Please sign in to comment.