/** * Copyright (c) Meta Platforms, Inc. and affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * * @emails react-core */ 'use strict'; import {insertNodesAndExecuteScripts} from '../test-utils/FizzTestUtils'; import {patchMessageChannel} from '../../../../scripts/jest/patchMessageChannel'; // Polyfills for test environment global.ReadableStream = require('web-streams-polyfill/ponyfill/es6').ReadableStream; global.TextEncoder = require('util').TextEncoder; let act; let serverAct; let container; let React; let ReactDOMServer; let ReactDOMClient; let Suspense; let useFormStatus; let useOptimistic; let useActionState; let assertConsoleErrorDev; describe('ReactDOMFizzForm', () => { beforeEach(() => { jest.resetModules(); patchMessageChannel(); React = require('react'); ReactDOMServer = require('react-dom/server.browser'); ReactDOMClient = require('react-dom/client'); Suspense = React.Suspense; useFormStatus = require('react-dom').useFormStatus; useOptimistic = require('react').useOptimistic; act = require('internal-test-utils').act; serverAct = require('internal-test-utils').serverAct; assertConsoleErrorDev = require('internal-test-utils').assertConsoleErrorDev; container = document.createElement('div'); document.body.appendChild(container); // TODO: Test the old api but it warns so needs warnings to be asserted. // if (__VARIANT__) { // Remove after API is deleted. // useActionState = require('react-dom').useFormState; // } useActionState = require('react').useActionState; }); afterEach(() => { document.body.removeChild(container); }); function submit(submitter) { const form = submitter.form || submitter; if (!submitter.form) { submitter = undefined; } const submitEvent = new Event('submit', {bubbles: true, cancelable: true}); submitEvent.submitter = submitter; const returnValue = form.dispatchEvent(submitEvent); if (!returnValue) { return; } const action = (submitter && submitter.getAttribute('formaction')) || form.action; if (!/\s*javascript:/i.test(action)) { throw new Error('Navigate to: ' + action); } } async function readIntoContainer(stream) { const reader = stream.getReader(); let result = ''; while (true) { const {done, value} = await reader.read(); if (done) { break; } result += Buffer.from(value).toString('utf8'); } const temp = document.createElement('div'); temp.innerHTML = result; insertNodesAndExecuteScripts(temp, container, null); } it('should allow passing a function to form action during SSR', async () => { const ref = React.createRef(); let foo; function action(formData) { foo = formData.get('foo'); } function App() { return (
); } const stream = await serverAct(() => ReactDOMServer.renderToReadableStream(), ); await readIntoContainer(stream); await act(async () => { ReactDOMClient.hydrateRoot(container, ); }); submit(ref.current); expect(foo).toBe('bar'); }); it('should allow passing a function to an input/button formAction', async () => { const inputRef = React.createRef(); const buttonRef = React.createRef(); let rootActionCalled = false; let savedTitle = null; let deletedTitle = null; function action(formData) { rootActionCalled = true; } function saveItem(formData) { savedTitle = formData.get('title'); } function deleteItem(formData) { deletedTitle = formData.get('title'); } function App() { return (
); } const stream = await serverAct(() => ReactDOMServer.renderToReadableStream(), ); await readIntoContainer(stream); await act(async () => { ReactDOMClient.hydrateRoot(container, ); }); expect(savedTitle).toBe(null); expect(deletedTitle).toBe(null); submit(inputRef.current); expect(savedTitle).toBe('Hello'); expect(deletedTitle).toBe(null); savedTitle = null; submit(buttonRef.current); expect(savedTitle).toBe(null); expect(deletedTitle).toBe('Hello'); deletedTitle = null; expect(rootActionCalled).toBe(false); }); it('should warn when passing a function action during SSR and string during hydration', async () => { function action(formData) {} function App({isClient}) { return (
); } const stream = await serverAct(() => ReactDOMServer.renderToReadableStream(), ); await readIntoContainer(stream); await act(async () => { ReactDOMClient.hydrateRoot(container, ); }); assertConsoleErrorDev([ "A tree hydrated but some attributes of the server rendered HTML didn't match the client properties. " + "This won't be patched up. This can happen if a SSR-ed Client Component used:\n\n" + "- A server/client branch `if (typeof window !== 'undefined')`.\n" + "- Variable input such as `Date.now()` or `Math.random()` which changes each time it's called.\n" + "- Date formatting in a user's locale which doesn't match the server.\n" + '- External changing data without sending a snapshot of it along with the HTML.\n' + '- Invalid HTML tag nesting.\n\n' + 'It can also happen if the client has a browser extension installed which messes with the HTML before React loaded.\n\n' + 'https://react.dev/link/hydration-mismatch\n\n' + ' \n' + ' \n' + '\n in form (at **)' + '\n in App (at **)', ]); }); it('should ideally warn when passing a string during SSR and function during hydration', async () => { function action(formData) {} function App({isClient}) { return (
); } const stream = await serverAct(() => ReactDOMServer.renderToReadableStream(), ); await readIntoContainer(stream); // This should ideally warn because only the client provides a function that doesn't line up. await act(async () => { ReactDOMClient.hydrateRoot(container, ); }); }); it('should reset form fields after you update away from hydrated function', async () => { const formRef = React.createRef(); const inputRef = React.createRef(); const buttonRef = React.createRef(); function action(formData) {} function App({isUpdate}) { return (
); } const stream = await serverAct(() => ReactDOMServer.renderToReadableStream(), ); await readIntoContainer(stream); submit(container.getElementsByTagName('input')[1]); submit(container.getElementsByTagName('button')[0]); await act(async () => { ReactDOMClient.hydrateRoot(container, ); }); expect(savedTitle).toBe('Hello'); expect(deletedTitle).toBe('Hello'); expect(rootActionCalled).toBe(false); }); it('useOptimistic returns passthrough value', async () => { function App() { const [optimisticState] = useOptimistic('hi'); return optimisticState; } const stream = await serverAct(() => ReactDOMServer.renderToReadableStream(), ); await readIntoContainer(stream); expect(container.textContent).toBe('hi'); await act(async () => { ReactDOMClient.hydrateRoot(container, ); }); expect(container.textContent).toBe('hi'); }); it('useActionState returns initial state', async () => { async function action(state) { return state; } function App() { const [state] = useActionState(action, 0); return state; } const stream = await serverAct(() => ReactDOMServer.renderToReadableStream(), ); await readIntoContainer(stream); expect(container.textContent).toBe('0'); await act(async () => { ReactDOMClient.hydrateRoot(container, ); }); expect(container.textContent).toBe('0'); }); it('can provide a custom action on the server for actions', async () => { const ref = React.createRef(); let foo; function action(formData) { foo = formData.get('foo'); } action.$$FORM_ACTION = function (identifierPrefix) { const extraFields = new FormData(); extraFields.append(identifierPrefix + 'hello', 'world'); return { action: this.name, name: identifierPrefix, method: 'POST', encType: 'multipart/form-data', target: 'self', data: extraFields, }; }; function App() { return (
); } const stream = await serverAct(() => ReactDOMServer.renderToReadableStream(), ); await readIntoContainer(stream); const form = container.firstChild; expect(form.getAttribute('action')).toBe('action'); expect(form.getAttribute('method')).toBe('POST'); expect(form.getAttribute('enctype')).toBe('multipart/form-data'); expect(form.getAttribute('target')).toBe('self'); const formActionName = form.firstChild.getAttribute('name'); expect( container .querySelector('input[name="' + formActionName + 'hello"]') .getAttribute('value'), ).toBe('world'); await act(async () => { ReactDOMClient.hydrateRoot(container, ); }); submit(ref.current); expect(foo).toBe('bar'); }); it('can provide a custom action on buttons the server for actions', async () => { const hiddenRef = React.createRef(); const inputRef = React.createRef(); const buttonRef = React.createRef(); let foo; function action(formData) { foo = formData.get('foo'); } action.$$FORM_ACTION = function (identifierPrefix) { const extraFields = new FormData(); extraFields.append(identifierPrefix + 'hello', 'world'); return { action: this.name, name: identifierPrefix, method: 'POST', encType: 'multipart/form-data', target: 'self', data: extraFields, }; }; function App() { return (