'use strict';
global.IS_REACT_ACT_ENVIRONMENT = true;
const NativeFormData = global.FormData;
const FormDataPolyfill = function FormData(form) {
const formData = new NativeFormData(form);
const formDataEvent = new Event('formdata', {
bubbles: true,
cancelable: false,
});
formDataEvent.formData = formData;
form.dispatchEvent(formDataEvent);
return formData;
};
NativeFormData.prototype.constructor = FormDataPolyfill;
global.FormData = FormDataPolyfill;
describe('ReactDOMForm', () => {
let act;
let container;
let React;
let ReactDOM;
let ReactDOMClient;
let Scheduler;
let assertLog;
let assertConsoleErrorDev;
let waitForThrow;
let useState;
let Suspense;
let startTransition;
let useTransition;
let use;
let textCache;
let useFormStatus;
let useActionState;
let requestFormReset;
beforeEach(() => {
jest.resetModules();
React = require('react');
ReactDOM = require('react-dom');
ReactDOMClient = require('react-dom/client');
Scheduler = require('scheduler');
act = require('internal-test-utils').act;
assertLog = require('internal-test-utils').assertLog;
waitForThrow = require('internal-test-utils').waitForThrow;
assertConsoleErrorDev =
require('internal-test-utils').assertConsoleErrorDev;
useState = React.useState;
Suspense = React.Suspense;
startTransition = React.startTransition;
useTransition = React.useTransition;
use = React.use;
useFormStatus = ReactDOM.useFormStatus;
requestFormReset = ReactDOM.requestFormReset;
container = document.createElement('div');
document.body.appendChild(container);
textCache = new Map();
if (__VARIANT__) {
const originalConsoleError = console.error;
console.error = (error, ...args) => {
if (
typeof error !== 'string' ||
error.indexOf('ReactDOM.useFormState has been renamed') === -1
) {
originalConsoleError(error, ...args);
}
};
useActionState = ReactDOM.useFormState;
} else {
useActionState = React.useActionState;
}
});
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(text));
}
}
function readText(text) {
const record = textCache.get(text);
if (record !== undefined) {
switch (record.status) {
case 'pending':
Scheduler.log(`Suspend! [${text}]`);
throw record.value;
case 'rejected':
throw record.value;
case 'resolved':
return record.value;
}
} else {
Scheduler.log(`Suspend! [${text}]`);
const thenable = {
pings: [],
then(resolve) {
if (newRecord.status === 'pending') {
thenable.pings.push(resolve);
} else {
Promise.resolve().then(() => resolve(newRecord.value));
}
},
};
const newRecord = {
status: 'pending',
value: thenable,
};
textCache.set(text, newRecord);
throw thenable;
}
}
function getText(text) {
const record = textCache.get(text);
if (record === undefined) {
const thenable = {
pings: [],
then(resolve) {
if (newRecord.status === 'pending') {
thenable.pings.push(resolve);
} else {
Promise.resolve().then(() => resolve(newRecord.value));
}
},
};
const newRecord = {
status: 'pending',
value: thenable,
};
textCache.set(text, newRecord);
return thenable;
} else {
switch (record.status) {
case 'pending':
return record.value;
case 'rejected':
return Promise.reject(record.value);
case 'resolved':
return Promise.resolve(record.value);
}
}
}
function Text({text}) {
Scheduler.log(text);
return text;
}
function AsyncText({text}) {
readText(text);
Scheduler.log(text);
return text;
}
afterEach(() => {
document.body.removeChild(container);
});
async function submit(submitter) {
await act(() => {
const form = submitter.form || submitter;
if (!submitter.form) {
submitter = undefined;
}
const submitEvent = new Event('submit', {
bubbles: true,
cancelable: true,
});
submitEvent.submitter = submitter;
const returnValue = form.dispatchEvent(submitEvent);
if (!returnValue) {
return;
}
const action =
(submitter && submitter.getAttribute('formaction')) || form.action;
if (!/\s*javascript:/i.test(action)) {
throw new Error('Navigate to: ' + action);
}
});
}
it('should allow passing a function to form action', async () => {
const ref = React.createRef();
let foo;
function action(formData) {
foo = formData.get('foo');
}
const root = ReactDOMClient.createRoot(container);
await act(async () => {
root.render(
<form action={action} ref={ref}>
<input type="text" name="foo" defaultValue="bar" />
</form>,
);
});
await submit(ref.current);
expect(foo).toBe('bar');
function action2(formData) {
foo = formData.get('foo') + '2';
}
await act(async () => {
root.render(
<form action={action2} ref={ref}>
<input type="text" name="foo" defaultValue="bar" />
</form>,
);
});
await submit(ref.current);
expect(foo).toBe('bar2');
});
it('should allow passing a function to an input/button formAction', async () => {
const inputRef = React.createRef();
const buttonRef = React.createRef();
let rootActionCalled = false;
let savedTitle = null;
let deletedTitle = null;
function action(formData) {
rootActionCalled = true;
}
function saveItem(formData) {
savedTitle = formData.get('title');
}
function deleteItem(formData) {
deletedTitle = formData.get('title');
}
const root = ReactDOMClient.createRoot(container);
await act(async () => {
root.render(
<form action={action}>
<input type="text" name="title" defaultValue="Hello" />
<input
type="submit"
formAction={saveItem}
value="Save"
ref={inputRef}
/>
<button formAction={deleteItem} ref={buttonRef}>
Delete
</button>
</form>,
);
});
expect(savedTitle).toBe(null);
expect(deletedTitle).toBe(null);
await submit(inputRef.current);
expect(savedTitle).toBe('Hello');
expect(deletedTitle).toBe(null);
savedTitle = null;
await submit(buttonRef.current);
expect(savedTitle).toBe(null);
expect(deletedTitle).toBe('Hello');
deletedTitle = null;
function saveItem2(formData) {
savedTitle = formData.get('title') + '2';
}
function deleteItem2(formData) {
deletedTitle = formData.get('title') + '2';
}
await act(async () => {
root.render(
<form action={action}>
<input type="text" name="title" defaultValue="Hello" />
<input
type="submit"
formAction={saveItem2}
value="Save"
ref={inputRef}
/>
<button formAction={deleteItem2} ref={buttonRef}>
Delete
</button>
</form>,
);
});
expect(savedTitle).toBe(null);
expect(deletedTitle).toBe(null);
await submit(inputRef.current);
expect(savedTitle).toBe('Hello2');
expect(deletedTitle).toBe(null);
savedTitle = null;
await submit(buttonRef.current);
expect(savedTitle).toBe(null);
expect(deletedTitle).toBe('Hello2');
expect(rootActionCalled).toBe(false);
});
it('should allow preventing default to block the action', async () => {
const ref = React.createRef();
let actionCalled = false;
function action(formData) {
actionCalled = true;
}
const root = ReactDOMClient.createRoot(container);
await act(async () => {
root.render(
<form action={action} ref={ref} onSubmit={e => e.preventDefault()}>
<input type="text" name="foo" defaultValue="bar" />
</form>,
);
});
await submit(ref.current);
expect(actionCalled).toBe(false);
});
it('should submit the inner of nested forms', async () => {
const ref = React.createRef();
let data;
function outerAction(formData) {
data = formData.get('data') + 'outer';
}
function innerAction(formData) {
data = formData.get('data') + 'inner';
}
const root = ReactDOMClient.createRoot(container);
await act(async () => {
root.render(
<form action={outerAction}>
<input type="text" name="data" defaultValue="outer" />
<form action={innerAction} ref={ref}>
<input type="text" name="data" defaultValue="inner" />
</form>
</form>,
);
});
assertConsoleErrorDev([
'In HTML, <form> cannot be a descendant of <form>.\n' +
'This will cause a hydration error.\n' +
'\n' +
'> <form action={function outerAction}>\n' +
' <input>\n' +
'> <form action={function innerAction} ref={{current:null}}>\n' +
'\n in form (at **)',
]);
await submit(ref.current);
expect(data).toBe('innerinner');
});
it('should submit once if one root is nested inside the other', async () => {
const ref = React.createRef();
let outerCalled = 0;
let innerCalled = 0;
let bubbledSubmit = false;
function outerAction(formData) {
outerCalled++;
}
function innerAction(formData) {
innerCalled++;
}
const innerContainerRef = React.createRef();
const outerRoot = ReactDOMClient.createRoot(container);
await act(async () => {
outerRoot.render(
<div onSubmit={() => (bubbledSubmit = true)}>
<form action={outerAction}>
<div ref={innerContainerRef} />
</form>
</div>,
);
});
const innerRoot = ReactDOMClient.createRoot(innerContainerRef.current);
await act(async () => {
innerRoot.render(
<form action={innerAction} ref={ref}>
<input type="text" name="data" defaultValue="inner" />
</form>,
);
});
await submit(ref.current);
expect(bubbledSubmit).toBe(true);
expect(outerCalled).toBe(0);
expect(innerCalled).toBe(1);
});
it('should submit once if a portal is nested inside its own root', async () => {
const ref = React.createRef();
let outerCalled = 0;
let innerCalled = 0;
let bubbledSubmit = false;
function outerAction(formData) {
outerCalled++;
}
function innerAction(formData) {
innerCalled++;
}
const innerContainer = document.createElement('div');
const innerContainerRef = React.createRef();
const outerRoot = ReactDOMClient.createRoot(container);
await act(async () => {
outerRoot.render(
<div onSubmit={() => (bubbledSubmit = true)}>
<form action={outerAction}>
<div ref={innerContainerRef} />
{ReactDOM.createPortal(
<form action={innerAction} ref={ref}>
<input type="text" name="data" defaultValue="inner" />
</form>,
innerContainer,
)}
</form>
</div>,
);
});
innerContainerRef.current.appendChild(innerContainer);
await submit(ref.current);
expect(bubbledSubmit).toBe(true);
expect(outerCalled).toBe(0);
expect(innerCalled).toBe(1);
});
it('can read the clicked button in the formdata event', async () => {
const inputRef = React.createRef();
const buttonRef = React.createRef();
const outsideButtonRef = React.createRef();
let button;
let title;
function action(formData) {
button = formData.get('button');
title = formData.get('title');
}
const root = ReactDOMClient.createRoot(container);
await act(async () => {
root.render(
<>
<form action={action}>
<input type="text" name="title" defaultValue="hello" />
<input type="submit" name="button" value="save" />
<input type="submit" name="button" value="delete" ref={inputRef} />
<button name="button" value="edit" ref={buttonRef}>
Edit
</button>
</form>
<form id="form" action={action}>
<input type="text" name="title" defaultValue="hello" />
</form>
<button
form="form"
name="button"
value="outside"
ref={outsideButtonRef}>
Button outside form
</button>
,
</>,
);
});
container.addEventListener('formdata', e => {
if (e.formData.get('button') === 'delete') {
e.formData.delete('title');
}
});
await submit(inputRef.current);
expect(button).toBe('delete');
expect(title).toBe(null);
await submit(buttonRef.current);
expect(button).toBe('edit');
expect(title).toBe('hello');
await submit(outsideButtonRef.current);
expect(button).toBe('outside');
expect(title).toBe('hello');
expect(inputRef.current.getAttribute('type')).toBe('submit');
expect(buttonRef.current.getAttribute('type')).toBe(null);
});
it('excludes the submitter name when the submitter is a function action', async () => {
const inputRef = React.createRef();
const buttonRef = React.createRef();
let button;
function action(formData) {
button = formData.get('button');
}
const root = ReactDOMClient.createRoot(container);
await act(async () => {
root.render(
<form>
<input
type="submit"
name="button"
value="delete"
ref={inputRef}
formAction={action}
/>
<button
name="button"
value="edit"
ref={buttonRef}
formAction={action}>
Edit
</button>
</form>,
);
});
assertConsoleErrorDev([
'Cannot specify a "name" prop for a button that specifies a function as a formAction. ' +
'React needs it to encode which action should be invoked. ' +
'It will get overridden.\n' +
' in input (at **)',
]);
await submit(inputRef.current);
expect(button).toBe(null);
await submit(buttonRef.current);
expect(button).toBe(null);
expect(inputRef.current.getAttribute('type')).toBe('submit');
expect(buttonRef.current.getAttribute('type')).toBe(null);
});
it('allows a non-function formaction to override a function one', async () => {
const ref = React.createRef();
let actionCalled = false;
function action(formData) {
actionCalled = true;
}
const root = ReactDOMClient.createRoot(container);
await act(async () => {
root.render(
<form action={action}>
<input
type="submit"
formAction="http://example.com/submit"
ref={ref}
/>
</form>,
);
});
let nav;
try {
await submit(ref.current);
} catch (x) {
nav = x.message;
}
expect(nav).toBe('Navigate to: http://example.com/submit');
expect(actionCalled).toBe(false);
});
it('allows a non-react html formaction to be invoked', async () => {
let actionCalled = false;
function action(formData) {
actionCalled = true;
}
const root = ReactDOMClient.createRoot(container);
await act(async () => {
root.render(
<form
action={action}
dangerouslySetInnerHTML={{
__html: `
<input
type="submit"
formAction="http://example.com/submit"
/>
`,
}}
/>,
);
});
const node = container.getElementsByTagName('input')[0];
let nav;
try {
await submit(node);
} catch (x) {
nav = x.message;
}
expect(nav).toBe('Navigate to: http://example.com/submit');
expect(actionCalled).toBe(false);
});
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>
</form>
);
}
const root = ReactDOMClient.createRoot(container);
await resolveText('Initial');
await act(() => root.render(<App />));
assertLog(['Initial']);
expect(container.textContent).toBe('Initial');
await submit(formRef.current);
assertLog(['Pending...', 'Suspend! [Updated]', 'Loading...']);
expect(container.textContent).toBe('Pending...Initial');
await act(() => resolveText('Updated'));
assertLog(['Updated']);
expect(container.textContent).toBe('Updated');
});
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>
</form>
);
}
const root = ReactDOMClient.createRoot(container);
await resolveText('Count: 0');
await act(() => root.render(<App />));
assertLog(['Count: 0']);
expect(container.textContent).toBe('Count: 0');
await submit(formRef.current);
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');
await submit(formRef.current);
assertLog(['Pending...', 'Suspend! [Count: 2]', 'Loading...']);
expect(container.textContent).toBe('Pending...Count: 1');
await act(() => resolveText('Count: 2'));
assertLog(['Count: 2']);
expect(container.textContent).toBe('Count: 2');
});
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 (
<form
action={async () => {
Scheduler.log('Async action started');
await getText('Wait');
startTransition(() => setState('Updated'));
}}
ref={formRef}>
<Status />
<Suspense fallback={<Text text="Loading..." />}>
<AsyncText text={state} />
</Suspense>
</form>
);
}
const root = ReactDOMClient.createRoot(container);
await resolveText('Initial');
await act(() => root.render(<App />));
assertLog(['Initial']);
expect(container.textContent).toBe('Initial');
await submit(formRef.current);
assertLog(['Async action started', 'Pending...']);
await act(() => resolveText('Wait'));
assertLog(['Suspend! [Updated]', 'Loading...']);
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 () => {
class ErrorBoundary extends React.Component {
state = {error: null};
static getDerivedStateFromError(error) {
return {error};
}
render() {
if (this.state.error !== null) {
return <Text text={this.state.error.message} />;
}
return this.props.children;
}
}
const formRef = React.createRef();
function App() {
return (
<ErrorBoundary>
<form
action={() => {
throw new Error('Oh no!');
}}
ref={formRef}>
<Text text="Everything is fine" />
</form>
</ErrorBoundary>
);
}
const root = ReactDOMClient.createRoot(container);
await act(() => root.render(<App />));
assertLog(['Everything is fine']);
expect(container.textContent).toBe('Everything is fine');
await submit(formRef.current);
assertLog(['Oh no!', 'Oh no!']);
expect(container.textContent).toBe('Oh no!');
});
it('async errors in form actions can be captured by an error boundary', async () => {
class ErrorBoundary extends React.Component {
state = {error: null};
static getDerivedStateFromError(error) {
return {error};
}
render() {
if (this.state.error !== null) {
return <Text text={this.state.error.message} />;
}
return this.props.children;
}
}
const formRef = React.createRef();
function App() {
return (
<ErrorBoundary>
<form
action={async () => {
Scheduler.log('Async action started');
await getText('Wait');
throw new Error('Oh no!');
}}
ref={formRef}>
<Text text="Everything is fine" />
</form>
</ErrorBoundary>
);
}
const root = ReactDOMClient.createRoot(container);
await act(() => root.render(<App />));
assertLog(['Everything is fine']);
expect(container.textContent).toBe('Everything is fine');
await submit(formRef.current);
assertLog(['Async action started']);
expect(container.textContent).toBe('Everything is fine');
await act(() => resolveText('Wait'));
assertLog(['Oh no!', 'Oh no!']);
expect(container.textContent).toBe('Oh no!');
});
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() {
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 />));
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']);
});
it('should error if submitting a form manually', async () => {
const ref = React.createRef();
let error = null;
let result = null;
function emulateForceSubmit(submitter) {
const form = submitter.form || submitter;
const action =
(submitter && submitter.getAttribute('formaction')) || form.action;
try {
if (!/\s*javascript:/i.test(action)) {
throw new Error('Navigate to: ' + action);
} else {
result = Function(action.slice(11))();
}
} catch (x) {
error = x;
}
}
const root = ReactDOMClient.createRoot(container);
await act(async () => {
root.render(
<form
action={() => {}}
ref={ref}
onSubmit={e => {
e.preventDefault();
emulateForceSubmit(e.target);
}}>
<input type="text" name="foo" defaultValue="bar" />
</form>,
);
});
await submit(ref.current);
expect(result).toBe(null);
expect(error.message).toContain(
'A React form was unexpectedly submitted. If you called form.submit()',
);
});
it('useActionState 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, _dispatch, isPending] = useActionState(action, 0);
dispatch = _dispatch;
const pending = isPending ? 'Pending ' : '';
return <Text text={pending + state} />;
}
const root = ReactDOMClient.createRoot(container);
await act(() => root.render(<App />));
assertLog(['0']);
expect(container.textContent).toBe('0');
await act(() => startTransition(() => dispatch('increment')));
assertLog(['Async action started [1]', 'Pending 0']);
expect(container.textContent).toBe('Pending 0');
await act(() => startTransition(() => dispatch('increment')));
await act(() => startTransition(() => dispatch('decrement')));
await act(() => startTransition(() => dispatch('increment')));
assertLog([]);
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]'));
assertLog(['2']);
expect(container.textContent).toBe('2');
});
it('useActionState supports inline actions', async () => {
let increment;
function App({stepSize}) {
const [state, dispatch, isPending] = useActionState(async prevState => {
return prevState + stepSize;
}, 0);
increment = dispatch;
const pending = isPending ? 'Pending ' : '';
return <Text text={pending + state} />;
}
const root = ReactDOMClient.createRoot(container);
await act(() => root.render(<App stepSize={1} />));
assertLog(['0']);
await act(() => startTransition(() => increment()));
assertLog(['Pending 0', '1']);
await act(() => root.render(<App stepSize={10} />));
assertLog(['1']);
await act(() => startTransition(() => increment()));
assertLog(['Pending 1', '11']);
});
it('useActionState: dispatch throws if called during render', async () => {
function App() {
const [state, dispatch, isPending] = useActionState(async () => {}, 0);
dispatch();
const pending = isPending ? 'Pending ' : '';
return <Text text={pending + state} />;
}
const root = ReactDOMClient.createRoot(container);
await act(async () => {
root.render(<App />);
await waitForThrow('Cannot update form state while rendering.');
});
});
it('useActionState: queues multiple actions and runs them in order', async () => {
let action;
function App() {
const [state, dispatch, isPending] = useActionState(
async (s, a) => await getText(a),
'A',
);
action = dispatch;
const pending = isPending ? 'Pending ' : '';
return <Text text={pending + state} />;
}
const root = ReactDOMClient.createRoot(container);
await act(() => root.render(<App />));
assertLog(['A']);
await act(() => startTransition(() => action('B')));
assertLog(['Pending A']);
await act(() => startTransition(() => action('C')));
await act(() => startTransition(() => action('D')));
assertLog([]);
await act(() => resolveText('B'));
await act(() => resolveText('C'));
await act(() => resolveText('D'));
assertLog(['D']);
expect(container.textContent).toBe('D');
});
it(
'useActionState: when calling a queued action, uses the implementation ' +
'that was current at the time it was dispatched, not the most recent one',
async () => {
let action;
function App({throwIfActionIsDispatched}) {
const [state, dispatch, isPending] = useActionState(async (s, a) => {
if (throwIfActionIsDispatched) {
throw new Error('Oops!');
}
return await getText(a);
}, 'Initial');
action = dispatch;
return <Text text={state + (isPending ? ' (pending)' : '')} />;
}
const root = ReactDOMClient.createRoot(container);
await act(() => root.render(<App throwIfActionIsDispatched={false} />));
assertLog(['Initial']);
await act(() => startTransition(() => action('First action')));
assertLog(['Initial (pending)']);
await act(() => startTransition(() => action('Second action')));
await act(() => root.render(<App throwIfActionIsDispatched={true} />));
assertLog(['Initial (pending)']);
await act(() => resolveText('First action'));
await act(() => resolveText('Second action'));
assertLog(['Second action']);
await expect(
act(() => startTransition(() => action('Third action'))),
).rejects.toThrow('Oops!');
},
);
it('useActionState: works if action is sync', async () => {
let increment;
function App({stepSize}) {
const [state, dispatch, isPending] = useActionState(prevState => {
return prevState + stepSize;
}, 0);
increment = dispatch;
const pending = isPending ? 'Pending ' : '';
return <Text text={pending + state} />;
}
const root = ReactDOMClient.createRoot(container);
await act(() => root.render(<App stepSize={1} />));
assertLog(['0']);
await act(() => startTransition(() => increment()));
assertLog(['Pending 0', '1']);
await act(() => root.render(<App stepSize={10} />));
assertLog(['1']);
await act(() => startTransition(() => increment()));
assertLog(['Pending 1', '11']);
});
it('useActionState: can mix sync and async actions', async () => {
let action;
function App() {
const [state, dispatch, isPending] = useActionState((s, a) => a, 'A');
action = dispatch;
const pending = isPending ? 'Pending ' : '';
return <Text text={pending + state} />;
}
const root = ReactDOMClient.createRoot(container);
await act(() => root.render(<App />));
assertLog(['A']);
await act(() => startTransition(() => action(getText('B'))));
assertLog(['Pending A']);
await act(() => startTransition(() => action('C')));
await act(() => startTransition(() => action(getText('D'))));
await act(() => startTransition(() => action('E')));
assertLog([]);
await act(() => resolveText('B'));
await act(() => resolveText('D'));
assertLog(['E']);
expect(container.textContent).toBe('E');
});
it('useActionState: error handling (sync action)', async () => {
class ErrorBoundary extends React.Component {
state = {error: null};
static getDerivedStateFromError(error) {
return {error};
}
render() {
if (this.state.error !== null) {
return <Text text={'Caught an error: ' + this.state.error.message} />;
}
return this.props.children;
}
}
let action;
function App() {
const [state, dispatch, isPending] = useActionState((s, a) => {
if (a.endsWith('!')) {
throw new Error(a);
}
return a;
}, 'A');
action = dispatch;
const pending = isPending ? 'Pending ' : '';
return <Text text={pending + state} />;
}
const root = ReactDOMClient.createRoot(container);
await act(() =>
root.render(
<ErrorBoundary>
<App />
</ErrorBoundary>,
),
);
assertLog(['A']);
await act(() => startTransition(() => action('Oops!')));
assertLog([
'Pending A',
'Caught an error: Oops!',
'Caught an error: Oops!',
]);
expect(container.textContent).toBe('Caught an error: Oops!');
});
it('useActionState: error handling (async action)', async () => {
class ErrorBoundary extends React.Component {
state = {error: null};
static getDerivedStateFromError(error) {
return {error};
}
render() {
if (this.state.error !== null) {
return <Text text={'Caught an error: ' + this.state.error.message} />;
}
return this.props.children;
}
}
let action;
function App() {
const [state, dispatch, isPending] = useActionState(async (s, a) => {
const text = await getText(a);
if (text.endsWith('!')) {
throw new Error(text);
}
return text;
}, 'A');
action = dispatch;
const pending = isPending ? 'Pending ' : '';
return <Text text={pending + state} />;
}
const root = ReactDOMClient.createRoot(container);
await act(() =>
root.render(
<ErrorBoundary>
<App />
</ErrorBoundary>,
),
);
assertLog(['A']);
await act(() => startTransition(() => action('Oops!')));
assertLog(['Pending A']);
await act(() => resolveText('Oops!'));
assertLog(['Caught an error: Oops!', 'Caught an error: Oops!']);
expect(container.textContent).toBe('Caught an error: Oops!');
});
it('useActionState: when an action errors, subsequent actions are canceled', async () => {
class ErrorBoundary extends React.Component {
state = {error: null};
static getDerivedStateFromError(error) {
return {error};
}
render() {
if (this.state.error !== null) {
return <Text text={'Caught an error: ' + this.state.error.message} />;
}
return this.props.children;
}
}
let action;
function App() {
const [state, dispatch, isPending] = useActionState(async (s, a) => {
Scheduler.log('Start action: ' + a);
const text = await getText(a);
if (text.endsWith('!')) {
throw new Error(text);
}
return text;
}, 'A');
action = dispatch;
const pending = isPending ? 'Pending ' : '';
return <Text text={pending + state} />;
}
const root = ReactDOMClient.createRoot(container);
await act(() =>
root.render(
<ErrorBoundary>
<App />
</ErrorBoundary>,
),
);
assertLog(['A']);
await act(() => startTransition(() => action('Oops!')));
assertLog(['Start action: Oops!', 'Pending A']);
await act(() => startTransition(() => action('Should never run')));
assertLog([]);
await act(() => resolveText('Oops!'));
assertLog(['Caught an error: Oops!', 'Caught an error: Oops!']);
expect(container.textContent).toBe('Caught an error: Oops!');
await act(() =>
startTransition(() => action('This also should never run')),
);
assertLog([]);
expect(container.textContent).toBe('Caught an error: Oops!');
});
it('useActionState works in StrictMode', 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, _dispatch, isPending] = useActionState(action, 0);
dispatch = _dispatch;
const pending = isPending ? 'Pending ' : '';
return <Text text={pending + state} />;
}
const root = ReactDOMClient.createRoot(container);
await act(() =>
root.render(
<React.StrictMode>
<App />
</React.StrictMode>,
),
);
assertLog(['0']);
expect(container.textContent).toBe('0');
await act(() => startTransition(() => dispatch('increment')));
assertLog(['Async action started [1]', 'Pending 0']);
expect(container.textContent).toBe('Pending 0');
await act(() => resolveText('Wait [1]'));
assertLog(['1']);
expect(container.textContent).toBe('1');
});
it('useActionState does not wrap action in a transition unless dispatch is in a transition', async () => {
let dispatch;
function App() {
const [state, _dispatch] = useActionState(() => {
return state + 1;
}, 0);
dispatch = _dispatch;
return <AsyncText text={'Count: ' + state} />;
}
const root = ReactDOMClient.createRoot(container);
await act(() =>
root.render(
<Suspense fallback={<Text text="Loading..." />}>
<App />
</Suspense>,
),
);
assertLog([
'Suspend! [Count: 0]',
'Loading...',
'Suspend! [Count: 0]',
]);
await act(() => resolveText('Count: 0'));
assertLog(['Count: 0']);
await act(() => dispatch());
assertLog([
'Suspend! [Count: 1]',
'Loading...',
'Suspend! [Count: 1]',
]);
expect(container.textContent).toBe('Loading...');
await act(() => resolveText('Count: 1'));
assertLog(['Count: 1']);
expect(container.textContent).toBe('Count: 1');
await act(() => startTransition(() => dispatch()));
assertLog(['Count: 1', 'Suspend! [Count: 2]', 'Loading...']);
expect(container.textContent).toBe('Count: 1');
await act(() => resolveText('Count: 2'));
assertLog(['Count: 2']);
expect(container.textContent).toBe('Count: 2');
});
it('useActionState warns if async action is dispatched outside of a transition', async () => {
let dispatch;
function App() {
const [state, _dispatch] = useActionState(async () => {
return state + 1;
}, 0);
dispatch = _dispatch;
return <AsyncText text={'Count: ' + state} />;
}
const root = ReactDOMClient.createRoot(container);
await act(() => root.render(<App />));
assertLog([
'Suspend! [Count: 0]',
'Suspend! [Count: 0]',
]);
await act(() => resolveText('Count: 0'));
assertLog(['Count: 0']);
await act(() => dispatch());
assertConsoleErrorDev([
[
'An async function with useActionState was called outside of a transition. ' +
'This is likely not what you intended (for example, isPending will not update ' +
'correctly). Either call the returned function inside startTransition, or pass it ' +
'to an `action` or `formAction` prop.',
{withoutStack: true},
],
]);
assertLog([
'Suspend! [Count: 1]',
'Suspend! [Count: 1]',
]);
expect(container.textContent).toBe('Count: 0');
});
it('uncontrolled form inputs are reset after the action completes', async () => {
const formRef = React.createRef();
const inputRef = React.createRef();
const divRef = React.createRef();
function App({promiseForUsername}) {
const username = use(promiseForUsername);
return (
<form
ref={formRef}
action={async formData => {
const rawUsername = formData.get('username');
const normalizedUsername = rawUsername.trim().toLowerCase();
Scheduler.log(`Async action started`);
await getText('Wait');
// Update the app with new data. This is analagous to re-rendering
// from the root with a new RSC payload.
startTransition(() => {
root.render(
<App promiseForUsername={getText(normalizedUsername)} />,
);
});
}}>
<input
ref={inputRef}
text="text"
name="username"
defaultValue={username}
/>
<div ref={divRef}>
<Text text={'Current username: ' + username} />
</div>
</form>
);
}
const root = ReactDOMClient.createRoot(container);
const promiseForInitialUsername = getText('(empty)');
await resolveText('(empty)');
await act(() =>
root.render(<App promiseForUsername={promiseForInitialUsername} />),
);
assertLog(['Current username: (empty)']);
expect(divRef.current.textContent).toEqual('Current username: (empty)');
inputRef.current.value = ' AcdLite ';
await submit(formRef.current);
assertLog(['Async action started']);
expect(inputRef.current.value).toBe(' AcdLite ');
await act(() => resolveText('Wait'));
assertLog([]);
expect(inputRef.current.value).toBe(' AcdLite ');
expect(divRef.current.textContent).toEqual('Current username: (empty)');
await act(() => resolveText('acdlite'));
assertLog(['Current username: acdlite']);
expect(inputRef.current.value).toBe('acdlite');
expect(divRef.current.textContent).toEqual('Current username: acdlite');
});
it('requestFormReset schedules a form reset after transition completes', async () => {
const formRef = React.createRef();
const inputRef = React.createRef();
const divRef = React.createRef();
function App({promiseForUsername}) {
const username = use(promiseForUsername);
return (
<form ref={formRef}>
<input
ref={inputRef}
text="text"
name="username"
defaultValue={username}
/>
<div ref={divRef}>
<Text text={'Current username: ' + username} />
</div>
</form>
);
}
const root = ReactDOMClient.createRoot(container);
const promiseForInitialUsername = getText('(empty)');
await resolveText('(empty)');
await act(() =>
root.render(<App promiseForUsername={promiseForInitialUsername} />),
);
assertLog(['Current username: (empty)']);
expect(divRef.current.textContent).toEqual('Current username: (empty)');
inputRef.current.value = ' AcdLite ';
await act(() => {
startTransition(async () => {
const form = formRef.current;
const formData = new FormData(form);
requestFormReset(form);
const rawUsername = formData.get('username');
const normalizedUsername = rawUsername.trim().toLowerCase();
Scheduler.log(`Async action started`);
await getText('Wait');
startTransition(() => {
root.render(<App promiseForUsername={getText(normalizedUsername)} />);
});
});
});
assertLog(['Async action started']);
expect(inputRef.current.value).toBe(' AcdLite ');
await act(() => resolveText('Wait'));
assertLog([]);
expect(inputRef.current.value).toBe(' AcdLite ');
expect(divRef.current.textContent).toEqual('Current username: (empty)');
await act(() => resolveText('acdlite'));
assertLog(['Current username: acdlite']);
expect(inputRef.current.value).toBe('acdlite');
expect(divRef.current.textContent).toEqual('Current username: acdlite');
});
it('parallel form submissions do not throw', async () => {
const formRef = React.createRef();
let resolve = null;
function App() {
async function submitForm() {
Scheduler.log('Action');
if (!resolve) {
await new Promise(res => {
resolve = res;
});
}
}
return <form ref={formRef} action={submitForm} />;
}
const root = ReactDOMClient.createRoot(container);
await act(() => root.render(<App />));
await act(async () => {
formRef.current.requestSubmit();
});
assertLog(['Action']);
await act(async () => {
formRef.current.requestSubmit();
resolve();
});
assertLog(['Action']);
});
it(
'requestFormReset works with inputs that are not descendants ' +
'of the form element',
async () => {
const formRef = React.createRef();
const inputRef = React.createRef();
const divRef = React.createRef();
function App({promiseForUsername}) {
const username = use(promiseForUsername);
return (
<>
<form id="myform" ref={formRef} />
<input
form="myform"
ref={inputRef}
text="text"
name="username"
defaultValue={username}
/>
<div ref={divRef}>
<Text text={'Current username: ' + username} />
</div>
</>
);
}
const root = ReactDOMClient.createRoot(container);
const promiseForInitialUsername = getText('(empty)');
await resolveText('(empty)');
await act(() =>
root.render(<App promiseForUsername={promiseForInitialUsername} />),
);
assertLog(['Current username: (empty)']);
expect(divRef.current.textContent).toEqual('Current username: (empty)');
inputRef.current.value = ' AcdLite ';
await act(() => {
startTransition(async () => {
const form = formRef.current;
const formData = new FormData(form);
requestFormReset(form);
const rawUsername = formData.get('username');
const normalizedUsername = rawUsername.trim().toLowerCase();
Scheduler.log(`Async action started`);
await getText('Wait');
startTransition(() => {
root.render(
<App promiseForUsername={getText(normalizedUsername)} />,
);
});
});
});
assertLog(['Async action started']);
expect(inputRef.current.value).toBe(' AcdLite ');
await act(() => resolveText('Wait'));
assertLog([]);
expect(inputRef.current.value).toBe(' AcdLite ');
expect(divRef.current.textContent).toEqual('Current username: (empty)');
await act(() => resolveText('acdlite'));
assertLog(['Current username: acdlite']);
expect(inputRef.current.value).toBe('acdlite');
expect(divRef.current.textContent).toEqual('Current username: acdlite');
},
);
it('reset multiple forms in the same transition', async () => {
const formRefA = React.createRef();
const formRefB = React.createRef();
function App({promiseForA, promiseForB}) {
const a = use(promiseForA);
const b = use(promiseForB);
return (
<>
<form ref={formRefA}>
<input type="text" name="inputName" defaultValue={a} />
</form>
<form ref={formRefB}>
<input type="text" name="inputName" defaultValue={b} />
</form>
</>
);
}
const root = ReactDOMClient.createRoot(container);
const initialPromiseForA = getText('A1');
const initialPromiseForB = getText('B1');
await resolveText('A1');
await resolveText('B1');
await act(() =>
root.render(
<App
promiseForA={initialPromiseForA}
promiseForB={initialPromiseForB}
/>,
),
);
formRefA.current.elements.inputName.value = ' A2 ';
formRefB.current.elements.inputName.value = ' B2 ';
await act(() => {
startTransition(async () => {
const currentA = formRefA.current.elements.inputName.value;
const currentB = formRefB.current.elements.inputName.value;
requestFormReset(formRefA.current);
requestFormReset(formRefB.current);
Scheduler.log('Async action started');
await getText('Wait');
const normalizedA = currentA.trim();
const normalizedB = currentB.trim();
startTransition(() => {
root.render(
<App
promiseForA={getText(normalizedA)}
promiseForB={getText(normalizedB)}
/>,
);
});
});
});
assertLog(['Async action started']);
await act(() => resolveText('Wait'));
expect(formRefA.current.elements.inputName.value).toBe(' A2 ');
expect(formRefB.current.elements.inputName.value).toBe(' B2 ');
await act(() => {
resolveText('A2');
resolveText('B2');
});
expect(formRefA.current.elements.inputName.value).toBe('A2');
expect(formRefB.current.elements.inputName.value).toBe('B2');
});
it('requestFormReset throws if the form is not managed by React', async () => {
container.innerHTML = `
<form id="myform">
<input id="input" type="text" name="greeting" />
</form>
`;
const form = document.getElementById('myform');
const input = document.getElementById('input');
input.value = 'Hi!!!!!!!!!!!!!';
expect(() => requestFormReset(form)).toThrow('Invalid form element.');
expect(input.value).toBe('Hi!!!!!!!!!!!!!');
form.reset();
expect(input.value).toBe('');
});
it('requestFormReset throws on a non-form DOM element', async () => {
const root = ReactDOMClient.createRoot(container);
const ref = React.createRef();
await act(() => root.render(<div ref={ref}>Hi</div>));
const div = ref.current;
expect(div.textContent).toBe('Hi');
expect(() => requestFormReset(div)).toThrow('Invalid form element.');
});
it('warns if requestFormReset is called outside of a transition', async () => {
const formRef = React.createRef();
const inputRef = React.createRef();
function App() {
return (
<form ref={formRef}>
<input ref={inputRef} type="text" defaultValue="Initial" />
</form>
);
}
const root = ReactDOMClient.createRoot(container);
await act(() => root.render(<App />));
inputRef.current.value = ' Updated ';
await act(() => {
startTransition(async () => {
Scheduler.log('Action started');
await getText('Wait 1');
Scheduler.log('Request form reset');
requestFormReset(formRef.current);
await getText('Wait 2');
Scheduler.log('Action finished');
});
});
assertLog(['Action started']);
expect(inputRef.current.value).toBe(' Updated ');
await act(() => resolveText('Wait 1'));
assertConsoleErrorDev(
[
'requestFormReset was called outside a transition or action. ' +
'To fix, move to an action, or wrap with startTransition.',
],
{
withoutStack: true,
},
);
assertLog(['Request form reset']);
expect(inputRef.current.value).toBe('Initial');
});
it("regression: submitter's formAction prop is coerced correctly before checking if it exists", async () => {
function App({submitterAction}) {
return (
<form action={() => Scheduler.log('Form action')}>
<button ref={buttonRef} type="submit" formAction={submitterAction} />
</form>
);
}
const buttonRef = React.createRef();
const root = ReactDOMClient.createRoot(container);
await act(() =>
root.render(
<App submitterAction={() => Scheduler.log('Button action')} />,
),
);
await submit(buttonRef.current);
assertLog(['Button action']);
await act(() => root.render(<App submitterAction={null} />));
await submit(buttonRef.current);
assertLog(['Form action']);
await act(() => root.render(<App submitterAction={Symbol()} />));
assertConsoleErrorDev([
'Invalid value for prop `formAction` on <button> tag. ' +
'Either remove it from the element, or pass a string or number value to keep it in the DOM. ' +
'For details, see https://react.dev/link/attribute-behavior \n' +
' in button (at **)\n' +
' in App (at **)',
]);
await submit(buttonRef.current);
assertLog(['Form action']);
await act(() => root.render(<App submitterAction={true} />));
await submit(buttonRef.current);
assertLog(['Form action']);
await act(() => root.render(<App submitterAction="https://react.dev/" />));
await expect(submit(buttonRef.current)).rejects.toThrow(
'Navigate to: https://react.dev/',
);
});
it(
'useFormStatus is activated if startTransition is called ' +
'inside preventDefault-ed submit event',
async () => {
function Output({value}) {
const {pending} = useFormStatus();
return <Text text={pending ? `${value} (pending...)` : value} />;
}
function App({value}) {
const [, startFormTransition] = useTransition();
function onSubmit(event) {
event.preventDefault();
startFormTransition(async () => {
const updatedValue = event.target.elements.search.value;
Scheduler.log('Action started');
await getText('Wait');
Scheduler.log('Action finished');
startTransition(() => root.render(<App value={updatedValue} />));
});
}
return (
<form ref={formRef} onSubmit={onSubmit}>
<input
ref={inputRef}
type="text"
name="search"
defaultValue={value}
/>
<div ref={outputRef}>
<Output value={value} />
</div>
</form>
);
}
const formRef = React.createRef();
const inputRef = React.createRef();
const outputRef = React.createRef();
const root = ReactDOMClient.createRoot(container);
await act(() => root.render(<App value="Initial" />));
assertLog(['Initial']);
inputRef.current.value = 'Updated';
await submit(formRef.current);
assertLog(['Action started', 'Initial (pending...)']);
expect(outputRef.current.textContent).toBe('Initial (pending...)');
inputRef.current.value = 'Updated again after submission';
await act(() => resolveText('Wait'));
assertLog(['Action finished', 'Updated']);
expect(outputRef.current.textContent).toBe('Updated');
expect(inputRef.current.value).toBe('Updated again after submission');
},
);
it('useFormStatus is not activated if startTransition is not called', async () => {
function Output({value}) {
const {pending} = useFormStatus();
return (
<Text
text={
pending
? 'Should be unreachable! This test should never activate the pending state.'
: value
}
/>
);
}
function App({value}) {
async function onSubmit(event) {
event.preventDefault();
const updatedValue = event.target.elements.search.value;
Scheduler.log('Async event handler started');
await getText('Wait');
Scheduler.log('Async event handler finished');
startTransition(() => root.render(<App value={updatedValue} />));
}
return (
<form ref={formRef} onSubmit={onSubmit}>
<input
ref={inputRef}
type="text"
name="search"
defaultValue={value}
/>
<div ref={outputRef}>
<Output value={value} />
</div>
</form>
);
}
const formRef = React.createRef();
const inputRef = React.createRef();
const outputRef = React.createRef();
const root = ReactDOMClient.createRoot(container);
await act(() => root.render(<App value="Initial" />));
assertLog(['Initial']);
inputRef.current.value = 'Updated';
await submit(formRef.current);
assertLog(['Async event handler started']);
expect(outputRef.current.textContent).toBe('Initial');
inputRef.current.value = 'Updated again after submission';
await act(() => resolveText('Wait'));
assertLog(['Async event handler finished', 'Updated']);
expect(outputRef.current.textContent).toBe('Updated');
expect(inputRef.current.value).toBe('Updated again after submission');
});
it('useFormStatus is not activated if event is not preventDefault-ed', async () => {
function Output({value}) {
const {pending} = useFormStatus();
return <Text text={pending ? `${value} (pending...)` : value} />;
}
function App({value}) {
const [, startFormTransition] = useTransition();
function onSubmit(event) {
startFormTransition(async () => {
const updatedValue = event.target.elements.search.value;
Scheduler.log('Action started');
await getText('Wait');
Scheduler.log('Action finished');
startTransition(() => root.render(<App value={updatedValue} />));
});
}
return (
<form ref={formRef} onSubmit={onSubmit}>
<input
ref={inputRef}
type="text"
name="search"
defaultValue={value}
/>
<div ref={outputRef}>
<Output value={value} />
</div>
</form>
);
}
const formRef = React.createRef();
const inputRef = React.createRef();
const outputRef = React.createRef();
const root = ReactDOMClient.createRoot(container);
await act(() => root.render(<App value="Initial" />));
assertLog(['Initial']);
inputRef.current.value = 'Updated';
await expect(submit(formRef.current)).rejects.toThrow(
'Navigate to: http://localhost/',
);
assertLog(['Action started', 'Initial']);
expect(outputRef.current.textContent).toBe('Initial');
});
it('useFormStatus coerces the value of the "action" prop', async () => {
function Status() {
const {pending, action} = useFormStatus();
if (pending) {
Scheduler.log(action);
return 'Pending';
} else {
return 'Not pending';
}
}
function Form({action}) {
const [, startFormTransition] = useTransition();
function onSubmit(event) {
event.preventDefault();
startFormTransition(async () => {});
}
return (
<form ref={formRef} action={action} onSubmit={onSubmit}>
<Status />
</form>
);
}
const formRef = React.createRef();
const root = ReactDOMClient.createRoot(container);
await act(() => root.render(<Form action={Symbol()} />));
assertConsoleErrorDev([
'Invalid value for prop `action` on <form> tag. ' +
'Either remove it from the element, or pass a string or number value to keep it in the DOM. ' +
'For details, see https://react.dev/link/attribute-behavior \n' +
' in form (at **)\n' +
' in Form (at **)',
]);
await submit(formRef.current);
assertLog([null]);
await act(() => root.render(<Form action={true} />));
await submit(formRef.current);
assertLog([null]);
await act(() => root.render(<Form action="https://react.dev" />));
await submit(formRef.current);
assertLog(['https://react.dev']);
const actionFn = () => {};
await act(() => root.render(<Form action={actionFn} />));
await submit(formRef.current);
assertLog([actionFn]);
class MyAction {
toString() {
return 'stringified action';
}
}
await act(() => root.render(<Form action={new MyAction()} />));
await submit(formRef.current);
assertLog(['stringified action']);
});
});