main
js 2,407 lines 72.1 KB
Raw
1 /**
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 *
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
6 *
7 * @emails react-core
8 */
9
10 'use strict';
11
12 global.IS_REACT_ACT_ENVIRONMENT = true;
13
14 // Our current version of JSDOM doesn't implement the event dispatching
15 // so we polyfill it.
16 const NativeFormData = global.FormData;
17 const FormDataPolyfill = function FormData(form, submitter) {
18 const formData = new NativeFormData(form, submitter);
19 const formDataEvent = new Event('formdata', {
20 bubbles: true,
21 cancelable: false,
22 });
23 formDataEvent.formData = formData;
24 form.dispatchEvent(formDataEvent);
25 return formData;
26 };
27 NativeFormData.prototype.constructor = FormDataPolyfill;
28 global.FormData = FormDataPolyfill;
29
30 describe('ReactDOMForm', () => {
31 let act;
32 let container;
33 let React;
34 let ReactDOM;
35 let ReactDOMClient;
36 let Scheduler;
37 let assertLog;
38 let assertConsoleErrorDev;
39 let waitForThrow;
40 let useState;
41 let Suspense;
42 let startTransition;
43 let useTransition;
44 let use;
45 let textCache;
46 let useFormStatus;
47 let useActionState;
48 let requestFormReset;
49
50 beforeEach(() => {
51 jest.resetModules();
52 React = require('react');
53 ReactDOM = require('react-dom');
54 ReactDOMClient = require('react-dom/client');
55 Scheduler = require('scheduler');
56 act = require('internal-test-utils').act;
57 assertLog = require('internal-test-utils').assertLog;
58 waitForThrow = require('internal-test-utils').waitForThrow;
59 assertConsoleErrorDev =
60 require('internal-test-utils').assertConsoleErrorDev;
61 useState = React.useState;
62 Suspense = React.Suspense;
63 startTransition = React.startTransition;
64 useTransition = React.useTransition;
65 use = React.use;
66 useFormStatus = ReactDOM.useFormStatus;
67 requestFormReset = ReactDOM.requestFormReset;
68 container = document.createElement('div');
69 document.body.appendChild(container);
70
71 textCache = new Map();
72
73 if (__VARIANT__) {
74 const originalConsoleError = console.error;
75 console.error = (error, ...args) => {
76 if (
77 typeof error !== 'string' ||
78 error.indexOf('ReactDOM.useFormState has been renamed') === -1
79 ) {
80 originalConsoleError(error, ...args);
81 }
82 };
83 // Remove after API is deleted.
84 useActionState = ReactDOM.useFormState;
85 } else {
86 useActionState = React.useActionState;
87 }
88 });
89
90 function resolveText(text) {
91 const record = textCache.get(text);
92 if (record === undefined) {
93 const newRecord = {
94 status: 'resolved',
95 value: text,
96 };
97 textCache.set(text, newRecord);
98 } else if (record.status === 'pending') {
99 const thenable = record.value;
100 record.status = 'resolved';
101 record.value = text;
102 thenable.pings.forEach(t => t(text));
103 }
104 }
105
106 function readText(text) {
107 const record = textCache.get(text);
108 if (record !== undefined) {
109 switch (record.status) {
110 case 'pending':
111 Scheduler.log(`Suspend! [${text}]`);
112 throw record.value;
113 case 'rejected':
114 throw record.value;
115 case 'resolved':
116 return record.value;
117 }
118 } else {
119 Scheduler.log(`Suspend! [${text}]`);
120 const thenable = {
121 pings: [],
122 then(resolve) {
123 if (newRecord.status === 'pending') {
124 thenable.pings.push(resolve);
125 } else {
126 Promise.resolve().then(() => resolve(newRecord.value));
127 }
128 },
129 };
130
131 const newRecord = {
132 status: 'pending',
133 value: thenable,
134 };
135 textCache.set(text, newRecord);
136
137 throw thenable;
138 }
139 }
140
141 function getText(text) {
142 const record = textCache.get(text);
143 if (record === undefined) {
144 const thenable = {
145 pings: [],
146 then(resolve) {
147 if (newRecord.status === 'pending') {
148 thenable.pings.push(resolve);
149 } else {
150 Promise.resolve().then(() => resolve(newRecord.value));
151 }
152 },
153 };
154 const newRecord = {
155 status: 'pending',
156 value: thenable,
157 };
158 textCache.set(text, newRecord);
159 return thenable;
160 } else {
161 switch (record.status) {
162 case 'pending':
163 return record.value;
164 case 'rejected':
165 return Promise.reject(record.value);
166 case 'resolved':
167 return Promise.resolve(record.value);
168 }
169 }
170 }
171
172 function Text({text}) {
173 Scheduler.log(text);
174 return text;
175 }
176
177 function AsyncText({text}) {
178 readText(text);
179 Scheduler.log(text);
180 return text;
181 }
182
183 afterEach(() => {
184 document.body.removeChild(container);
185 });
186
187 async function submit(submitter) {
188 await act(() => {
189 const form = submitter.form || submitter;
190 if (!submitter.form) {
191 submitter = undefined;
192 }
193 const submitEvent = new Event('submit', {
194 bubbles: true,
195 cancelable: true,
196 });
197 submitEvent.submitter = submitter;
198 const returnValue = form.dispatchEvent(submitEvent);
199 if (!returnValue) {
200 return;
201 }
202 const action =
203 (submitter && submitter.getAttribute('formaction')) || form.action;
204 if (!/\s*javascript:/i.test(action)) {
205 throw new Error('Navigate to: ' + action);
206 }
207 });
208 }
209
210 it('should allow passing a function to form action', async () => {
211 const ref = React.createRef();
212 let foo;
213
214 function action(formData) {
215 foo = formData.get('foo');
216 }
217
218 const root = ReactDOMClient.createRoot(container);
219 await act(async () => {
220 root.render(
221 <form action={action} ref={ref}>
222 <input type="text" name="foo" defaultValue="bar" />
223 </form>,
224 );
225 });
226
227 await submit(ref.current);
228
229 expect(foo).toBe('bar');
230
231 // Try updating the action
232
233 function action2(formData) {
234 foo = formData.get('foo') + '2';
235 }
236
237 await act(async () => {
238 root.render(
239 <form action={action2} ref={ref}>
240 <input type="text" name="foo" defaultValue="bar" />
241 </form>,
242 );
243 });
244
245 await submit(ref.current);
246
247 expect(foo).toBe('bar2');
248 });
249
250 it('should allow passing a function to an input/button formAction', async () => {
251 const inputRef = React.createRef();
252 const buttonRef = React.createRef();
253 let rootActionCalled = false;
254 let savedTitle = null;
255 let deletedTitle = null;
256
257 function action(formData) {
258 rootActionCalled = true;
259 }
260
261 function saveItem(formData) {
262 savedTitle = formData.get('title');
263 }
264
265 function deleteItem(formData) {
266 deletedTitle = formData.get('title');
267 }
268
269 const root = ReactDOMClient.createRoot(container);
270 await act(async () => {
271 root.render(
272 <form action={action}>
273 <input type="text" name="title" defaultValue="Hello" />
274 <input
275 type="submit"
276 formAction={saveItem}
277 value="Save"
278 ref={inputRef}
279 />
280 <button formAction={deleteItem} ref={buttonRef}>
281 Delete
282 </button>
283 </form>,
284 );
285 });
286
287 expect(savedTitle).toBe(null);
288 expect(deletedTitle).toBe(null);
289
290 await submit(inputRef.current);
291 expect(savedTitle).toBe('Hello');
292 expect(deletedTitle).toBe(null);
293 savedTitle = null;
294
295 await submit(buttonRef.current);
296 expect(savedTitle).toBe(null);
297 expect(deletedTitle).toBe('Hello');
298 deletedTitle = null;
299
300 // Try updating the actions
301
302 function saveItem2(formData) {
303 savedTitle = formData.get('title') + '2';
304 }
305
306 function deleteItem2(formData) {
307 deletedTitle = formData.get('title') + '2';
308 }
309
310 await act(async () => {
311 root.render(
312 <form action={action}>
313 <input type="text" name="title" defaultValue="Hello" />
314 <input
315 type="submit"
316 formAction={saveItem2}
317 value="Save"
318 ref={inputRef}
319 />
320 <button formAction={deleteItem2} ref={buttonRef}>
321 Delete
322 </button>
323 </form>,
324 );
325 });
326
327 expect(savedTitle).toBe(null);
328 expect(deletedTitle).toBe(null);
329
330 await submit(inputRef.current);
331 expect(savedTitle).toBe('Hello2');
332 expect(deletedTitle).toBe(null);
333 savedTitle = null;
334
335 await submit(buttonRef.current);
336 expect(savedTitle).toBe(null);
337 expect(deletedTitle).toBe('Hello2');
338
339 expect(rootActionCalled).toBe(false);
340 });
341
342 it('should allow preventing default to block the action', async () => {
343 const ref = React.createRef();
344 let actionCalled = false;
345
346 function action(formData) {
347 actionCalled = true;
348 }
349
350 const root = ReactDOMClient.createRoot(container);
351 await act(async () => {
352 root.render(
353 <form action={action} ref={ref} onSubmit={e => e.preventDefault()}>
354 <input type="text" name="foo" defaultValue="bar" />
355 </form>,
356 );
357 });
358
359 await submit(ref.current);
360
361 expect(actionCalled).toBe(false);
362 });
363
364 it('should submit the inner of nested forms', async () => {
365 const ref = React.createRef();
366 let data;
367
368 function outerAction(formData) {
369 data = formData.get('data') + 'outer';
370 }
371 function innerAction(formData) {
372 data = formData.get('data') + 'inner';
373 }
374
375 const root = ReactDOMClient.createRoot(container);
376 await act(async () => {
377 // This isn't valid HTML but just in case.
378 root.render(
379 <form action={outerAction}>
380 <input type="text" name="data" defaultValue="outer" />
381 <form action={innerAction} ref={ref}>
382 <input type="text" name="data" defaultValue="inner" />
383 </form>
384 </form>,
385 );
386 });
387 assertConsoleErrorDev([
388 'In HTML, <form> cannot be a descendant of <form>.\n' +
389 'This will cause a hydration error.\n' +
390 '\n' +
391 '> <form action={function outerAction}>\n' +
392 ' <input>\n' +
393 '> <form action={function innerAction} ref={{current:null}}>\n' +
394 '\n in form (at **)',
395 ]);
396
397 await submit(ref.current);
398
399 expect(data).toBe('innerinner');
400 });
401
402 it('should submit once if one root is nested inside the other', async () => {
403 const ref = React.createRef();
404 let outerCalled = 0;
405 let innerCalled = 0;
406 let bubbledSubmit = false;
407
408 function outerAction(formData) {
409 outerCalled++;
410 }
411
412 function innerAction(formData) {
413 innerCalled++;
414 }
415
416 const innerContainerRef = React.createRef();
417 const outerRoot = ReactDOMClient.createRoot(container);
418 await act(async () => {
419 outerRoot.render(
420 // Nesting forms isn't valid HTML but just in case.
421 <div onSubmit={() => (bubbledSubmit = true)}>
422 <form action={outerAction}>
423 <div ref={innerContainerRef} />
424 </form>
425 </div>,
426 );
427 });
428
429 const innerRoot = ReactDOMClient.createRoot(innerContainerRef.current);
430 await act(async () => {
431 innerRoot.render(
432 <form action={innerAction} ref={ref}>
433 <input type="text" name="data" defaultValue="inner" />
434 </form>,
435 );
436 });
437
438 await submit(ref.current);
439
440 expect(bubbledSubmit).toBe(true);
441 expect(outerCalled).toBe(0);
442 expect(innerCalled).toBe(1);
443 });
444
445 it('should submit once if a portal is nested inside its own root', async () => {
446 const ref = React.createRef();
447 let outerCalled = 0;
448 let innerCalled = 0;
449 let bubbledSubmit = false;
450
451 function outerAction(formData) {
452 outerCalled++;
453 }
454
455 function innerAction(formData) {
456 innerCalled++;
457 }
458
459 const innerContainer = document.createElement('div');
460 const innerContainerRef = React.createRef();
461 const outerRoot = ReactDOMClient.createRoot(container);
462 await act(async () => {
463 outerRoot.render(
464 // Nesting forms isn't valid HTML but just in case.
465 <div onSubmit={() => (bubbledSubmit = true)}>
466 <form action={outerAction}>
467 <div ref={innerContainerRef} />
468 {ReactDOM.createPortal(
469 <form action={innerAction} ref={ref}>
470 <input type="text" name="data" defaultValue="inner" />
471 </form>,
472 innerContainer,
473 )}
474 </form>
475 </div>,
476 );
477 });
478
479 innerContainerRef.current.appendChild(innerContainer);
480
481 await submit(ref.current);
482
483 expect(bubbledSubmit).toBe(true);
484 expect(outerCalled).toBe(0);
485 expect(innerCalled).toBe(1);
486 });
487
488 it('can read the clicked button in the formdata event', async () => {
489 const inputRef = React.createRef();
490 const buttonRef = React.createRef();
491 const outsideButtonRef = React.createRef();
492 const imageButtonRef = React.createRef();
493 let button;
494 let buttonX;
495 let buttonY;
496 let title;
497
498 function action(formData) {
499 button = formData.get('button');
500 buttonX = formData.get('button.x');
501 buttonY = formData.get('button.y');
502 title = formData.get('title');
503 }
504
505 const root = ReactDOMClient.createRoot(container);
506 await act(async () => {
507 root.render(
508 <>
509 <form action={action}>
510 <input type="text" name="title" defaultValue="hello" />
511 <input type="submit" name="button" value="save" />
512 <input type="submit" name="button" value="delete" ref={inputRef} />
513 <button name="button" value="edit" ref={buttonRef}>
514 Edit
515 </button>
516 <input
517 type="image"
518 name="button"
519 href="/some/image.png"
520 ref={imageButtonRef}
521 />
522 </form>
523 <form id="form" action={action}>
524 <input type="text" name="title" defaultValue="hello" />
525 </form>
526 <button
527 form="form"
528 name="button"
529 value="outside"
530 ref={outsideButtonRef}>
531 Button outside form
532 </button>
533 ,
534 </>,
535 );
536 });
537
538 container.addEventListener('formdata', e => {
539 // Process in the formdata event somehow
540 if (e.formData.get('button') === 'delete') {
541 e.formData.delete('title');
542 }
543 });
544
545 await submit(inputRef.current);
546
547 expect(button).toBe('delete');
548 expect(title).toBe(null);
549
550 await submit(buttonRef.current);
551
552 expect(button).toBe('edit');
553 expect(title).toBe('hello');
554
555 await submit(outsideButtonRef.current);
556
557 expect(button).toBe('outside');
558 expect(title).toBe('hello');
559
560 await submit(imageButtonRef.current);
561
562 expect(button).toBe(null);
563 expect(buttonX).toBe('0');
564 expect(buttonY).toBe('0');
565 expect(title).toBe('hello');
566 });
567
568 it('excludes the submitter name when the submitter is a function action', async () => {
569 const inputRef = React.createRef();
570 const buttonRef = React.createRef();
571 let button;
572
573 function action(formData) {
574 // A function action cannot control the name since it might be controlled by the server
575 // so we need to make sure it doesn't get into the FormData.
576 button = formData.get('button');
577 }
578
579 const root = ReactDOMClient.createRoot(container);
580 await act(async () => {
581 root.render(
582 <form>
583 <input
584 type="submit"
585 name="button"
586 value="delete"
587 ref={inputRef}
588 formAction={action}
589 />
590 <button
591 name="button"
592 value="edit"
593 ref={buttonRef}
594 formAction={action}>
595 Edit
596 </button>
597 </form>,
598 );
599 });
600 assertConsoleErrorDev([
601 'Cannot specify a "name" prop for a button that specifies a function as a formAction. ' +
602 'React needs it to encode which action should be invoked. ' +
603 'It will get overridden.\n' +
604 ' in input (at **)',
605 ]);
606
607 await submit(inputRef.current);
608
609 expect(button).toBe(null);
610
611 await submit(buttonRef.current);
612
613 expect(button).toBe(null);
614
615 // Ensure that the type field got correctly restored
616 expect(inputRef.current.getAttribute('type')).toBe('submit');
617 expect(buttonRef.current.getAttribute('type')).toBe(null);
618 });
619
620 it('allows a non-function formaction to override a function one', async () => {
621 const ref = React.createRef();
622 let actionCalled = false;
623
624 function action(formData) {
625 actionCalled = true;
626 }
627
628 const root = ReactDOMClient.createRoot(container);
629 await act(async () => {
630 root.render(
631 <form action={action}>
632 <input
633 type="submit"
634 formAction="http://example.com/submit"
635 ref={ref}
636 />
637 </form>,
638 );
639 });
640
641 let nav;
642 try {
643 await submit(ref.current);
644 } catch (x) {
645 nav = x.message;
646 }
647 expect(nav).toBe('Navigate to: http://example.com/submit');
648 expect(actionCalled).toBe(false);
649 });
650
651 it('allows a non-react html formaction to be invoked', async () => {
652 let actionCalled = false;
653
654 function action(formData) {
655 actionCalled = true;
656 }
657
658 const root = ReactDOMClient.createRoot(container);
659 await act(async () => {
660 root.render(
661 <form
662 action={action}
663 dangerouslySetInnerHTML={{
664 __html: `
665 <input
666 type="submit"
667 formAction="http://example.com/submit"
668 />
669 `,
670 }}
671 />,
672 );
673 });
674
675 const node = container.getElementsByTagName('input')[0];
676 let nav;
677 try {
678 await submit(node);
679 } catch (x) {
680 nav = x.message;
681 }
682 expect(nav).toBe('Navigate to: http://example.com/submit');
683 expect(actionCalled).toBe(false);
684 });
685
686 it('form actions are transitions', async () => {
687 const formRef = React.createRef();
688
689 function Status() {
690 const {pending} = useFormStatus();
691 return pending ? <Text text="Pending..." /> : null;
692 }
693
694 function App() {
695 const [state, setState] = useState('Initial');
696 return (
697 <form action={() => setState('Updated')} ref={formRef}>
698 <Status />
699 <Suspense fallback={<Text text="Loading..." />}>
700 <AsyncText text={state} />
701 </Suspense>
702 </form>
703 );
704 }
705
706 const root = ReactDOMClient.createRoot(container);
707 await resolveText('Initial');
708 await act(() => root.render(<App />));
709 assertLog(['Initial']);
710 expect(container.textContent).toBe('Initial');
711
712 // This should suspend because form actions are implicitly wrapped
713 // in startTransition.
714 await submit(formRef.current);
715 assertLog(['Pending...', 'Suspend! [Updated]', 'Loading...']);
716 expect(container.textContent).toBe('Pending...Initial');
717
718 await act(() => resolveText('Updated'));
719 assertLog(['Updated']);
720 expect(container.textContent).toBe('Updated');
721 });
722
723 it('multiple form actions', async () => {
724 const formRef = React.createRef();
725
726 function Status() {
727 const {pending} = useFormStatus();
728 return pending ? <Text text="Pending..." /> : null;
729 }
730
731 function App() {
732 const [state, setState] = useState(0);
733 return (
734 <form action={() => setState(n => n + 1)} ref={formRef}>
735 <Status />
736 <Suspense fallback={<Text text="Loading..." />}>
737 <AsyncText text={'Count: ' + state} />
738 </Suspense>
739 </form>
740 );
741 }
742
743 const root = ReactDOMClient.createRoot(container);
744 await resolveText('Count: 0');
745 await act(() => root.render(<App />));
746 assertLog(['Count: 0']);
747 expect(container.textContent).toBe('Count: 0');
748
749 // Update
750 await submit(formRef.current);
751 assertLog(['Pending...', 'Suspend! [Count: 1]', 'Loading...']);
752 expect(container.textContent).toBe('Pending...Count: 0');
753
754 await act(() => resolveText('Count: 1'));
755 assertLog(['Count: 1']);
756 expect(container.textContent).toBe('Count: 1');
757
758 // Update again
759 await submit(formRef.current);
760 assertLog(['Pending...', 'Suspend! [Count: 2]', 'Loading...']);
761 expect(container.textContent).toBe('Pending...Count: 1');
762
763 await act(() => resolveText('Count: 2'));
764 assertLog(['Count: 2']);
765 expect(container.textContent).toBe('Count: 2');
766 });
767
768 it('form actions can be asynchronous', async () => {
769 const formRef = React.createRef();
770
771 function Status() {
772 const {pending} = useFormStatus();
773 return pending ? <Text text="Pending..." /> : null;
774 }
775
776 function App() {
777 const [state, setState] = useState('Initial');
778 return (
779 <form
780 action={async () => {
781 Scheduler.log('Async action started');
782 await getText('Wait');
783 startTransition(() => setState('Updated'));
784 }}
785 ref={formRef}>
786 <Status />
787 <Suspense fallback={<Text text="Loading..." />}>
788 <AsyncText text={state} />
789 </Suspense>
790 </form>
791 );
792 }
793
794 const root = ReactDOMClient.createRoot(container);
795 await resolveText('Initial');
796 await act(() => root.render(<App />));
797 assertLog(['Initial']);
798 expect(container.textContent).toBe('Initial');
799
800 await submit(formRef.current);
801 assertLog(['Async action started', 'Pending...']);
802
803 await act(() => resolveText('Wait'));
804 assertLog(['Suspend! [Updated]', 'Loading...']);
805 expect(container.textContent).toBe('Pending...Initial');
806
807 await act(() => resolveText('Updated'));
808 assertLog(['Updated']);
809 expect(container.textContent).toBe('Updated');
810 });
811
812 it('sync errors in form actions can be captured by an error boundary', async () => {
813 class ErrorBoundary extends React.Component {
814 state = {error: null};
815 static getDerivedStateFromError(error) {
816 return {error};
817 }
818 render() {
819 if (this.state.error !== null) {
820 return <Text text={this.state.error.message} />;
821 }
822 return this.props.children;
823 }
824 }
825
826 const formRef = React.createRef();
827
828 function App() {
829 return (
830 <ErrorBoundary>
831 <form
832 action={() => {
833 throw new Error('Oh no!');
834 }}
835 ref={formRef}>
836 <Text text="Everything is fine" />
837 </form>
838 </ErrorBoundary>
839 );
840 }
841
842 const root = ReactDOMClient.createRoot(container);
843 await act(() => root.render(<App />));
844 assertLog(['Everything is fine']);
845 expect(container.textContent).toBe('Everything is fine');
846
847 await submit(formRef.current);
848 assertLog(['Oh no!', 'Oh no!']);
849 expect(container.textContent).toBe('Oh no!');
850 });
851
852 it('async errors in form actions can be captured by an error boundary', async () => {
853 class ErrorBoundary extends React.Component {
854 state = {error: null};
855 static getDerivedStateFromError(error) {
856 return {error};
857 }
858 render() {
859 if (this.state.error !== null) {
860 return <Text text={this.state.error.message} />;
861 }
862 return this.props.children;
863 }
864 }
865
866 const formRef = React.createRef();
867
868 function App() {
869 return (
870 <ErrorBoundary>
871 <form
872 action={async () => {
873 Scheduler.log('Async action started');
874 await getText('Wait');
875 throw new Error('Oh no!');
876 }}
877 ref={formRef}>
878 <Text text="Everything is fine" />
879 </form>
880 </ErrorBoundary>
881 );
882 }
883
884 const root = ReactDOMClient.createRoot(container);
885 await act(() => root.render(<App />));
886 assertLog(['Everything is fine']);
887 expect(container.textContent).toBe('Everything is fine');
888
889 await submit(formRef.current);
890 assertLog(['Async action started']);
891 expect(container.textContent).toBe('Everything is fine');
892
893 await act(() => resolveText('Wait'));
894 assertLog(['Oh no!', 'Oh no!']);
895 expect(container.textContent).toBe('Oh no!');
896 });
897
898 it('useFormStatus reads the status of a pending form action', async () => {
899 const formRef = React.createRef();
900
901 function Status() {
902 const {pending, data, action, method} = useFormStatus();
903 if (!pending) {
904 return <Text text="No pending action" />;
905 } else {
906 const foo = data.get('foo');
907 return (
908 <Text
909 text={`Pending action ${action.name}: foo is ${foo}, method is ${method}`}
910 />
911 );
912 }
913 }
914
915 async function myAction() {
916 Scheduler.log('Async action started');
917 await getText('Wait');
918 Scheduler.log('Async action finished');
919 }
920
921 function App() {
922 return (
923 <form action={myAction} ref={formRef}>
924 <input type="text" name="foo" defaultValue="bar" />
925 <Status />
926 </form>
927 );
928 }
929
930 const root = ReactDOMClient.createRoot(container);
931 await act(() => root.render(<App />));
932 assertLog(['No pending action']);
933 expect(container.textContent).toBe('No pending action');
934
935 await submit(formRef.current);
936 assertLog([
937 'Async action started',
938 'Pending action myAction: foo is bar, method is get',
939 ]);
940 expect(container.textContent).toBe(
941 'Pending action myAction: foo is bar, method is get',
942 );
943
944 await act(() => resolveText('Wait'));
945 assertLog(['Async action finished', 'No pending action']);
946 });
947
948 it('should error if submitting a form manually', async () => {
949 const ref = React.createRef();
950
951 let error = null;
952 let result = null;
953
954 function emulateForceSubmit(submitter) {
955 const form = submitter.form || submitter;
956 const action =
957 (submitter && submitter.getAttribute('formaction')) || form.action;
958 try {
959 if (!/\s*javascript:/i.test(action)) {
960 throw new Error('Navigate to: ' + action);
961 } else {
962 // eslint-disable-next-line no-new-func
963 result = Function(action.slice(11))();
964 }
965 } catch (x) {
966 error = x;
967 }
968 }
969
970 const root = ReactDOMClient.createRoot(container);
971 await act(async () => {
972 root.render(
973 <form
974 action={() => {}}
975 ref={ref}
976 onSubmit={e => {
977 e.preventDefault();
978 emulateForceSubmit(e.target);
979 }}>
980 <input type="text" name="foo" defaultValue="bar" />
981 </form>,
982 );
983 });
984
985 // This submits the form, which gets blocked and then resubmitted. It's a somewhat
986 // common idiom but we don't support this pattern unless it uses requestSubmit().
987 await submit(ref.current);
988 expect(result).toBe(null);
989 expect(error.message).toContain(
990 'A React form was unexpectedly submitted. If you called form.submit()',
991 );
992 });
993
994 it('useActionState updates state asynchronously and queues multiple actions', async () => {
995 let actionCounter = 0;
996 async function action(state, type) {
997 actionCounter++;
998
999 Scheduler.log(`Async action started [${actionCounter}]`);
1000 await getText(`Wait [${actionCounter}]`);
1001
1002 switch (type) {
1003 case 'increment':
1004 return state + 1;
1005 case 'decrement':
1006 return state - 1;
1007 default:
1008 return state;
1009 }
1010 }
1011
1012 let dispatch;
1013 function App() {
1014 const [state, _dispatch, isPending] = useActionState(action, 0);
1015 dispatch = _dispatch;
1016 const pending = isPending ? 'Pending ' : '';
1017 return <Text text={pending + state} />;
1018 }
1019
1020 const root = ReactDOMClient.createRoot(container);
1021 await act(() => root.render(<App />));
1022 assertLog(['0']);
1023 expect(container.textContent).toBe('0');
1024
1025 await act(() => startTransition(() => dispatch('increment')));
1026 assertLog(['Async action started [1]', 'Pending 0']);
1027 expect(container.textContent).toBe('Pending 0');
1028
1029 // Dispatch a few more actions. None of these will start until the previous
1030 // one finishes.
1031 await act(() => startTransition(() => dispatch('increment')));
1032 await act(() => startTransition(() => dispatch('decrement')));
1033 await act(() => startTransition(() => dispatch('increment')));
1034 assertLog([]);
1035
1036 // Each action starts as soon as the previous one finishes.
1037 // NOTE: React does not render in between these actions because they all
1038 // update the same queue, which means they get entangled together. This is
1039 // intentional behavior.
1040 await act(() => resolveText('Wait [1]'));
1041 assertLog(['Async action started [2]']);
1042 await act(() => resolveText('Wait [2]'));
1043 assertLog(['Async action started [3]']);
1044 await act(() => resolveText('Wait [3]'));
1045 assertLog(['Async action started [4]']);
1046 await act(() => resolveText('Wait [4]'));
1047
1048 // Finally the last action finishes and we can render the result.
1049 assertLog(['2']);
1050 expect(container.textContent).toBe('2');
1051 });
1052
1053 it('useActionState supports inline actions', async () => {
1054 let increment;
1055 function App({stepSize}) {
1056 const [state, dispatch, isPending] = useActionState(async prevState => {
1057 return prevState + stepSize;
1058 }, 0);
1059 increment = dispatch;
1060 const pending = isPending ? 'Pending ' : '';
1061 return <Text text={pending + state} />;
1062 }
1063
1064 // Initial render
1065 const root = ReactDOMClient.createRoot(container);
1066 await act(() => root.render(<App stepSize={1} />));
1067 assertLog(['0']);
1068
1069 // Perform an action. This will increase the state by 1, as defined by the
1070 // stepSize prop.
1071 await act(() => startTransition(() => increment()));
1072 assertLog(['Pending 0', '1']);
1073
1074 // Now increase the stepSize prop to 10. Subsequent steps will increase
1075 // by this amount.
1076 await act(() => root.render(<App stepSize={10} />));
1077 assertLog(['1']);
1078
1079 // Increment again. The state should increase by 10.
1080 await act(() => startTransition(() => increment()));
1081 assertLog(['Pending 1', '11']);
1082 });
1083
1084 it('useActionState: dispatch throws if called during render', async () => {
1085 function App() {
1086 const [state, dispatch, isPending] = useActionState(async () => {}, 0);
1087 dispatch();
1088 const pending = isPending ? 'Pending ' : '';
1089 return <Text text={pending + state} />;
1090 }
1091
1092 const root = ReactDOMClient.createRoot(container);
1093 await act(async () => {
1094 root.render(<App />);
1095 await waitForThrow('Cannot update action state while rendering.');
1096 });
1097 });
1098
1099 it('useActionState: queues multiple actions and runs them in order', async () => {
1100 let action;
1101 function App() {
1102 const [state, dispatch, isPending] = useActionState(
1103 async (s, a) => await getText(a),
1104 'A',
1105 );
1106 action = dispatch;
1107 const pending = isPending ? 'Pending ' : '';
1108 return <Text text={pending + state} />;
1109 }
1110
1111 const root = ReactDOMClient.createRoot(container);
1112 await act(() => root.render(<App />));
1113 assertLog(['A']);
1114
1115 await act(() => startTransition(() => action('B')));
1116 // The first dispatch will update the pending state.
1117 assertLog(['Pending A']);
1118 await act(() => startTransition(() => action('C')));
1119 await act(() => startTransition(() => action('D')));
1120 assertLog([]);
1121
1122 await act(() => resolveText('B'));
1123 await act(() => resolveText('C'));
1124 await act(() => resolveText('D'));
1125
1126 assertLog(['D']);
1127 expect(container.textContent).toBe('D');
1128 });
1129
1130 it(
1131 'useActionState: when calling a queued action, uses the implementation ' +
1132 'that was current at the time it was dispatched, not the most recent one',
1133 async () => {
1134 let action;
1135 function App({throwIfActionIsDispatched}) {
1136 const [state, dispatch, isPending] = useActionState(async (s, a) => {
1137 if (throwIfActionIsDispatched) {
1138 throw new Error('Oops!');
1139 }
1140 return await getText(a);
1141 }, 'Initial');
1142 action = dispatch;
1143 return <Text text={state + (isPending ? ' (pending)' : '')} />;
1144 }
1145
1146 const root = ReactDOMClient.createRoot(container);
1147 await act(() => root.render(<App throwIfActionIsDispatched={false} />));
1148 assertLog(['Initial']);
1149
1150 // Dispatch two actions. The first one is async, so it forces the second
1151 // one into an async queue.
1152 await act(() => startTransition(() => action('First action')));
1153 assertLog(['Initial (pending)']);
1154 // This action won't run until the first one finishes.
1155 await act(() => startTransition(() => action('Second action')));
1156
1157 // While the first action is still pending, update a prop. This causes the
1158 // inline action implementation to change, but it should not affect the
1159 // behavior of the action that is already queued.
1160 await act(() => root.render(<App throwIfActionIsDispatched={true} />));
1161 assertLog(['Initial (pending)']);
1162
1163 // Finish both of the actions.
1164 await act(() => resolveText('First action'));
1165 await act(() => resolveText('Second action'));
1166 assertLog(['Second action']);
1167
1168 // Confirm that if we dispatch yet another action, it uses the updated
1169 // action implementation.
1170 await expect(
1171 act(() => startTransition(() => action('Third action'))),
1172 ).rejects.toThrow('Oops!');
1173 },
1174 );
1175
1176 it('useActionState: works if action is sync', async () => {
1177 let increment;
1178 function App({stepSize}) {
1179 const [state, dispatch, isPending] = useActionState(prevState => {
1180 return prevState + stepSize;
1181 }, 0);
1182 increment = dispatch;
1183 const pending = isPending ? 'Pending ' : '';
1184 return <Text text={pending + state} />;
1185 }
1186
1187 // Initial render
1188 const root = ReactDOMClient.createRoot(container);
1189 await act(() => root.render(<App stepSize={1} />));
1190 assertLog(['0']);
1191
1192 // Perform an action. This will increase the state by 1, as defined by the
1193 // stepSize prop.
1194 await act(() => startTransition(() => increment()));
1195 assertLog(['Pending 0', '1']);
1196
1197 // Now increase the stepSize prop to 10. Subsequent steps will increase
1198 // by this amount.
1199 await act(() => root.render(<App stepSize={10} />));
1200 assertLog(['1']);
1201
1202 // Increment again. The state should increase by 10.
1203 await act(() => startTransition(() => increment()));
1204 assertLog(['Pending 1', '11']);
1205 });
1206
1207 it('useActionState: can mix sync and async actions', async () => {
1208 let action;
1209 function App() {
1210 const [state, dispatch, isPending] = useActionState((s, a) => a, 'A');
1211 action = dispatch;
1212 const pending = isPending ? 'Pending ' : '';
1213 return <Text text={pending + state} />;
1214 }
1215
1216 const root = ReactDOMClient.createRoot(container);
1217 await act(() => root.render(<App />));
1218 assertLog(['A']);
1219
1220 await act(() => startTransition(() => action(getText('B'))));
1221 // The first dispatch will update the pending state.
1222 assertLog(['Pending A']);
1223 await act(() => startTransition(() => action('C')));
1224 await act(() => startTransition(() => action(getText('D'))));
1225 await act(() => startTransition(() => action('E')));
1226 assertLog([]);
1227
1228 await act(() => resolveText('B'));
1229 await act(() => resolveText('D'));
1230 assertLog(['E']);
1231 expect(container.textContent).toBe('E');
1232 });
1233
1234 it('useActionState: error handling (sync action)', async () => {
1235 class ErrorBoundary extends React.Component {
1236 state = {error: null};
1237 static getDerivedStateFromError(error) {
1238 return {error};
1239 }
1240 render() {
1241 if (this.state.error !== null) {
1242 return <Text text={'Caught an error: ' + this.state.error.message} />;
1243 }
1244 return this.props.children;
1245 }
1246 }
1247
1248 let action;
1249 function App() {
1250 const [state, dispatch, isPending] = useActionState((s, a) => {
1251 if (a.endsWith('!')) {
1252 throw new Error(a);
1253 }
1254 return a;
1255 }, 'A');
1256 action = dispatch;
1257 const pending = isPending ? 'Pending ' : '';
1258 return <Text text={pending + state} />;
1259 }
1260
1261 const root = ReactDOMClient.createRoot(container);
1262 await act(() =>
1263 root.render(
1264 <ErrorBoundary>
1265 <App />
1266 </ErrorBoundary>,
1267 ),
1268 );
1269 assertLog(['A']);
1270
1271 await act(() => startTransition(() => action('Oops!')));
1272 assertLog([
1273 // Action begins, error has not thrown yet.
1274 'Pending A',
1275 // Now the action runs and throws.
1276 'Caught an error: Oops!',
1277 'Caught an error: Oops!',
1278 ]);
1279 expect(container.textContent).toBe('Caught an error: Oops!');
1280 });
1281
1282 it('useActionState: error handling (async action)', async () => {
1283 class ErrorBoundary extends React.Component {
1284 state = {error: null};
1285 static getDerivedStateFromError(error) {
1286 return {error};
1287 }
1288 render() {
1289 if (this.state.error !== null) {
1290 return <Text text={'Caught an error: ' + this.state.error.message} />;
1291 }
1292 return this.props.children;
1293 }
1294 }
1295
1296 let action;
1297 function App() {
1298 const [state, dispatch, isPending] = useActionState(async (s, a) => {
1299 const text = await getText(a);
1300 if (text.endsWith('!')) {
1301 throw new Error(text);
1302 }
1303 return text;
1304 }, 'A');
1305 action = dispatch;
1306 const pending = isPending ? 'Pending ' : '';
1307 return <Text text={pending + state} />;
1308 }
1309
1310 const root = ReactDOMClient.createRoot(container);
1311 await act(() =>
1312 root.render(
1313 <ErrorBoundary>
1314 <App />
1315 </ErrorBoundary>,
1316 ),
1317 );
1318 assertLog(['A']);
1319
1320 await act(() => startTransition(() => action('Oops!')));
1321 // The first dispatch will update the pending state.
1322 assertLog(['Pending A']);
1323 await act(() => resolveText('Oops!'));
1324 assertLog(['Caught an error: Oops!', 'Caught an error: Oops!']);
1325 expect(container.textContent).toBe('Caught an error: Oops!');
1326 });
1327
1328 it('useActionState: when an action errors, subsequent actions are canceled', async () => {
1329 class ErrorBoundary extends React.Component {
1330 state = {error: null};
1331 static getDerivedStateFromError(error) {
1332 return {error};
1333 }
1334 render() {
1335 if (this.state.error !== null) {
1336 return <Text text={'Caught an error: ' + this.state.error.message} />;
1337 }
1338 return this.props.children;
1339 }
1340 }
1341
1342 let action;
1343 function App() {
1344 const [state, dispatch, isPending] = useActionState(async (s, a) => {
1345 Scheduler.log('Start action: ' + a);
1346 const text = await getText(a);
1347 if (text.endsWith('!')) {
1348 throw new Error(text);
1349 }
1350 return text;
1351 }, 'A');
1352 action = dispatch;
1353 const pending = isPending ? 'Pending ' : '';
1354 return <Text text={pending + state} />;
1355 }
1356
1357 const root = ReactDOMClient.createRoot(container);
1358 await act(() =>
1359 root.render(
1360 <ErrorBoundary>
1361 <App />
1362 </ErrorBoundary>,
1363 ),
1364 );
1365 assertLog(['A']);
1366
1367 await act(() => startTransition(() => action('Oops!')));
1368 assertLog(['Start action: Oops!', 'Pending A']);
1369
1370 // Queue up another action after the one will error.
1371 await act(() => startTransition(() => action('Should never run')));
1372 assertLog([]);
1373
1374 // The first dispatch will update the pending state.
1375 await act(() => resolveText('Oops!'));
1376 assertLog(['Caught an error: Oops!', 'Caught an error: Oops!']);
1377 expect(container.textContent).toBe('Caught an error: Oops!');
1378
1379 // Attempt to dispatch another action. This should not run either.
1380 await act(() =>
1381 startTransition(() => action('This also should never run')),
1382 );
1383 assertLog([]);
1384 expect(container.textContent).toBe('Caught an error: Oops!');
1385 });
1386
1387 it('useActionState works in StrictMode', async () => {
1388 let actionCounter = 0;
1389 async function action(state, type) {
1390 actionCounter++;
1391
1392 Scheduler.log(`Async action started [${actionCounter}]`);
1393 await getText(`Wait [${actionCounter}]`);
1394
1395 switch (type) {
1396 case 'increment':
1397 return state + 1;
1398 case 'decrement':
1399 return state - 1;
1400 default:
1401 return state;
1402 }
1403 }
1404
1405 let dispatch;
1406 function App() {
1407 const [state, _dispatch, isPending] = useActionState(action, 0);
1408 dispatch = _dispatch;
1409 const pending = isPending ? 'Pending ' : '';
1410 return <Text text={pending + state} />;
1411 }
1412
1413 const root = ReactDOMClient.createRoot(container);
1414 await act(() =>
1415 root.render(
1416 <React.StrictMode>
1417 <App />
1418 </React.StrictMode>,
1419 ),
1420 );
1421 assertLog(['0']);
1422 expect(container.textContent).toBe('0');
1423
1424 await act(() => startTransition(() => dispatch('increment')));
1425 assertLog(['Async action started [1]', 'Pending 0']);
1426 expect(container.textContent).toBe('Pending 0');
1427
1428 await act(() => resolveText('Wait [1]'));
1429 assertLog(['1']);
1430 expect(container.textContent).toBe('1');
1431 });
1432
1433 it('useActionState does not wrap action in a transition unless dispatch is in a transition', async () => {
1434 let dispatch;
1435 function App() {
1436 const [state, _dispatch] = useActionState(() => {
1437 return state + 1;
1438 }, 0);
1439 dispatch = _dispatch;
1440 return <AsyncText text={'Count: ' + state} />;
1441 }
1442
1443 const root = ReactDOMClient.createRoot(container);
1444 await act(() =>
1445 root.render(
1446 <Suspense fallback={<Text text="Loading..." />}>
1447 <App />
1448 </Suspense>,
1449 ),
1450 );
1451 assertLog([
1452 'Suspend! [Count: 0]',
1453 'Loading...',
1454 // pre-warming
1455 'Suspend! [Count: 0]',
1456 ]);
1457 await act(() => resolveText('Count: 0'));
1458 assertLog(['Count: 0']);
1459
1460 // Dispatch outside of a transition. This will trigger a loading state.
1461 await act(() => dispatch());
1462 assertLog([
1463 'Suspend! [Count: 1]',
1464 'Loading...',
1465 // pre-warming
1466 'Suspend! [Count: 1]',
1467 ]);
1468 expect(container.textContent).toBe('Loading...');
1469
1470 await act(() => resolveText('Count: 1'));
1471 assertLog(['Count: 1']);
1472 expect(container.textContent).toBe('Count: 1');
1473
1474 // Now dispatch inside of a transition. This one does not trigger a
1475 // loading state.
1476 await act(() => startTransition(() => dispatch()));
1477 assertLog(['Count: 1', 'Suspend! [Count: 2]', 'Loading...']);
1478 expect(container.textContent).toBe('Count: 1');
1479
1480 await act(() => resolveText('Count: 2'));
1481 assertLog(['Count: 2']);
1482 expect(container.textContent).toBe('Count: 2');
1483 });
1484
1485 it('useActionState warns if async action is dispatched outside of a transition', async () => {
1486 let dispatch;
1487 function App() {
1488 const [state, _dispatch] = useActionState(async () => {
1489 return state + 1;
1490 }, 0);
1491 dispatch = _dispatch;
1492 return <AsyncText text={'Count: ' + state} />;
1493 }
1494
1495 const root = ReactDOMClient.createRoot(container);
1496 await act(() => root.render(<App />));
1497 assertLog([
1498 'Suspend! [Count: 0]',
1499 // pre-warming
1500 'Suspend! [Count: 0]',
1501 ]);
1502 await act(() => resolveText('Count: 0'));
1503 assertLog(['Count: 0']);
1504
1505 // Dispatch outside of a transition.
1506 await act(() => dispatch());
1507 assertConsoleErrorDev([
1508 'An async function with useActionState was called outside of a transition. ' +
1509 'This is likely not what you intended (for example, isPending will not update ' +
1510 'correctly). Either call the returned function inside startTransition, or pass it ' +
1511 'to an `action` or `formAction` prop.',
1512 ]);
1513 assertLog([
1514 'Suspend! [Count: 1]',
1515 // pre-warming
1516 'Suspend! [Count: 1]',
1517 ]);
1518 expect(container.textContent).toBe('Count: 0');
1519 });
1520
1521 it('uncontrolled form inputs are reset after the action completes', async () => {
1522 const formRef = React.createRef();
1523 const inputRef = React.createRef();
1524 const divRef = React.createRef();
1525
1526 function App({promiseForUsername}) {
1527 // Make this suspensey to simulate RSC streaming.
1528 const username = use(promiseForUsername);
1529
1530 return (
1531 <form
1532 ref={formRef}
1533 action={async formData => {
1534 const rawUsername = formData.get('username');
1535 const normalizedUsername = rawUsername.trim().toLowerCase();
1536
1537 Scheduler.log(`Async action started`);
1538 await getText('Wait');
1539
1540 // Update the app with new data. This is analagous to re-rendering
1541 // from the root with a new RSC payload.
1542 startTransition(() => {
1543 root.render(
1544 <App promiseForUsername={getText(normalizedUsername)} />,
1545 );
1546 });
1547 }}>
1548 <input
1549 ref={inputRef}
1550 text="text"
1551 name="username"
1552 defaultValue={username}
1553 />
1554 <div ref={divRef}>
1555 <Text text={'Current username: ' + username} />
1556 </div>
1557 </form>
1558 );
1559 }
1560
1561 // Initial render
1562 const root = ReactDOMClient.createRoot(container);
1563 const promiseForInitialUsername = getText('(empty)');
1564 await resolveText('(empty)');
1565 await act(() =>
1566 root.render(<App promiseForUsername={promiseForInitialUsername} />),
1567 );
1568 assertLog(['Current username: (empty)']);
1569 expect(divRef.current.textContent).toEqual('Current username: (empty)');
1570
1571 // Dirty the uncontrolled input
1572 inputRef.current.value = ' AcdLite ';
1573
1574 // Submit the form. This will trigger an async action.
1575 await submit(formRef.current);
1576 assertLog(['Async action started']);
1577 expect(inputRef.current.value).toBe(' AcdLite ');
1578
1579 // Finish the async action. This will trigger a re-render from the root with
1580 // new data from the "server", which suspends.
1581 //
1582 // The form should not reset yet because we need to update `defaultValue`
1583 // first. So we wait for the render to complete.
1584 await act(() => resolveText('Wait'));
1585 assertLog([]);
1586 // The DOM input is still dirty.
1587 expect(inputRef.current.value).toBe(' AcdLite ');
1588 // The React tree is suspended.
1589 expect(divRef.current.textContent).toEqual('Current username: (empty)');
1590
1591 // Unsuspend and finish rendering. Now the form should be reset.
1592 await act(() => resolveText('acdlite'));
1593 assertLog(['Current username: acdlite']);
1594 // The form was reset to the new value from the server.
1595 expect(inputRef.current.value).toBe('acdlite');
1596 expect(divRef.current.textContent).toEqual('Current username: acdlite');
1597 });
1598
1599 it('should fire onReset on automatic form reset', async () => {
1600 const formRef = React.createRef();
1601 const inputRef = React.createRef();
1602
1603 let setValue;
1604 const defaultValue = 0;
1605 function App({promiseForUsername}) {
1606 const [value, _setValue] = useState(defaultValue);
1607 setValue = _setValue;
1608
1609 return (
1610 <form
1611 ref={formRef}
1612 action={async formData => {
1613 Scheduler.log(`Async action started`);
1614 await getText('Wait');
1615 }}
1616 onReset={() => {
1617 setValue(defaultValue);
1618 }}>
1619 <input
1620 ref={inputRef}
1621 text="text"
1622 name="amount"
1623 value={value}
1624 onChange={event => setValue(event.currentTarget.value)}
1625 />
1626 </form>
1627 );
1628 }
1629
1630 const root = ReactDOMClient.createRoot(container);
1631 await act(() => root.render(<App />));
1632
1633 // Dirty the controlled input
1634 await act(() => setValue('3'));
1635 expect(inputRef.current.value).toEqual('3');
1636
1637 // Submit the form. This will trigger an async action.
1638 await submit(formRef.current);
1639 assertLog(['Async action started']);
1640
1641 // We haven't reset yet.
1642 expect(inputRef.current.value).toEqual('3');
1643
1644 // Action completes. onReset has been fired and values reset manually.
1645 await act(() => resolveText('Wait'));
1646 assertLog([]);
1647 expect(inputRef.current.value).toEqual('0');
1648 });
1649
1650 it('requestFormReset schedules a form reset after transition completes', async () => {
1651 // This is the same as the previous test, except the form is updated with
1652 // a userspace action instead of a built-in form action.
1653
1654 const formRef = React.createRef();
1655 const inputRef = React.createRef();
1656 const divRef = React.createRef();
1657
1658 function App({promiseForUsername}) {
1659 // Make this suspensey to simulate RSC streaming.
1660 const username = use(promiseForUsername);
1661
1662 return (
1663 <form ref={formRef}>
1664 <input
1665 ref={inputRef}
1666 text="text"
1667 name="username"
1668 defaultValue={username}
1669 />
1670 <div ref={divRef}>
1671 <Text text={'Current username: ' + username} />
1672 </div>
1673 </form>
1674 );
1675 }
1676
1677 // Initial render
1678 const root = ReactDOMClient.createRoot(container);
1679 const promiseForInitialUsername = getText('(empty)');
1680 await resolveText('(empty)');
1681 await act(() =>
1682 root.render(<App promiseForUsername={promiseForInitialUsername} />),
1683 );
1684 assertLog(['Current username: (empty)']);
1685 expect(divRef.current.textContent).toEqual('Current username: (empty)');
1686
1687 // Dirty the uncontrolled input
1688 inputRef.current.value = ' AcdLite ';
1689
1690 // This is a userspace action. It does not trigger a real form submission.
1691 // The practical use case is implementing a custom action prop using
1692 // onSubmit without losing the built-in form resetting behavior.
1693 await act(() => {
1694 startTransition(async () => {
1695 const form = formRef.current;
1696 const formData = new FormData(form);
1697 requestFormReset(form);
1698
1699 const rawUsername = formData.get('username');
1700 const normalizedUsername = rawUsername.trim().toLowerCase();
1701
1702 Scheduler.log(`Async action started`);
1703 await getText('Wait');
1704
1705 // Update the app with new data. This is analagous to re-rendering
1706 // from the root with a new RSC payload.
1707 startTransition(() => {
1708 root.render(<App promiseForUsername={getText(normalizedUsername)} />);
1709 });
1710 });
1711 });
1712 assertLog(['Async action started']);
1713 expect(inputRef.current.value).toBe(' AcdLite ');
1714
1715 // Finish the async action. This will trigger a re-render from the root with
1716 // new data from the "server", which suspends.
1717 //
1718 // The form should not reset yet because we need to update `defaultValue`
1719 // first. So we wait for the render to complete.
1720 await act(() => resolveText('Wait'));
1721 assertLog([]);
1722 // The DOM input is still dirty.
1723 expect(inputRef.current.value).toBe(' AcdLite ');
1724 // The React tree is suspended.
1725 expect(divRef.current.textContent).toEqual('Current username: (empty)');
1726
1727 // Unsuspend and finish rendering. Now the form should be reset.
1728 await act(() => resolveText('acdlite'));
1729 assertLog(['Current username: acdlite']);
1730 // The form was reset to the new value from the server.
1731 expect(inputRef.current.value).toBe('acdlite');
1732 expect(divRef.current.textContent).toEqual('Current username: acdlite');
1733 });
1734
1735 it('parallel form submissions do not throw', async () => {
1736 const formRef = React.createRef();
1737 let resolve = null;
1738 function App() {
1739 async function submitForm() {
1740 Scheduler.log('Action');
1741 if (!resolve) {
1742 await new Promise(res => {
1743 resolve = res;
1744 });
1745 }
1746 }
1747 return <form ref={formRef} action={submitForm} />;
1748 }
1749 const root = ReactDOMClient.createRoot(container);
1750 await act(() => root.render(<App />));
1751
1752 // Start first form submission
1753 await act(async () => {
1754 formRef.current.requestSubmit();
1755 });
1756 assertLog(['Action']);
1757
1758 // Submit form again while first form action is still pending
1759 await act(async () => {
1760 formRef.current.requestSubmit();
1761 resolve(); // Resolve the promise to allow the first form action to complete
1762 });
1763 assertLog(['Action']);
1764 });
1765
1766 it(
1767 'requestFormReset works with inputs that are not descendants ' +
1768 'of the form element',
1769 async () => {
1770 // This is the same as the previous test, except the input is not a child
1771 // of the form; it's linked with <input form="myform" />
1772
1773 const formRef = React.createRef();
1774 const inputRef = React.createRef();
1775 const divRef = React.createRef();
1776
1777 function App({promiseForUsername}) {
1778 // Make this suspensey to simulate RSC streaming.
1779 const username = use(promiseForUsername);
1780
1781 return (
1782 <>
1783 <form id="myform" ref={formRef} />
1784 <input
1785 form="myform"
1786 ref={inputRef}
1787 text="text"
1788 name="username"
1789 defaultValue={username}
1790 />
1791 <div ref={divRef}>
1792 <Text text={'Current username: ' + username} />
1793 </div>
1794 </>
1795 );
1796 }
1797
1798 // Initial render
1799 const root = ReactDOMClient.createRoot(container);
1800 const promiseForInitialUsername = getText('(empty)');
1801 await resolveText('(empty)');
1802 await act(() =>
1803 root.render(<App promiseForUsername={promiseForInitialUsername} />),
1804 );
1805 assertLog(['Current username: (empty)']);
1806 expect(divRef.current.textContent).toEqual('Current username: (empty)');
1807
1808 // Dirty the uncontrolled input
1809 inputRef.current.value = ' AcdLite ';
1810
1811 // This is a userspace action. It does not trigger a real form submission.
1812 // The practical use case is implementing a custom action prop using
1813 // onSubmit without losing the built-in form resetting behavior.
1814 await act(() => {
1815 startTransition(async () => {
1816 const form = formRef.current;
1817 const formData = new FormData(form);
1818 requestFormReset(form);
1819
1820 const rawUsername = formData.get('username');
1821 const normalizedUsername = rawUsername.trim().toLowerCase();
1822
1823 Scheduler.log(`Async action started`);
1824 await getText('Wait');
1825
1826 // Update the app with new data. This is analagous to re-rendering
1827 // from the root with a new RSC payload.
1828 startTransition(() => {
1829 root.render(
1830 <App promiseForUsername={getText(normalizedUsername)} />,
1831 );
1832 });
1833 });
1834 });
1835 assertLog(['Async action started']);
1836 expect(inputRef.current.value).toBe(' AcdLite ');
1837
1838 // Finish the async action. This will trigger a re-render from the root with
1839 // new data from the "server", which suspends.
1840 //
1841 // The form should not reset yet because we need to update `defaultValue`
1842 // first. So we wait for the render to complete.
1843 await act(() => resolveText('Wait'));
1844 assertLog([]);
1845 // The DOM input is still dirty.
1846 expect(inputRef.current.value).toBe(' AcdLite ');
1847 // The React tree is suspended.
1848 expect(divRef.current.textContent).toEqual('Current username: (empty)');
1849
1850 // Unsuspend and finish rendering. Now the form should be reset.
1851 await act(() => resolveText('acdlite'));
1852 assertLog(['Current username: acdlite']);
1853 // The form was reset to the new value from the server.
1854 expect(inputRef.current.value).toBe('acdlite');
1855 expect(divRef.current.textContent).toEqual('Current username: acdlite');
1856 },
1857 );
1858
1859 it('reset multiple forms in the same transition', async () => {
1860 const formRefA = React.createRef();
1861 const formRefB = React.createRef();
1862
1863 function App({promiseForA, promiseForB}) {
1864 // Make these suspensey to simulate RSC streaming.
1865 const a = use(promiseForA);
1866 const b = use(promiseForB);
1867 return (
1868 <>
1869 <form ref={formRefA}>
1870 <input type="text" name="inputName" defaultValue={a} />
1871 </form>
1872 <form ref={formRefB}>
1873 <input type="text" name="inputName" defaultValue={b} />
1874 </form>
1875 </>
1876 );
1877 }
1878
1879 const root = ReactDOMClient.createRoot(container);
1880 const initialPromiseForA = getText('A1');
1881 const initialPromiseForB = getText('B1');
1882 await resolveText('A1');
1883 await resolveText('B1');
1884 await act(() =>
1885 root.render(
1886 <App
1887 promiseForA={initialPromiseForA}
1888 promiseForB={initialPromiseForB}
1889 />,
1890 ),
1891 );
1892
1893 // Dirty the uncontrolled inputs
1894 formRefA.current.elements.inputName.value = ' A2 ';
1895 formRefB.current.elements.inputName.value = ' B2 ';
1896
1897 // Trigger an async action that updates and reset both forms.
1898 await act(() => {
1899 startTransition(async () => {
1900 const currentA = formRefA.current.elements.inputName.value;
1901 const currentB = formRefB.current.elements.inputName.value;
1902
1903 requestFormReset(formRefA.current);
1904 requestFormReset(formRefB.current);
1905
1906 Scheduler.log('Async action started');
1907 await getText('Wait');
1908
1909 // Pretend the server did something with the data.
1910 const normalizedA = currentA.trim();
1911 const normalizedB = currentB.trim();
1912
1913 // Update the app with new data. This is analagous to re-rendering
1914 // from the root with a new RSC payload.
1915 startTransition(() => {
1916 root.render(
1917 <App
1918 promiseForA={getText(normalizedA)}
1919 promiseForB={getText(normalizedB)}
1920 />,
1921 );
1922 });
1923 });
1924 });
1925 assertLog(['Async action started']);
1926
1927 // Finish the async action. This will trigger a re-render from the root with
1928 // new data from the "server", which suspends.
1929 //
1930 // The forms should not reset yet because we need to update `defaultValue`
1931 // first. So we wait for the render to complete.
1932 await act(() => resolveText('Wait'));
1933
1934 // The DOM inputs are still dirty.
1935 expect(formRefA.current.elements.inputName.value).toBe(' A2 ');
1936 expect(formRefB.current.elements.inputName.value).toBe(' B2 ');
1937
1938 // Unsuspend and finish rendering. Now the forms should be reset.
1939 await act(() => {
1940 resolveText('A2');
1941 resolveText('B2');
1942 });
1943 // The forms were reset to the new value from the server.
1944 expect(formRefA.current.elements.inputName.value).toBe('A2');
1945 expect(formRefB.current.elements.inputName.value).toBe('B2');
1946 });
1947
1948 it('requestFormReset throws if the form is not managed by React', async () => {
1949 container.innerHTML = `
1950 <form id="myform">
1951 <input id="input" type="text" name="greeting" />
1952 </form>
1953 `;
1954
1955 const form = document.getElementById('myform');
1956 const input = document.getElementById('input');
1957
1958 input.value = 'Hi!!!!!!!!!!!!!';
1959
1960 expect(() => requestFormReset(form)).toThrow('Invalid form element.');
1961 // The form was not reset.
1962 expect(input.value).toBe('Hi!!!!!!!!!!!!!');
1963
1964 // Just confirming a regular form reset works fine.
1965 form.reset();
1966 expect(input.value).toBe('');
1967 });
1968
1969 it('requestFormReset throws on a non-form DOM element', async () => {
1970 const root = ReactDOMClient.createRoot(container);
1971 const ref = React.createRef();
1972 await act(() => root.render(<div ref={ref}>Hi</div>));
1973 const div = ref.current;
1974 expect(div.textContent).toBe('Hi');
1975
1976 expect(() => requestFormReset(div)).toThrow('Invalid form element.');
1977 });
1978
1979 it('warns if requestFormReset is called outside of a transition', async () => {
1980 const formRef = React.createRef();
1981 const inputRef = React.createRef();
1982
1983 function App() {
1984 return (
1985 <form ref={formRef}>
1986 <input ref={inputRef} type="text" defaultValue="Initial" />
1987 </form>
1988 );
1989 }
1990
1991 const root = ReactDOMClient.createRoot(container);
1992 await act(() => root.render(<App />));
1993
1994 // Dirty the uncontrolled input
1995 inputRef.current.value = ' Updated ';
1996
1997 // Trigger an async action that updates and reset both forms.
1998 await act(() => {
1999 startTransition(async () => {
2000 Scheduler.log('Action started');
2001 await getText('Wait 1');
2002 Scheduler.log('Request form reset');
2003
2004 // This happens after an `await`, and is not wrapped in startTransition,
2005 // so it will be scheduled synchronously instead of with the transition.
2006 // This is almost certainly a mistake, so we log a warning in dev.
2007 requestFormReset(formRef.current);
2008
2009 await getText('Wait 2');
2010 Scheduler.log('Action finished');
2011 });
2012 });
2013 assertLog(['Action started']);
2014 expect(inputRef.current.value).toBe(' Updated ');
2015
2016 // This triggers a synchronous requestFormReset, and a warning
2017 await act(() => resolveText('Wait 1'));
2018 assertConsoleErrorDev([
2019 'requestFormReset was called outside a transition or action. ' +
2020 'To fix, move to an action, or wrap with startTransition.',
2021 ]);
2022 assertLog(['Request form reset']);
2023
2024 // The form was reset even though the action didn't finish.
2025 expect(inputRef.current.value).toBe('Initial');
2026 });
2027
2028 it("regression: submitter's formAction prop is coerced correctly before checking if it exists", async () => {
2029 function App({submitterAction}) {
2030 return (
2031 <form action={() => Scheduler.log('Form action')}>
2032 <button ref={buttonRef} type="submit" formAction={submitterAction} />
2033 </form>
2034 );
2035 }
2036
2037 const buttonRef = React.createRef();
2038 const root = ReactDOMClient.createRoot(container);
2039
2040 await act(() =>
2041 root.render(
2042 <App submitterAction={() => Scheduler.log('Button action')} />,
2043 ),
2044 );
2045 await submit(buttonRef.current);
2046 assertLog(['Button action']);
2047
2048 // When there's no button action, the form action should fire
2049 await act(() => root.render(<App submitterAction={null} />));
2050 await submit(buttonRef.current);
2051 assertLog(['Form action']);
2052
2053 // Symbols are coerced to null, so this should fire the form action
2054 await act(() => root.render(<App submitterAction={Symbol()} />));
2055 assertConsoleErrorDev([
2056 'Invalid value for prop `formAction` on <button> tag. ' +
2057 'Either remove it from the element, or pass a string or number value to keep it in the DOM. ' +
2058 'For details, see https://react.dev/link/attribute-behavior \n' +
2059 ' in button (at **)\n' +
2060 ' in App (at **)',
2061 ]);
2062 await submit(buttonRef.current);
2063 assertLog(['Form action']);
2064
2065 // Booleans are coerced to null, so this should fire the form action
2066 await act(() => root.render(<App submitterAction={true} />));
2067 await submit(buttonRef.current);
2068 assertLog(['Form action']);
2069
2070 // A string on the submitter should prevent the form action from firing
2071 // and trigger the native behavior
2072 await act(() => root.render(<App submitterAction="https://react.dev/" />));
2073 await expect(submit(buttonRef.current)).rejects.toThrow(
2074 'Navigate to: https://react.dev/',
2075 );
2076 });
2077
2078 it(
2079 'useFormStatus is activated if startTransition is called ' +
2080 'inside preventDefault-ed submit event',
2081 async () => {
2082 function Output({value}) {
2083 const {pending} = useFormStatus();
2084 return <Text text={pending ? `${value} (pending...)` : value} />;
2085 }
2086
2087 function App({value}) {
2088 const [, startFormTransition] = useTransition();
2089
2090 function onSubmit(event) {
2091 event.preventDefault();
2092 startFormTransition(async () => {
2093 const updatedValue = event.target.elements.search.value;
2094 Scheduler.log('Action started');
2095 await getText('Wait');
2096 Scheduler.log('Action finished');
2097 startTransition(() => root.render(<App value={updatedValue} />));
2098 });
2099 }
2100 return (
2101 <form ref={formRef} onSubmit={onSubmit}>
2102 <input
2103 ref={inputRef}
2104 type="text"
2105 name="search"
2106 defaultValue={value}
2107 />
2108 <div ref={outputRef}>
2109 <Output value={value} />
2110 </div>
2111 </form>
2112 );
2113 }
2114
2115 const formRef = React.createRef();
2116 const inputRef = React.createRef();
2117 const outputRef = React.createRef();
2118 const root = ReactDOMClient.createRoot(container);
2119 await act(() => root.render(<App value="Initial" />));
2120 assertLog(['Initial']);
2121
2122 // Update the input to something different
2123 inputRef.current.value = 'Updated';
2124
2125 // Submit the form.
2126 await submit(formRef.current);
2127 // The form switches into a pending state.
2128 assertLog(['Action started', 'Initial (pending...)']);
2129 expect(outputRef.current.textContent).toBe('Initial (pending...)');
2130
2131 // While the submission is still pending, update the input again so we
2132 // can check whether the form is reset after the action finishes.
2133 inputRef.current.value = 'Updated again after submission';
2134
2135 // Resolve the async action
2136 await act(() => resolveText('Wait'));
2137 assertLog(['Action finished', 'Updated']);
2138 expect(outputRef.current.textContent).toBe('Updated');
2139
2140 // Confirm that the form was not automatically reset (should call
2141 // requestFormReset(formRef.current) to opt into this behavior)
2142 expect(inputRef.current.value).toBe('Updated again after submission');
2143 },
2144 );
2145
2146 it('useFormStatus is not activated if startTransition is not called', async () => {
2147 function Output({value}) {
2148 const {pending} = useFormStatus();
2149
2150 return (
2151 <Text
2152 text={
2153 pending
2154 ? 'Should be unreachable! This test should never activate the pending state.'
2155 : value
2156 }
2157 />
2158 );
2159 }
2160
2161 function App({value}) {
2162 async function onSubmit(event) {
2163 event.preventDefault();
2164 const updatedValue = event.target.elements.search.value;
2165 Scheduler.log('Async event handler started');
2166 await getText('Wait');
2167 Scheduler.log('Async event handler finished');
2168 startTransition(() => root.render(<App value={updatedValue} />));
2169 }
2170 return (
2171 <form ref={formRef} onSubmit={onSubmit}>
2172 <input
2173 ref={inputRef}
2174 type="text"
2175 name="search"
2176 defaultValue={value}
2177 />
2178 <div ref={outputRef}>
2179 <Output value={value} />
2180 </div>
2181 </form>
2182 );
2183 }
2184
2185 const formRef = React.createRef();
2186 const inputRef = React.createRef();
2187 const outputRef = React.createRef();
2188 const root = ReactDOMClient.createRoot(container);
2189 await act(() => root.render(<App value="Initial" />));
2190 assertLog(['Initial']);
2191
2192 // Update the input to something different
2193 inputRef.current.value = 'Updated';
2194
2195 // Submit the form.
2196 await submit(formRef.current);
2197 // Unlike the previous test, which uses startTransition to manually dispatch
2198 // an action, this test uses a regular event handler, so useFormStatus is
2199 // not activated.
2200 assertLog(['Async event handler started']);
2201 expect(outputRef.current.textContent).toBe('Initial');
2202
2203 // While the submission is still pending, update the input again so we
2204 // can check whether the form is reset after the action finishes.
2205 inputRef.current.value = 'Updated again after submission';
2206
2207 // Resolve the async action
2208 await act(() => resolveText('Wait'));
2209 assertLog(['Async event handler finished', 'Updated']);
2210 expect(outputRef.current.textContent).toBe('Updated');
2211
2212 // Confirm that the form was not automatically reset (should call
2213 // requestFormReset(formRef.current) to opt into this behavior)
2214 expect(inputRef.current.value).toBe('Updated again after submission');
2215 });
2216
2217 it('useFormStatus is not activated if event is not preventDefault-ed', async () => {
2218 function Output({value}) {
2219 const {pending} = useFormStatus();
2220 return <Text text={pending ? `${value} (pending...)` : value} />;
2221 }
2222
2223 function App({value}) {
2224 const [, startFormTransition] = useTransition();
2225
2226 function onSubmit(event) {
2227 // This event is not preventDefault-ed, so the default form submission
2228 // happens, and useFormStatus is not activated.
2229 startFormTransition(async () => {
2230 const updatedValue = event.target.elements.search.value;
2231 Scheduler.log('Action started');
2232 await getText('Wait');
2233 Scheduler.log('Action finished');
2234 startTransition(() => root.render(<App value={updatedValue} />));
2235 });
2236 }
2237 return (
2238 <form ref={formRef} onSubmit={onSubmit}>
2239 <input
2240 ref={inputRef}
2241 type="text"
2242 name="search"
2243 defaultValue={value}
2244 />
2245 <div ref={outputRef}>
2246 <Output value={value} />
2247 </div>
2248 </form>
2249 );
2250 }
2251
2252 const formRef = React.createRef();
2253 const inputRef = React.createRef();
2254 const outputRef = React.createRef();
2255 const root = ReactDOMClient.createRoot(container);
2256 await act(() => root.render(<App value="Initial" />));
2257 assertLog(['Initial']);
2258
2259 // Update the input to something different
2260 inputRef.current.value = 'Updated';
2261
2262 // Submitting the form should trigger the default navigation behavior
2263 await expect(submit(formRef.current)).rejects.toThrow(
2264 'Navigate to: http://localhost/',
2265 );
2266
2267 // The useFormStatus hook was not activated
2268 assertLog(['Action started', 'Initial']);
2269 expect(outputRef.current.textContent).toBe('Initial');
2270 });
2271
2272 it('useFormStatus coerces the value of the "action" prop', async () => {
2273 function Status() {
2274 const {pending, action} = useFormStatus();
2275
2276 if (pending) {
2277 Scheduler.log(action);
2278 return 'Pending';
2279 } else {
2280 return 'Not pending';
2281 }
2282 }
2283
2284 function Form({action}) {
2285 const [, startFormTransition] = useTransition();
2286
2287 function onSubmit(event) {
2288 event.preventDefault();
2289 // Schedule an empty action for no other purpose than to trigger the
2290 // pending state.
2291 startFormTransition(async () => {});
2292 }
2293 return (
2294 <form ref={formRef} action={action} onSubmit={onSubmit}>
2295 <Status />
2296 </form>
2297 );
2298 }
2299
2300 const formRef = React.createRef();
2301 const root = ReactDOMClient.createRoot(container);
2302
2303 // Symbols are coerced to null
2304 await act(() => root.render(<Form action={Symbol()} />));
2305 assertConsoleErrorDev([
2306 'Invalid value for prop `action` on <form> tag. ' +
2307 'Either remove it from the element, or pass a string or number value to keep it in the DOM. ' +
2308 'For details, see https://react.dev/link/attribute-behavior \n' +
2309 ' in form (at **)\n' +
2310 ' in Form (at **)',
2311 ]);
2312 await submit(formRef.current);
2313 assertLog([null]);
2314
2315 // Booleans are coerced to null
2316 await act(() => root.render(<Form action={true} />));
2317 await submit(formRef.current);
2318 assertLog([null]);
2319
2320 // Strings are passed through
2321 await act(() => root.render(<Form action="https://react.dev" />));
2322 await submit(formRef.current);
2323 assertLog(['https://react.dev']);
2324
2325 // Functions are passed through
2326 const actionFn = () => {};
2327 await act(() => root.render(<Form action={actionFn} />));
2328 await submit(formRef.current);
2329 assertLog([actionFn]);
2330
2331 // Everything else is toString-ed, unless trusted types are enabled.
2332 class MyAction {
2333 toString() {
2334 return 'stringified action';
2335 }
2336 }
2337 const instance = new MyAction();
2338
2339 await act(() => root.render(<Form action={instance} />));
2340 await submit(formRef.current);
2341 assertLog(
2342 gate('enableTrustedTypesIntegration')
2343 ? [instance]
2344 : ['stringified action'],
2345 );
2346 });
2347
2348 it('form actions should retain status when nested state changes', async () => {
2349 const formRef = React.createRef();
2350
2351 let rerenderUnrelatedStatus;
2352 function UnrelatedStatus() {
2353 const {pending} = useFormStatus();
2354 const [counter, setCounter] = useState(0);
2355 rerenderUnrelatedStatus = () => setCounter(n => n + 1);
2356 Scheduler.log(`[unrelated form] pending: ${pending}, state: ${counter}`);
2357 }
2358
2359 let rerenderTargetStatus;
2360 function TargetStatus() {
2361 const {pending} = useFormStatus();
2362 const [counter, setCounter] = useState(0);
2363 Scheduler.log(`[target form] pending: ${pending}, state: ${counter}`);
2364 rerenderTargetStatus = () => setCounter(n => n + 1);
2365 }
2366
2367 function App() {
2368 async function action() {
2369 return new Promise(resolve => {
2370 // never resolves
2371 });
2372 }
2373
2374 return (
2375 <>
2376 <form action={action} ref={formRef}>
2377 <input type="submit" />
2378 <TargetStatus />
2379 </form>
2380 <form>
2381 <UnrelatedStatus />
2382 </form>
2383 </>
2384 );
2385 }
2386
2387 const root = ReactDOMClient.createRoot(container);
2388 await act(() => root.render(<App />));
2389
2390 assertLog([
2391 '[target form] pending: false, state: 0',
2392 '[unrelated form] pending: false, state: 0',
2393 ]);
2394
2395 await submit(formRef.current);
2396
2397 assertLog(['[target form] pending: true, state: 0']);
2398
2399 await act(() => rerenderTargetStatus());
2400
2401 assertLog(['[target form] pending: true, state: 1']);
2402
2403 await act(() => rerenderUnrelatedStatus());
2404
2405 assertLog(['[unrelated form] pending: false, state: 1']);
2406 });
2407 });