main
js 755 lines 23.8 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 import {insertNodesAndExecuteScripts} from '../test-utils/FizzTestUtils';
13 import {patchMessageChannel} from '../../../../scripts/jest/patchMessageChannel';
14
15 // Polyfills for test environment
16 global.ReadableStream =
17 require('web-streams-polyfill/ponyfill/es6').ReadableStream;
18 global.TextEncoder = require('util').TextEncoder;
19
20 let act;
21 let serverAct;
22 let container;
23 let React;
24 let ReactDOMServer;
25 let ReactDOMClient;
26 let Suspense;
27 let useFormStatus;
28 let useOptimistic;
29 let useActionState;
30 let assertConsoleErrorDev;
31
32 describe('ReactDOMFizzForm', () => {
33 beforeEach(() => {
34 jest.resetModules();
35 patchMessageChannel();
36 React = require('react');
37 ReactDOMServer = require('react-dom/server.browser');
38 ReactDOMClient = require('react-dom/client');
39 Suspense = React.Suspense;
40 useFormStatus = require('react-dom').useFormStatus;
41 useOptimistic = require('react').useOptimistic;
42 act = require('internal-test-utils').act;
43 serverAct = require('internal-test-utils').serverAct;
44 assertConsoleErrorDev =
45 require('internal-test-utils').assertConsoleErrorDev;
46 container = document.createElement('div');
47 document.body.appendChild(container);
48 // TODO: Test the old api but it warns so needs warnings to be asserted.
49 // if (__VARIANT__) {
50 // Remove after API is deleted.
51 // useActionState = require('react-dom').useFormState;
52 // }
53 useActionState = require('react').useActionState;
54 });
55
56 afterEach(() => {
57 document.body.removeChild(container);
58 });
59
60 function submit(submitter) {
61 const form = submitter.form || submitter;
62 if (!submitter.form) {
63 submitter = undefined;
64 }
65 const submitEvent = new Event('submit', {bubbles: true, cancelable: true});
66 submitEvent.submitter = submitter;
67 const returnValue = form.dispatchEvent(submitEvent);
68 if (!returnValue) {
69 return;
70 }
71 const action =
72 (submitter && submitter.getAttribute('formaction')) || form.action;
73 if (!/\s*javascript:/i.test(action)) {
74 throw new Error('Navigate to: ' + action);
75 }
76 }
77
78 async function readIntoContainer(stream) {
79 const reader = stream.getReader();
80 let result = '';
81 while (true) {
82 const {done, value} = await reader.read();
83 if (done) {
84 break;
85 }
86 result += Buffer.from(value).toString('utf8');
87 }
88 const temp = document.createElement('div');
89 temp.innerHTML = result;
90 insertNodesAndExecuteScripts(temp, container, null);
91 }
92
93 it('should allow passing a function to form action during SSR', async () => {
94 const ref = React.createRef();
95 let foo;
96
97 function action(formData) {
98 foo = formData.get('foo');
99 }
100 function App() {
101 return (
102 <form action={action} ref={ref}>
103 <input type="text" name="foo" defaultValue="bar" />
104 </form>
105 );
106 }
107
108 const stream = await serverAct(() =>
109 ReactDOMServer.renderToReadableStream(<App />),
110 );
111 await readIntoContainer(stream);
112 await act(async () => {
113 ReactDOMClient.hydrateRoot(container, <App />);
114 });
115
116 submit(ref.current);
117
118 expect(foo).toBe('bar');
119 });
120
121 it('should allow passing a function to an input/button formAction', async () => {
122 const inputRef = React.createRef();
123 const buttonRef = React.createRef();
124 let rootActionCalled = false;
125 let savedTitle = null;
126 let deletedTitle = null;
127
128 function action(formData) {
129 rootActionCalled = true;
130 }
131
132 function saveItem(formData) {
133 savedTitle = formData.get('title');
134 }
135
136 function deleteItem(formData) {
137 deletedTitle = formData.get('title');
138 }
139
140 function App() {
141 return (
142 <form action={action}>
143 <input type="text" name="title" defaultValue="Hello" />
144 <input
145 type="submit"
146 formAction={saveItem}
147 value="Save"
148 ref={inputRef}
149 />
150 <button formAction={deleteItem} ref={buttonRef}>
151 Delete
152 </button>
153 </form>
154 );
155 }
156
157 const stream = await serverAct(() =>
158 ReactDOMServer.renderToReadableStream(<App />),
159 );
160 await readIntoContainer(stream);
161 await act(async () => {
162 ReactDOMClient.hydrateRoot(container, <App />);
163 });
164
165 expect(savedTitle).toBe(null);
166 expect(deletedTitle).toBe(null);
167
168 submit(inputRef.current);
169 expect(savedTitle).toBe('Hello');
170 expect(deletedTitle).toBe(null);
171 savedTitle = null;
172
173 submit(buttonRef.current);
174 expect(savedTitle).toBe(null);
175 expect(deletedTitle).toBe('Hello');
176 deletedTitle = null;
177
178 expect(rootActionCalled).toBe(false);
179 });
180
181 it('should warn when passing a function action during SSR and string during hydration', async () => {
182 function action(formData) {}
183 function App({isClient}) {
184 return (
185 <form action={isClient ? 'action' : action}>
186 <input type="text" name="foo" defaultValue="bar" />
187 </form>
188 );
189 }
190
191 const stream = await serverAct(() =>
192 ReactDOMServer.renderToReadableStream(<App />),
193 );
194 await readIntoContainer(stream);
195 await act(async () => {
196 ReactDOMClient.hydrateRoot(container, <App isClient={true} />);
197 });
198 assertConsoleErrorDev([
199 "A tree hydrated but some attributes of the server rendered HTML didn't match the client properties. " +
200 "This won't be patched up. This can happen if a SSR-ed Client Component used:\n\n" +
201 "- A server/client branch `if (typeof window !== 'undefined')`.\n" +
202 "- Variable input such as `Date.now()` or `Math.random()` which changes each time it's called.\n" +
203 "- Date formatting in a user's locale which doesn't match the server.\n" +
204 '- External changing data without sending a snapshot of it along with the HTML.\n' +
205 '- Invalid HTML tag nesting.\n\n' +
206 'It can also happen if the client has a browser extension installed which messes with the HTML before React loaded.\n\n' +
207 'https://react.dev/link/hydration-mismatch\n\n' +
208 ' <App isClient={true}>\n' +
209 ' <form\n' +
210 '+ action="action"\n' +
211 '- action="function"\n' +
212 ' >\n' +
213 '\n in form (at **)' +
214 '\n in App (at **)',
215 ]);
216 });
217
218 it('should ideally warn when passing a string during SSR and function during hydration', async () => {
219 function action(formData) {}
220 function App({isClient}) {
221 return (
222 <form action={isClient ? action : 'action'}>
223 <input type="text" name="foo" defaultValue="bar" />
224 </form>
225 );
226 }
227
228 const stream = await serverAct(() =>
229 ReactDOMServer.renderToReadableStream(<App />),
230 );
231 await readIntoContainer(stream);
232 // This should ideally warn because only the client provides a function that doesn't line up.
233 await act(async () => {
234 ReactDOMClient.hydrateRoot(container, <App isClient={true} />);
235 });
236 });
237
238 it('should reset form fields after you update away from hydrated function', async () => {
239 const formRef = React.createRef();
240 const inputRef = React.createRef();
241 const buttonRef = React.createRef();
242 function action(formData) {}
243 function App({isUpdate}) {
244 return (
245 <form
246 action={isUpdate ? 'action' : action}
247 ref={formRef}
248 method={isUpdate ? 'POST' : null}>
249 <input
250 type="submit"
251 formAction={isUpdate ? 'action' : action}
252 ref={inputRef}
253 formTarget={isUpdate ? 'elsewhere' : null}
254 />
255 <button
256 formAction={isUpdate ? 'action' : action}
257 ref={buttonRef}
258 formEncType={isUpdate ? 'multipart/form-data' : null}
259 />
260 </form>
261 );
262 }
263
264 const stream = await serverAct(() =>
265 ReactDOMServer.renderToReadableStream(<App />),
266 );
267 await readIntoContainer(stream);
268 let root;
269 await act(async () => {
270 root = ReactDOMClient.hydrateRoot(container, <App />);
271 });
272 await act(async () => {
273 root.render(<App isUpdate={true} />);
274 });
275 expect(formRef.current.getAttribute('action')).toBe('action');
276 expect(formRef.current.hasAttribute('encType')).toBe(false);
277 expect(formRef.current.getAttribute('method')).toBe('POST');
278 expect(formRef.current.hasAttribute('target')).toBe(false);
279
280 expect(inputRef.current.getAttribute('formAction')).toBe('action');
281 expect(inputRef.current.hasAttribute('name')).toBe(false);
282 expect(inputRef.current.hasAttribute('formEncType')).toBe(false);
283 expect(inputRef.current.hasAttribute('formMethod')).toBe(false);
284 expect(inputRef.current.getAttribute('formTarget')).toBe('elsewhere');
285
286 expect(buttonRef.current.getAttribute('formAction')).toBe('action');
287 expect(buttonRef.current.hasAttribute('name')).toBe(false);
288 expect(buttonRef.current.getAttribute('formEncType')).toBe(
289 'multipart/form-data',
290 );
291 expect(buttonRef.current.hasAttribute('formMethod')).toBe(false);
292 expect(buttonRef.current.hasAttribute('formTarget')).toBe(false);
293 });
294
295 it('should reset form fields after you remove a hydrated function', async () => {
296 const formRef = React.createRef();
297 const inputRef = React.createRef();
298 const buttonRef = React.createRef();
299 function action(formData) {}
300 function App({isUpdate}) {
301 return (
302 <form action={isUpdate ? undefined : action} ref={formRef}>
303 <input
304 type="submit"
305 formAction={isUpdate ? undefined : action}
306 ref={inputRef}
307 />
308 <button formAction={isUpdate ? undefined : action} ref={buttonRef} />
309 </form>
310 );
311 }
312
313 const stream = await serverAct(() =>
314 ReactDOMServer.renderToReadableStream(<App />),
315 );
316 await readIntoContainer(stream);
317 let root;
318 await act(async () => {
319 root = ReactDOMClient.hydrateRoot(container, <App />);
320 });
321 await act(async () => {
322 root.render(<App isUpdate={true} />);
323 });
324 expect(formRef.current.hasAttribute('action')).toBe(false);
325 expect(formRef.current.hasAttribute('encType')).toBe(false);
326 expect(formRef.current.hasAttribute('method')).toBe(false);
327 expect(formRef.current.hasAttribute('target')).toBe(false);
328
329 expect(inputRef.current.hasAttribute('formAction')).toBe(false);
330 expect(inputRef.current.hasAttribute('name')).toBe(false);
331 expect(inputRef.current.hasAttribute('formEncType')).toBe(false);
332 expect(inputRef.current.hasAttribute('formMethod')).toBe(false);
333 expect(inputRef.current.hasAttribute('formTarget')).toBe(false);
334
335 expect(buttonRef.current.hasAttribute('formAction')).toBe(false);
336 expect(buttonRef.current.hasAttribute('name')).toBe(false);
337 expect(buttonRef.current.hasAttribute('formEncType')).toBe(false);
338 expect(buttonRef.current.hasAttribute('formMethod')).toBe(false);
339 expect(buttonRef.current.hasAttribute('formTarget')).toBe(false);
340 });
341
342 it('should restore the form fields even if they were incorrectly set', async () => {
343 const formRef = React.createRef();
344 const inputRef = React.createRef();
345 const buttonRef = React.createRef();
346 function action(formData) {}
347 function App({isUpdate}) {
348 return (
349 <form
350 action={isUpdate ? 'action' : action}
351 ref={formRef}
352 method="DELETE">
353 <input
354 type="submit"
355 formAction={isUpdate ? 'action' : action}
356 ref={inputRef}
357 formTarget="elsewhere"
358 />
359 <button
360 formAction={isUpdate ? 'action' : action}
361 ref={buttonRef}
362 formEncType="text/plain"
363 />
364 </form>
365 );
366 }
367
368 // Specifying the extra form fields are a DEV error, but we expect it
369 // to eventually still be patched up after an update.
370 const stream = await serverAct(() =>
371 ReactDOMServer.renderToReadableStream(<App />),
372 );
373 await readIntoContainer(stream);
374 assertConsoleErrorDev([
375 'Cannot specify a encType or method for a form that specifies a function as the action. ' +
376 'React provides those automatically. They will get overridden.\n' +
377 ' in form (at **)\n' +
378 ' in App (at **)',
379 'Cannot specify a formTarget for a button that specifies a function as a formAction. ' +
380 'The function will always be executed in the same window.\n' +
381 ' in input (at **)\n' +
382 ' in App (at **)',
383 ]);
384 let root;
385 await act(async () => {
386 root = ReactDOMClient.hydrateRoot(container, <App />);
387 });
388 assertConsoleErrorDev([
389 "A tree hydrated but some attributes of the server rendered HTML didn't match the client properties. " +
390 "This won't be patched up. This can happen if a SSR-ed Client Component used:\n\n" +
391 "- A server/client branch `if (typeof window !== 'undefined')`.\n" +
392 "- Variable input such as `Date.now()` or `Math.random()` which changes each time it's called.\n" +
393 "- Date formatting in a user's locale which doesn't match the server.\n" +
394 '- External changing data without sending a snapshot of it along with the HTML.\n' +
395 '- Invalid HTML tag nesting.\n\n' +
396 'It can also happen if the client has a browser extension installed which messes with the HTML before React loaded.\n\n' +
397 'https://react.dev/link/hydration-mismatch\n\n' +
398 ' <App>\n' +
399 ' <form\n' +
400 ' action={function action}\n' +
401 ' ref={{current:null}}\n' +
402 '+ method="DELETE"\n' +
403 '- method={null}\n' +
404 ' >\n' +
405 ' <input\n' +
406 ' type="submit"\n' +
407 ' formAction={function action}\n' +
408 ' ref={{current:null}}\n' +
409 '+ formTarget="elsewhere"\n' +
410 '- formTarget={null}\n' +
411 ' >\n' +
412 ' <button\n' +
413 ' formAction={function action}\n' +
414 ' ref={{current:null}}\n' +
415 '+ formEncType="text/plain"\n' +
416 '- formEncType={null}\n' +
417 ' >\n' +
418 '\n in input (at **)' +
419 '\n in App (at **)',
420 ]);
421 await act(async () => {
422 root.render(<App isUpdate={true} />);
423 });
424 expect(formRef.current.getAttribute('action')).toBe('action');
425 expect(formRef.current.hasAttribute('encType')).toBe(false);
426 expect(formRef.current.getAttribute('method')).toBe('DELETE');
427 expect(formRef.current.hasAttribute('target')).toBe(false);
428
429 expect(inputRef.current.getAttribute('formAction')).toBe('action');
430 expect(inputRef.current.hasAttribute('name')).toBe(false);
431 expect(inputRef.current.hasAttribute('formEncType')).toBe(false);
432 expect(inputRef.current.hasAttribute('formMethod')).toBe(false);
433 expect(inputRef.current.getAttribute('formTarget')).toBe('elsewhere');
434
435 expect(buttonRef.current.getAttribute('formAction')).toBe('action');
436 expect(buttonRef.current.hasAttribute('name')).toBe(false);
437 expect(buttonRef.current.getAttribute('formEncType')).toBe('text/plain');
438 expect(buttonRef.current.hasAttribute('formMethod')).toBe(false);
439 expect(buttonRef.current.hasAttribute('formTarget')).toBe(false);
440 });
441
442 it('useFormStatus is not pending during server render', async () => {
443 function App() {
444 const {pending} = useFormStatus();
445 return 'Pending: ' + pending;
446 }
447
448 const stream = await serverAct(() =>
449 ReactDOMServer.renderToReadableStream(<App />),
450 );
451 await readIntoContainer(stream);
452 expect(container.textContent).toBe('Pending: false');
453
454 await act(() => ReactDOMClient.hydrateRoot(container, <App />));
455 expect(container.textContent).toBe('Pending: false');
456 });
457
458 it('should replay a form action after hydration', async () => {
459 let foo;
460 function action(formData) {
461 foo = formData.get('foo');
462 }
463 function App() {
464 return (
465 <form action={action}>
466 <input type="text" name="foo" defaultValue="bar" />
467 </form>
468 );
469 }
470
471 const stream = await serverAct(() =>
472 ReactDOMServer.renderToReadableStream(<App />),
473 );
474 await readIntoContainer(stream);
475
476 // Dispatch an event before hydration
477 submit(container.getElementsByTagName('form')[0]);
478
479 await act(async () => {
480 ReactDOMClient.hydrateRoot(container, <App />);
481 });
482
483 // It should've now been replayed
484 expect(foo).toBe('bar');
485 });
486
487 it('should replay input/button formAction', async () => {
488 let rootActionCalled = false;
489 let savedTitle = null;
490 let deletedTitle = null;
491
492 function action(formData) {
493 rootActionCalled = true;
494 }
495
496 function saveItem(formData) {
497 savedTitle = formData.get('title');
498 }
499
500 function deleteItem(formData) {
501 deletedTitle = formData.get('title');
502 }
503
504 function App() {
505 return (
506 <form action={action}>
507 <input type="text" name="title" defaultValue="Hello" />
508 <input type="submit" formAction={saveItem} value="Save" />
509 <button formAction={deleteItem}>Delete</button>
510 </form>
511 );
512 }
513
514 const stream = await serverAct(() =>
515 ReactDOMServer.renderToReadableStream(<App />),
516 );
517 await readIntoContainer(stream);
518
519 submit(container.getElementsByTagName('input')[1]);
520 submit(container.getElementsByTagName('button')[0]);
521
522 await act(async () => {
523 ReactDOMClient.hydrateRoot(container, <App />);
524 });
525
526 expect(savedTitle).toBe('Hello');
527 expect(deletedTitle).toBe('Hello');
528 expect(rootActionCalled).toBe(false);
529 });
530
531 it('useOptimistic returns passthrough value', async () => {
532 function App() {
533 const [optimisticState] = useOptimistic('hi');
534 return optimisticState;
535 }
536
537 const stream = await serverAct(() =>
538 ReactDOMServer.renderToReadableStream(<App />),
539 );
540 await readIntoContainer(stream);
541 expect(container.textContent).toBe('hi');
542
543 await act(async () => {
544 ReactDOMClient.hydrateRoot(container, <App />);
545 });
546 expect(container.textContent).toBe('hi');
547 });
548
549 it('useActionState returns initial state', async () => {
550 async function action(state) {
551 return state;
552 }
553
554 function App() {
555 const [state] = useActionState(action, 0);
556 return state;
557 }
558
559 const stream = await serverAct(() =>
560 ReactDOMServer.renderToReadableStream(<App />),
561 );
562 await readIntoContainer(stream);
563 expect(container.textContent).toBe('0');
564
565 await act(async () => {
566 ReactDOMClient.hydrateRoot(container, <App />);
567 });
568 expect(container.textContent).toBe('0');
569 });
570
571 it('can provide a custom action on the server for actions', async () => {
572 const ref = React.createRef();
573 let foo;
574
575 function action(formData) {
576 foo = formData.get('foo');
577 }
578 action.$$FORM_ACTION = function (identifierPrefix) {
579 const extraFields = new FormData();
580 extraFields.append(identifierPrefix + 'hello', 'world');
581 return {
582 action: this.name,
583 name: identifierPrefix,
584 method: 'POST',
585 encType: 'multipart/form-data',
586 target: 'self',
587 data: extraFields,
588 };
589 };
590 function App() {
591 return (
592 <form action={action} ref={ref} method={null}>
593 <Suspense />
594 <input type="text" name="foo" defaultValue="bar" />
595 </form>
596 );
597 }
598
599 const stream = await serverAct(() =>
600 ReactDOMServer.renderToReadableStream(<App />),
601 );
602 await readIntoContainer(stream);
603
604 const form = container.firstChild;
605 expect(form.getAttribute('action')).toBe('action');
606 expect(form.getAttribute('method')).toBe('POST');
607 expect(form.getAttribute('enctype')).toBe('multipart/form-data');
608 expect(form.getAttribute('target')).toBe('self');
609 const formActionName = form.firstChild.getAttribute('name');
610 expect(
611 container
612 .querySelector('input[name="' + formActionName + 'hello"]')
613 .getAttribute('value'),
614 ).toBe('world');
615
616 await act(async () => {
617 ReactDOMClient.hydrateRoot(container, <App />);
618 });
619
620 submit(ref.current);
621
622 expect(foo).toBe('bar');
623 });
624
625 it('can provide a custom action on buttons the server for actions', async () => {
626 const hiddenRef = React.createRef();
627 const inputRef = React.createRef();
628 const buttonRef = React.createRef();
629 let foo;
630
631 function action(formData) {
632 foo = formData.get('foo');
633 }
634 action.$$FORM_ACTION = function (identifierPrefix) {
635 const extraFields = new FormData();
636 extraFields.append(identifierPrefix + 'hello', 'world');
637 return {
638 action: this.name,
639 name: identifierPrefix,
640 method: 'POST',
641 encType: 'multipart/form-data',
642 target: 'self',
643 data: extraFields,
644 };
645 };
646 function App() {
647 return (
648 <form>
649 <input type="hidden" name="foo" value="bar" ref={hiddenRef} />
650 <input
651 type="submit"
652 formAction={action}
653 method={null}
654 ref={inputRef}
655 />
656 <button formAction={action} ref={buttonRef} target={null} />
657 </form>
658 );
659 }
660
661 const stream = await serverAct(() =>
662 ReactDOMServer.renderToReadableStream(<App />),
663 );
664 await readIntoContainer(stream);
665
666 const input = container.getElementsByTagName('input')[1];
667 const button = container.getElementsByTagName('button')[0];
668 expect(input.getAttribute('formaction')).toBe('action');
669 expect(input.getAttribute('formmethod')).toBe('POST');
670 expect(input.getAttribute('formenctype')).toBe('multipart/form-data');
671 expect(input.getAttribute('formtarget')).toBe('self');
672 expect(button.getAttribute('formaction')).toBe('action');
673 expect(button.getAttribute('formmethod')).toBe('POST');
674 expect(button.getAttribute('formenctype')).toBe('multipart/form-data');
675 expect(button.getAttribute('formtarget')).toBe('self');
676 const inputName = input.getAttribute('name');
677 const buttonName = button.getAttribute('name');
678 expect(
679 container
680 .querySelector('input[name="' + inputName + 'hello"]')
681 .getAttribute('value'),
682 ).toBe('world');
683 expect(
684 container
685 .querySelector('input[name="' + buttonName + 'hello"]')
686 .getAttribute('value'),
687 ).toBe('world');
688
689 await act(async () => {
690 ReactDOMClient.hydrateRoot(container, <App />);
691 });
692
693 expect(hiddenRef.current.name).toBe('foo');
694
695 submit(inputRef.current);
696
697 expect(foo).toBe('bar');
698
699 foo = null;
700
701 submit(buttonRef.current);
702
703 expect(foo).toBe('bar');
704 });
705
706 it('can hydrate hidden fields in the beginning of a form', async () => {
707 const hiddenRef = React.createRef();
708
709 let invoked = false;
710 function action(formData) {
711 invoked = true;
712 }
713 action.$$FORM_ACTION = function (identifierPrefix) {
714 const extraFields = new FormData();
715 extraFields.append(identifierPrefix + 'hello', 'world');
716 return {
717 action: '',
718 name: identifierPrefix,
719 method: 'POST',
720 encType: 'multipart/form-data',
721 data: extraFields,
722 };
723 };
724 function App() {
725 return (
726 <form action={action}>
727 <input type="hidden" name="bar" defaultValue="baz" ref={hiddenRef} />
728 <input type="text" name="foo" defaultValue="bar" />
729 </form>
730 );
731 }
732
733 const stream = await serverAct(() =>
734 ReactDOMServer.renderToReadableStream(<App />),
735 );
736 await readIntoContainer(stream);
737
738 const barField = container.querySelector('[name=bar]');
739
740 await act(async () => {
741 ReactDOMClient.hydrateRoot(container, <App />);
742 });
743
744 expect(hiddenRef.current).toBe(barField);
745
746 expect(hiddenRef.current.name).toBe('bar');
747 expect(hiddenRef.current.value).toBe('baz');
748
749 expect(container.querySelectorAll('[name=bar]').length).toBe(1);
750
751 submit(hiddenRef.current.form);
752
753 expect(invoked).toBe(true);
754 });
755 });