mirror of
https://github.com/facebook/react.git
synced 2025-11-01 09:12:30 +00:00
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>
2242 lines
68 KiB
JavaScript
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']);
|
|
});
|
|
});
|