Files
react/packages/react-dom/src/__tests__/ReactDOMForm-test.js
T
Sebastian Markbågeandtjallingt 2d3f81bb6a Format DOM Nesting Warning as Diff View + An Additional Log for Stack Trace (#30302)
Currently we're printing parent stacks at the end of DOM nesting even
with owner stacks enabled. That's because the context of parent tree is
relevant for determining why two things are nested. It might not be
sufficient to see the owner stack alone.

I'm trying to get rid of parent stacks and rely on more of the plain
owner stacks or ideally console.createTask. These are generally better
anyway since the exact line for creating the JSX is available. It also
lets you find a parent stack frame that is most relevant e.g. if it's
hidden inside internals.

For DOM nesting there's really only two stacks that are relevant. The
creation of the parent and the creation of the child. Sometimes they're
close enough to be the same thing. Such as for parents that can't have
text children or when the ancestor is the direct parent created at the
same place (same owner).

Sometimes they're far apart. In this case I add a second console.error
within the context of the ancestor. That way the second stack trace can
be used to read the stack trace for where it was created.

To preserve some parent context I now print the parent stack in a diff
view format using the logic from hydration diffs. This includes some
siblings and props for context.

<img width="756" alt="Screenshot 2024-07-10 at 12 21 38 AM"
src="https://github.com/facebook/react/assets/63648/0843133d-cc7a-4ecc-91c0-f46ae8e99f20">

Text Nodes:

<img width="749" alt="Screenshot 2024-07-10 at 12 37 40 AM"
src="https://github.com/facebook/react/assets/63648/ee377d82-54ee-450a-99d1-fcc3ef290d59">

---------

Co-authored-by: tjallingt <tjallingt@gmail.com>
2024-07-10 12:17:13 -04:00

2242 lines
68 KiB
JavaScript

/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @emails react-core
*/
'use strict';
global.IS_REACT_ACT_ENVIRONMENT = true;
// Our current version of JSDOM doesn't implement the event dispatching
// so we polyfill it.
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);
}
};
// Remove after API is deleted.
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');
// Try updating the action
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;
// Try updating the actions
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 only 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 expect(async () => {
await act(async () => {
// This isn't valid HTML but just in case.
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>,
);
});
}).toErrorDev(
'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 **)' +
(gate(flags => flags.enableOwnerStacks) ? '' : '\n in form (at **)'),
);
await submit(ref.current);
expect(data).toBe('innerinner');
});
it('should only 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(
// Nesting forms isn't valid HTML but just in case.
<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 only 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(
// Nesting forms isn't valid HTML but just in case.
<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 => {
// Process in the formdata event somehow
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');
// Ensure that the type field got correctly restored
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) {
// A function action cannot control the name since it might be controlled by the server
// so we need to make sure it doesn't get into the FormData.
button = formData.get('button');
}
const root = ReactDOMClient.createRoot(container);
await expect(async () => {
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>,
);
});
}).toErrorDev([
'Cannot specify a "name" prop for a button that specifies a function as a formAction.',
]);
await submit(inputRef.current);
expect(button).toBe(null);
await submit(buttonRef.current);
expect(button).toBe(null);
// Ensure that the type field got correctly restored
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);
});
// @gate enableAsyncActions
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');
// This should suspend because form actions are implicitly wrapped
// in startTransition.
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');
});
// @gate enableAsyncActions
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');
// Update
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');
// Update again
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 () => {
if (gate(flags => !flags.enableAsyncActions)) {
// TODO: Uncaught JSDOM errors fail the test after the scope has finished
// so don't work with the `gate` mechanism.
return;
}
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 () => {
if (gate(flags => !flags.enableAsyncActions)) {
// TODO: Uncaught JSDOM errors fail the test after the scope has finished
// so don't work with the `gate` mechanism.
return;
}
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!');
});
// @gate enableAsyncActions
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 {
// eslint-disable-next-line no-new-func
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>,
);
});
// This submits the form, which gets blocked and then resubmitted. It's a somewhat
// common idiom but we don't support this pattern unless it uses requestSubmit().
await submit(ref.current);
expect(result).toBe(null);
expect(error.message).toContain(
'A React form was unexpectedly submitted. If you called form.submit()',
);
});
// @gate enableAsyncActions
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');
// Dispatch a few more actions. None of these will start until the previous
// one finishes.
await act(() => startTransition(() => dispatch('increment')));
await act(() => startTransition(() => dispatch('decrement')));
await act(() => startTransition(() => 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 enableAsyncActions
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} />;
}
// 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(() => startTransition(() => increment()));
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']);
// Increment again. The state should increase by 10.
await act(() => startTransition(() => increment()));
assertLog(['Pending 1', '11']);
});
// @gate enableAsyncActions
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.');
});
});
// @gate enableAsyncActions
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')));
// The first dispatch will update the pending state.
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');
});
// @gate enableAsyncActions
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']);
// Dispatch two actions. The first one is async, so it forces the second
// one into an async queue.
await act(() => startTransition(() => action('First action')));
assertLog(['Initial (pending)']);
// This action won't run until the first one finishes.
await act(() => startTransition(() => action('Second action')));
// While the first action is still pending, update a prop. This causes the
// inline action implementation to change, but it should not affect the
// behavior of the action that is already queued.
await act(() => root.render(<App throwIfActionIsDispatched={true} />));
assertLog(['Initial (pending)']);
// Finish both of the actions.
await act(() => resolveText('First action'));
await act(() => resolveText('Second action'));
assertLog(['Second action']);
// Confirm that if we dispatch yet another action, it uses the updated
// action implementation.
await expect(
act(() => startTransition(() => action('Third action'))),
).rejects.toThrow('Oops!');
},
);
// @gate enableAsyncActions
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} />;
}
// 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(() => startTransition(() => increment()));
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']);
// Increment again. The state should increase by 10.
await act(() => startTransition(() => increment()));
assertLog(['Pending 1', '11']);
});
// @gate enableAsyncActions
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'))));
// The first dispatch will update the pending state.
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');
});
// @gate enableAsyncActions
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([
// 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!');
});
// @gate enableAsyncActions
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!')));
// 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!');
});
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']);
// Queue up another action after the one will error.
await act(() => startTransition(() => action('Should never run')));
assertLog([]);
// The first dispatch will update the pending state.
await act(() => resolveText('Oops!'));
assertLog(['Caught an error: Oops!', 'Caught an error: Oops!']);
expect(container.textContent).toBe('Caught an error: Oops!');
// Attempt to dispatch another action. This should not run either.
await act(() =>
startTransition(() => action('This also should never run')),
);
assertLog([]);
expect(container.textContent).toBe('Caught an error: Oops!');
});
// @gate enableAsyncActions
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...']);
await act(() => resolveText('Count: 0'));
assertLog(['Count: 0']);
// Dispatch outside of a transition. This will trigger a loading state.
await act(() => dispatch());
assertLog(['Suspend! [Count: 1]', 'Loading...']);
expect(container.textContent).toBe('Loading...');
await act(() => resolveText('Count: 1'));
assertLog(['Count: 1']);
expect(container.textContent).toBe('Count: 1');
// Now dispatch inside of a transition. This one does not trigger a
// loading state.
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]']);
await act(() => resolveText('Count: 0'));
assertLog(['Count: 0']);
// Dispatch outside of a transition.
await act(() => dispatch());
assertConsoleErrorDev([
[
'An async function was passed to useActionState, but it was ' +
'dispatched outside of an action context',
{withoutStack: true},
],
]);
assertLog(['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}) {
// Make this suspensey to simulate RSC streaming.
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>
);
}
// Initial render
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)');
// Dirty the uncontrolled input
inputRef.current.value = ' AcdLite ';
// Submit the form. This will trigger an async action.
await submit(formRef.current);
assertLog(['Async action started']);
expect(inputRef.current.value).toBe(' AcdLite ');
// Finish the async action. This will trigger a re-render from the root with
// new data from the "server", which suspends.
//
// The form should not reset yet because we need to update `defaultValue`
// first. So we wait for the render to complete.
await act(() => resolveText('Wait'));
assertLog([]);
// The DOM input is still dirty.
expect(inputRef.current.value).toBe(' AcdLite ');
// The React tree is suspended.
expect(divRef.current.textContent).toEqual('Current username: (empty)');
// Unsuspend and finish rendering. Now the form should be reset.
await act(() => resolveText('acdlite'));
assertLog(['Current username: acdlite']);
// The form was reset to the new value from the server.
expect(inputRef.current.value).toBe('acdlite');
expect(divRef.current.textContent).toEqual('Current username: acdlite');
});
it('requestFormReset schedules a form reset after transition completes', async () => {
// This is the same as the previous test, except the form is updated with
// a userspace action instead of a built-in form action.
const formRef = React.createRef();
const inputRef = React.createRef();
const divRef = React.createRef();
function App({promiseForUsername}) {
// Make this suspensey to simulate RSC streaming.
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>
);
}
// Initial render
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)');
// Dirty the uncontrolled input
inputRef.current.value = ' AcdLite ';
// This is a userspace action. It does not trigger a real form submission.
// The practical use case is implementing a custom action prop using
// onSubmit without losing the built-in form resetting behavior.
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');
// 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)} />);
});
});
});
assertLog(['Async action started']);
expect(inputRef.current.value).toBe(' AcdLite ');
// Finish the async action. This will trigger a re-render from the root with
// new data from the "server", which suspends.
//
// The form should not reset yet because we need to update `defaultValue`
// first. So we wait for the render to complete.
await act(() => resolveText('Wait'));
assertLog([]);
// The DOM input is still dirty.
expect(inputRef.current.value).toBe(' AcdLite ');
// The React tree is suspended.
expect(divRef.current.textContent).toEqual('Current username: (empty)');
// Unsuspend and finish rendering. Now the form should be reset.
await act(() => resolveText('acdlite'));
assertLog(['Current username: acdlite']);
// The form was reset to the new value from the server.
expect(inputRef.current.value).toBe('acdlite');
expect(divRef.current.textContent).toEqual('Current username: acdlite');
});
it(
'requestFormReset works with inputs that are not descendants ' +
'of the form element',
async () => {
// This is the same as the previous test, except the input is not a child
// of the form; it's linked with <input form="myform" />
const formRef = React.createRef();
const inputRef = React.createRef();
const divRef = React.createRef();
function App({promiseForUsername}) {
// Make this suspensey to simulate RSC streaming.
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>
</>
);
}
// Initial render
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)');
// Dirty the uncontrolled input
inputRef.current.value = ' AcdLite ';
// This is a userspace action. It does not trigger a real form submission.
// The practical use case is implementing a custom action prop using
// onSubmit without losing the built-in form resetting behavior.
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');
// 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)} />,
);
});
});
});
assertLog(['Async action started']);
expect(inputRef.current.value).toBe(' AcdLite ');
// Finish the async action. This will trigger a re-render from the root with
// new data from the "server", which suspends.
//
// The form should not reset yet because we need to update `defaultValue`
// first. So we wait for the render to complete.
await act(() => resolveText('Wait'));
assertLog([]);
// The DOM input is still dirty.
expect(inputRef.current.value).toBe(' AcdLite ');
// The React tree is suspended.
expect(divRef.current.textContent).toEqual('Current username: (empty)');
// Unsuspend and finish rendering. Now the form should be reset.
await act(() => resolveText('acdlite'));
assertLog(['Current username: acdlite']);
// The form was reset to the new value from the server.
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}) {
// Make these suspensey to simulate RSC streaming.
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}
/>,
),
);
// Dirty the uncontrolled inputs
formRefA.current.elements.inputName.value = ' A2 ';
formRefB.current.elements.inputName.value = ' B2 ';
// Trigger an async action that updates and reset both forms.
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');
// Pretend the server did something with the data.
const normalizedA = currentA.trim();
const normalizedB = currentB.trim();
// Update the app with new data. This is analagous to re-rendering
// from the root with a new RSC payload.
startTransition(() => {
root.render(
<App
promiseForA={getText(normalizedA)}
promiseForB={getText(normalizedB)}
/>,
);
});
});
});
assertLog(['Async action started']);
// Finish the async action. This will trigger a re-render from the root with
// new data from the "server", which suspends.
//
// The forms should not reset yet because we need to update `defaultValue`
// first. So we wait for the render to complete.
await act(() => resolveText('Wait'));
// The DOM inputs are still dirty.
expect(formRefA.current.elements.inputName.value).toBe(' A2 ');
expect(formRefB.current.elements.inputName.value).toBe(' B2 ');
// Unsuspend and finish rendering. Now the forms should be reset.
await act(() => {
resolveText('A2');
resolveText('B2');
});
// The forms were reset to the new value from the server.
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.');
// The form was not reset.
expect(input.value).toBe('Hi!!!!!!!!!!!!!');
// Just confirming a regular form reset works fine.
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 />));
// Dirty the uncontrolled input
inputRef.current.value = ' Updated ';
// Trigger an async action that updates and reset both forms.
await act(() => {
startTransition(async () => {
Scheduler.log('Action started');
await getText('Wait 1');
Scheduler.log('Request form reset');
// This happens after an `await`, and is not wrapped in startTransition,
// so it will be scheduled synchronously instead of with the transition.
// This is almost certainly a mistake, so we log a warning in dev.
requestFormReset(formRef.current);
await getText('Wait 2');
Scheduler.log('Action finished');
});
});
assertLog(['Action started']);
expect(inputRef.current.value).toBe(' Updated ');
// This triggers a synchronous requestFormReset, and a warning
await expect(async () => {
await act(() => resolveText('Wait 1'));
}).toErrorDev(['requestFormReset was called outside a transition'], {
withoutStack: true,
});
assertLog(['Request form reset']);
// The form was reset even though the action didn't finish.
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']);
// When there's no button action, the form action should fire
await act(() => root.render(<App submitterAction={null} />));
await submit(buttonRef.current);
assertLog(['Form action']);
// Symbols are coerced to null, so this should fire the form action
await act(() => root.render(<App submitterAction={Symbol()} />));
assertConsoleErrorDev(['Invalid value for prop `formAction`']);
await submit(buttonRef.current);
assertLog(['Form action']);
// Booleans are coerced to null, so this should fire the form action
await act(() => root.render(<App submitterAction={true} />));
await submit(buttonRef.current);
assertLog(['Form action']);
// A string on the submitter should prevent the form action from firing
// and trigger the native behavior
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']);
// Update the input to something different
inputRef.current.value = 'Updated';
// Submit the form.
await submit(formRef.current);
// The form switches into a pending state.
assertLog(['Action started', 'Initial (pending...)']);
expect(outputRef.current.textContent).toBe('Initial (pending...)');
// While the submission is still pending, update the input again so we
// can check whether the form is reset after the action finishes.
inputRef.current.value = 'Updated again after submission';
// Resolve the async action
await act(() => resolveText('Wait'));
assertLog(['Action finished', 'Updated']);
expect(outputRef.current.textContent).toBe('Updated');
// Confirm that the form was not automatically reset (should call
// requestFormReset(formRef.current) to opt into this behavior)
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']);
// Update the input to something different
inputRef.current.value = 'Updated';
// Submit the form.
await submit(formRef.current);
// Unlike the previous test, which uses startTransition to manually dispatch
// an action, this test uses a regular event handler, so useFormStatus is
// not activated.
assertLog(['Async event handler started']);
expect(outputRef.current.textContent).toBe('Initial');
// While the submission is still pending, update the input again so we
// can check whether the form is reset after the action finishes.
inputRef.current.value = 'Updated again after submission';
// Resolve the async action
await act(() => resolveText('Wait'));
assertLog(['Async event handler finished', 'Updated']);
expect(outputRef.current.textContent).toBe('Updated');
// Confirm that the form was not automatically reset (should call
// requestFormReset(formRef.current) to opt into this behavior)
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) {
// This event is not preventDefault-ed, so the default form submission
// happens, and useFormStatus is not activated.
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']);
// Update the input to something different
inputRef.current.value = 'Updated';
// Submitting the form should trigger the default navigation behavior
await expect(submit(formRef.current)).rejects.toThrow(
'Navigate to: http://localhost/',
);
// The useFormStatus hook was not activated
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();
// Schedule an empty action for no other purpose than to trigger the
// pending state.
startFormTransition(async () => {});
}
return (
<form ref={formRef} action={action} onSubmit={onSubmit}>
<Status />
</form>
);
}
const formRef = React.createRef();
const root = ReactDOMClient.createRoot(container);
// Symbols are coerced to null
await act(() => root.render(<Form action={Symbol()} />));
assertConsoleErrorDev(['Invalid value for prop `action`']);
await submit(formRef.current);
assertLog([null]);
// Booleans are coerced to null
await act(() => root.render(<Form action={true} />));
await submit(formRef.current);
assertLog([null]);
// Strings are passed through
await act(() => root.render(<Form action="https://react.dev" />));
await submit(formRef.current);
assertLog(['https://react.dev']);
// Functions are passed through
const actionFn = () => {};
await act(() => root.render(<Form action={actionFn} />));
await submit(formRef.current);
assertLog([actionFn]);
// Everything else is toString-ed
class MyAction {
toString() {
return 'stringified action';
}
}
await act(() => root.render(<Form action={new MyAction()} />));
await submit(formRef.current);
assertLog(['stringified action']);
});
});