mirror of
https://github.com/facebook/react.git
synced 2025-11-01 09:12:30 +00:00
2257 lines
68 KiB
JavaScript
2257 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 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 () => {
|
|
// 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>,
|
|
);
|
|
});
|
|
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 **)' +
|
|
(gate(flags => flags.enableOwnerStacks) ? '' : '\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(
|
|
// 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 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 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 **)' +
|
|
(gate('enableOwnerStacks') ? '' : '\n in form (at **)'),
|
|
]);
|
|
|
|
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);
|
|
});
|
|
|
|
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');
|
|
});
|
|
|
|
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 () => {
|
|
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 {
|
|
// 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()',
|
|
);
|
|
});
|
|
|
|
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');
|
|
});
|
|
|
|
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']);
|
|
});
|
|
|
|
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')));
|
|
// 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');
|
|
});
|
|
|
|
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!');
|
|
},
|
|
);
|
|
|
|
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']);
|
|
});
|
|
|
|
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');
|
|
});
|
|
|
|
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!');
|
|
});
|
|
|
|
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!');
|
|
});
|
|
|
|
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...',
|
|
|
|
...(gate('enableSiblingPrerendering') ? ['Suspend! [Count: 0]'] : []),
|
|
]);
|
|
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...',
|
|
|
|
...(gate('enableSiblingPrerendering') ? ['Suspend! [Count: 1]'] : []),
|
|
]);
|
|
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]',
|
|
|
|
...(gate('enableSiblingPrerendering') ? ['Suspend! [Count: 0]'] : []),
|
|
]);
|
|
await act(() => resolveText('Count: 0'));
|
|
assertLog(['Count: 0']);
|
|
|
|
// Dispatch outside of a transition.
|
|
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]',
|
|
|
|
...(gate('enableSiblingPrerendering') ? ['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 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']);
|
|
|
|
// 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` 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' +
|
|
(gate('enableOwnerStacks') ? '' : ' in form (at **)\n') +
|
|
' in App (at **)',
|
|
]);
|
|
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` 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]);
|
|
|
|
// 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']);
|
|
});
|
|
});
|