Files
react/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMForm-test.js
T
Sebastian Markbåge fee786a057 [Fizz] Recover from errors thrown by progressive enhancement form generation (#28611)
This a follow up to #28564. It's alternative to #28609 which takes
#28610 into account.

It used to be possible to return JSX from an action with
`useActionState`.

```js
async function action(errors, payload) {
  "use server";
  try {
    ...
  } catch (x) {
    return <div>Error message</div>;
  }
}
```

```js
const [errors, formAction] = useActionState(action);
return <div>{errors}</div>;
```

Returning JSX from an action is itself not anything problematic. It's
that it also has to return the previous state to the action reducer
again that's the problem. When this happens we accidentally could
serialize an Element back to the server.

I fixed this in #28564 so it's now blocked if you don't have a temporary
reference set.

However, you can't have that for the progressive enhancement case. The
reply is eagerly encoded as part of the SSR render. Typically you
wouldn't have these in the initial state so the common case is that they
show up after the first POST back that yields an error and it's only in
the no-JS case where this happens so it's hard to discover.

As noted in #28609 there's a security implication with allowing elements
to be sent across this kind of payload, so we can't just make it work.

When an error happens during SSR our general policy is to try to recover
on the client instead. After all, SSR is mainly a perf optimization in
React terms and it's not primarily intended for a no JS solution.

This PR takes the approach that if we fail to generate the progressive
enhancement payload. I.e. if the serialization of previous state /
closures throw. Then we fallback to the replaying semantics just client
actions instead which will succeed.

The effect of this is that this pattern mostly just works:

- First render in the typical case doesn't have any JSX in it so it just
renders a progressive enhanced form.
- If JS fails to hydrate or you click early we do a form POST. If that
hits an error and it tries to render it using JSX, then the new page
will render successfully - however this time with a Replaying form
instead.
- If you try to submit the form again it'll have to be using JS.

Meaning if you use JSX as the error return value of form state and you
make a first attempt that fails, then no JS won't work because either
the first or second attempt has to hydrate.

We have ideas for potentially optimizing away serializing unused
arguments like if you don't actually use previous state which would also
solve it but it wouldn't cover all cases such as if it was deeply nested
in complex state.

Another approach that I considered was to poison the prev state if you
passed an element back but let it through to the action but if you try
to render the poisoned value, it wouldn't work. The downside of this is
when to error. Because in the progressive enhancement case it wouldn't
error early but when you actually try to invoke it at which point it
would be too late to fallback to client replaying. It would probably
have to always error even on the client which is unfortunate since this
mostly just works as long as it hydrates.
2024-03-21 19:51:38 -04:00

974 lines
30 KiB
JavaScript

/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @emails react-core
*/
'use strict';
import {insertNodesAndExecuteScripts} from 'react-dom/src/test-utils/FizzTestUtils';
// Polyfills for test environment
global.ReadableStream =
require('web-streams-polyfill/ponyfill/es6').ReadableStream;
global.TextEncoder = require('util').TextEncoder;
global.TextDecoder = require('util').TextDecoder;
// Don't wait before processing work on the server.
// TODO: we can replace this with FlightServer.act().
global.setTimeout = cb => cb();
let container;
let clientExports;
let serverExports;
let webpackMap;
let webpackServerMap;
let React;
let ReactDOMServer;
let ReactServerDOMServer;
let ReactServerDOMClient;
let ReactDOMClient;
let useFormState;
let act;
describe('ReactFlightDOMForm', () => {
beforeEach(() => {
jest.resetModules();
// Simulate the condition resolution
jest.mock('react', () => require('react/react.react-server'));
jest.mock('react-server-dom-webpack/server', () =>
require('react-server-dom-webpack/server.edge'),
);
ReactServerDOMServer = require('react-server-dom-webpack/server.edge');
const WebpackMock = require('./utils/WebpackMock');
clientExports = WebpackMock.clientExports;
serverExports = WebpackMock.serverExports;
webpackMap = WebpackMock.webpackMap;
webpackServerMap = WebpackMock.webpackServerMap;
__unmockReact();
jest.resetModules();
React = require('react');
ReactServerDOMClient = require('react-server-dom-webpack/client.edge');
ReactDOMServer = require('react-dom/server.edge');
ReactDOMClient = require('react-dom/client');
act = React.act;
useFormState = require('react-dom').useFormState;
container = document.createElement('div');
document.body.appendChild(container);
});
afterEach(() => {
document.body.removeChild(container);
});
async function POST(formData) {
const boundAction = await ReactServerDOMServer.decodeAction(
formData,
webpackServerMap,
);
const returnValue = boundAction();
const formState = await ReactServerDOMServer.decodeFormState(
await returnValue,
formData,
webpackServerMap,
);
return {returnValue, formState};
}
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)) {
const method = (submitter && submitter.formMethod) || form.method;
const encType = (submitter && submitter.formEnctype) || form.enctype;
if (method === 'post' && encType === 'multipart/form-data') {
let formData;
if (submitter) {
const temp = document.createElement('input');
temp.name = submitter.name;
temp.value = submitter.value;
submitter.parentNode.insertBefore(temp, submitter);
formData = new FormData(form);
temp.parentNode.removeChild(temp);
} else {
formData = new FormData(form);
}
return POST(formData);
}
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);
}
// @gate enableFormActions
it('can submit a passed server action without hydrating it', async () => {
let foo = null;
const serverAction = serverExports(function action(formData) {
foo = formData.get('foo');
return 'hello';
});
function App() {
return (
<form action={serverAction}>
<input type="text" name="foo" defaultValue="bar" />
</form>
);
}
const rscStream = ReactServerDOMServer.renderToReadableStream(<App />);
const response = ReactServerDOMClient.createFromReadableStream(rscStream, {
ssrManifest: {
moduleMap: null,
moduleLoading: null,
},
});
const ssrStream = await ReactDOMServer.renderToReadableStream(response);
await readIntoContainer(ssrStream);
const form = container.firstChild;
expect(foo).toBe(null);
const {returnValue} = await submit(form);
expect(returnValue).toBe('hello');
expect(foo).toBe('bar');
});
// @gate enableFormActions
it('can submit an imported server action without hydrating it', async () => {
let foo = null;
const ServerModule = serverExports(function action(formData) {
foo = formData.get('foo');
return 'hi';
});
const serverAction = ReactServerDOMClient.createServerReference(
ServerModule.$$id,
);
function App() {
return (
<form action={serverAction}>
<input type="text" name="foo" defaultValue="bar" />
</form>
);
}
const ssrStream = await ReactDOMServer.renderToReadableStream(<App />);
await readIntoContainer(ssrStream);
const form = container.firstChild;
expect(foo).toBe(null);
const {returnValue} = await submit(form);
expect(returnValue).toBe('hi');
expect(foo).toBe('bar');
});
// @gate enableFormActions
it('can submit a complex closure server action without hydrating it', async () => {
let foo = null;
const serverAction = serverExports(function action(bound, formData) {
foo = formData.get('foo') + bound.complex;
return 'hello';
});
function App() {
return (
<form action={serverAction.bind(null, {complex: 'object'})}>
<input type="text" name="foo" defaultValue="bar" />
</form>
);
}
const rscStream = ReactServerDOMServer.renderToReadableStream(<App />);
const response = ReactServerDOMClient.createFromReadableStream(rscStream, {
ssrManifest: {
moduleMap: null,
moduleLoading: null,
},
});
const ssrStream = await ReactDOMServer.renderToReadableStream(response);
await readIntoContainer(ssrStream);
const form = container.firstChild;
expect(foo).toBe(null);
const {returnValue} = await submit(form);
expect(returnValue).toBe('hello');
expect(foo).toBe('barobject');
});
// @gate enableFormActions
it('can submit a multiple complex closure server action without hydrating it', async () => {
let foo = null;
const serverAction = serverExports(function action(bound, formData) {
foo = formData.get('foo') + bound.complex;
return 'hello' + bound.complex;
});
function App() {
return (
<form action={serverAction.bind(null, {complex: 'a'})}>
<input type="text" name="foo" defaultValue="bar" />
<button formAction={serverAction.bind(null, {complex: 'b'})} />
<button formAction={serverAction.bind(null, {complex: 'c'})} />
<input
type="submit"
formAction={serverAction.bind(null, {complex: 'd'})}
/>
</form>
);
}
const rscStream = ReactServerDOMServer.renderToReadableStream(<App />);
const response = ReactServerDOMClient.createFromReadableStream(rscStream, {
ssrManifest: {
moduleMap: null,
moduleLoading: null,
},
});
const ssrStream = await ReactDOMServer.renderToReadableStream(response);
await readIntoContainer(ssrStream);
const form = container.firstChild;
expect(foo).toBe(null);
const {returnValue} = await submit(form.getElementsByTagName('button')[1]);
expect(returnValue).toBe('helloc');
expect(foo).toBe('barc');
});
// @gate enableFormActions
it('can bind an imported server action on the client without hydrating it', async () => {
let foo = null;
const ServerModule = serverExports(function action(bound, formData) {
foo = formData.get('foo') + bound.complex;
return 'hello';
});
const serverAction = ReactServerDOMClient.createServerReference(
ServerModule.$$id,
);
function Client() {
return (
<form action={serverAction.bind(null, {complex: 'object'})}>
<input type="text" name="foo" defaultValue="bar" />
</form>
);
}
const ssrStream = await ReactDOMServer.renderToReadableStream(<Client />);
await readIntoContainer(ssrStream);
const form = container.firstChild;
expect(foo).toBe(null);
const {returnValue} = await submit(form);
expect(returnValue).toBe('hello');
expect(foo).toBe('barobject');
});
// @gate enableFormActions
it('can bind a server action on the client without hydrating it', async () => {
let foo = null;
const serverAction = serverExports(function action(bound, formData) {
foo = formData.get('foo') + bound.complex;
return 'hello';
});
function Client({action}) {
return (
<form action={action.bind(null, {complex: 'object'})}>
<input type="text" name="foo" defaultValue="bar" />
</form>
);
}
const ClientRef = await clientExports(Client);
const rscStream = ReactServerDOMServer.renderToReadableStream(
<ClientRef action={serverAction} />,
webpackMap,
);
const response = ReactServerDOMClient.createFromReadableStream(rscStream, {
ssrManifest: {
moduleMap: null,
moduleLoading: null,
},
});
const ssrStream = await ReactDOMServer.renderToReadableStream(response);
await readIntoContainer(ssrStream);
const form = container.firstChild;
expect(foo).toBe(null);
const {returnValue} = await submit(form);
expect(returnValue).toBe('hello');
expect(foo).toBe('barobject');
});
// @gate enableFormActions
// @gate enableAsyncActions
it("useFormState's dispatch binds the initial state to the provided action", async () => {
const serverAction = serverExports(
async function action(prevState, formData) {
return {
count:
prevState.count + parseInt(formData.get('incrementAmount'), 10),
};
},
);
const initialState = {count: 1};
function Client({action}) {
const [state, dispatch, isPending] = useFormState(action, initialState);
return (
<form action={dispatch}>
<span>{isPending ? 'Pending...' : ''}</span>
<span>Count: {state.count}</span>
<input type="text" name="incrementAmount" defaultValue="5" />
</form>
);
}
const ClientRef = await clientExports(Client);
const rscStream = ReactServerDOMServer.renderToReadableStream(
<ClientRef action={serverAction} />,
webpackMap,
);
const response = ReactServerDOMClient.createFromReadableStream(rscStream, {
ssrManifest: {
moduleMap: null,
moduleLoading: null,
},
});
const ssrStream = await ReactDOMServer.renderToReadableStream(response);
await readIntoContainer(ssrStream);
const form = container.getElementsByTagName('form')[0];
const pendingSpan = container.getElementsByTagName('span')[0];
const stateSpan = container.getElementsByTagName('span')[1];
expect(pendingSpan.textContent).toBe('');
expect(stateSpan.textContent).toBe('Count: 1');
const {returnValue} = await submit(form);
expect(await returnValue).toEqual({count: 6});
});
// @gate enableFormActions
// @gate enableAsyncActions
it('useFormState can reuse state during MPA form submission', async () => {
const serverAction = serverExports(
async function action(prevState, formData) {
return prevState + 1;
},
);
function Form({action}) {
const [count, dispatch, isPending] = useFormState(action, 1);
return (
<form action={dispatch}>
{isPending ? 'Pending...' : ''}
{count}
</form>
);
}
function Client({action}) {
return (
<div>
<Form action={action} />
<Form action={action} />
<Form action={action} />
</div>
);
}
const ClientRef = await clientExports(Client);
const rscStream = ReactServerDOMServer.renderToReadableStream(
<ClientRef action={serverAction} />,
webpackMap,
);
const response = ReactServerDOMClient.createFromReadableStream(rscStream, {
ssrManifest: {
moduleMap: null,
moduleLoading: null,
},
});
const ssrStream = await ReactDOMServer.renderToReadableStream(response);
await readIntoContainer(ssrStream);
expect(container.textContent).toBe('111');
// There are three identical forms. We're going to submit the second one.
const form = container.getElementsByTagName('form')[1];
const {formState} = await submit(form);
// Simulate an MPA form submission by resetting the container and
// rendering again.
container.innerHTML = '';
const postbackRscStream = ReactServerDOMServer.renderToReadableStream(
<ClientRef action={serverAction} />,
webpackMap,
);
const postbackResponse = ReactServerDOMClient.createFromReadableStream(
postbackRscStream,
{
ssrManifest: {
moduleMap: null,
moduleLoading: null,
},
},
);
const postbackSsrStream = await ReactDOMServer.renderToReadableStream(
postbackResponse,
{formState: formState},
);
await readIntoContainer(postbackSsrStream);
// Only the second form's state should have been updated.
expect(container.textContent).toBe('121');
// Test that it hydrates correctly
if (__DEV__) {
// TODO: Can't use our internal act() util that works in production
// because it works by overriding the timer APIs, which this test module
// also does. Remove dev condition once FlightServer.act() is available.
await act(() => {
ReactDOMClient.hydrateRoot(container, postbackResponse, {
formState: formState,
});
});
expect(container.textContent).toBe('121');
}
});
// @gate enableFormActions
// @gate enableAsyncActions
it(
'useFormState preserves state if arity is the same, but different ' +
'arguments are bound (i.e. inline closure)',
async () => {
const serverAction = serverExports(
async function action(stepSize, prevState, formData) {
return prevState + stepSize;
},
);
function Form({action}) {
const [count, dispatch, isPending] = useFormState(action, 1);
return (
<form action={dispatch}>
{isPending ? 'Pending...' : ''}
{count}
</form>
);
}
function Client({action}) {
return (
<div>
<Form action={action} />
<Form action={action} />
<Form action={action} />
</div>
);
}
const ClientRef = await clientExports(Client);
const rscStream = ReactServerDOMServer.renderToReadableStream(
// Note: `.bind` is the same as an inline closure with 'use server'
<ClientRef action={serverAction.bind(null, 1)} />,
webpackMap,
);
const response = ReactServerDOMClient.createFromReadableStream(
rscStream,
{
ssrManifest: {
moduleMap: null,
moduleLoading: null,
},
},
);
const ssrStream = await ReactDOMServer.renderToReadableStream(response);
await readIntoContainer(ssrStream);
expect(container.textContent).toBe('111');
// There are three identical forms. We're going to submit the second one.
const form = container.getElementsByTagName('form')[1];
const {formState} = await submit(form);
// Simulate an MPA form submission by resetting the container and
// rendering again.
container.innerHTML = '';
// On the next page, the same server action is rendered again, but with
// a different bound stepSize argument. We should treat this as the same
// action signature.
const postbackRscStream = ReactServerDOMServer.renderToReadableStream(
// Note: `.bind` is the same as an inline closure with 'use server'
<ClientRef action={serverAction.bind(null, 5)} />,
webpackMap,
);
const postbackResponse = ReactServerDOMClient.createFromReadableStream(
postbackRscStream,
{
ssrManifest: {
moduleMap: null,
moduleLoading: null,
},
},
);
const postbackSsrStream = await ReactDOMServer.renderToReadableStream(
postbackResponse,
{formState: formState},
);
await readIntoContainer(postbackSsrStream);
// The state should have been preserved because the action signatures are
// the same. (Note that the amount increased by 1, because that was the
// value of stepSize at the time the form was submitted)
expect(container.textContent).toBe('121');
// Now submit the form again. This time, the state should increase by 5
// because the stepSize argument has changed.
const form2 = container.getElementsByTagName('form')[1];
const {formState: formState2} = await submit(form2);
container.innerHTML = '';
const postbackRscStream2 = ReactServerDOMServer.renderToReadableStream(
// Note: `.bind` is the same as an inline closure with 'use server'
<ClientRef action={serverAction.bind(null, 5)} />,
webpackMap,
);
const postbackResponse2 = ReactServerDOMClient.createFromReadableStream(
postbackRscStream2,
{
ssrManifest: {
moduleMap: null,
moduleLoading: null,
},
},
);
const postbackSsrStream2 = await ReactDOMServer.renderToReadableStream(
postbackResponse2,
{formState: formState2},
);
await readIntoContainer(postbackSsrStream2);
expect(container.textContent).toBe('171');
},
);
// @gate enableFormActions
// @gate enableAsyncActions
it('useFormState does not reuse state if action signatures are different', async () => {
// This is the same as the previous test, except instead of using bind to
// configure the server action (i.e. a closure), it swaps the action.
const increaseBy1 = serverExports(
async function action(prevState, formData) {
return prevState + 1;
},
);
const increaseBy5 = serverExports(
async function action(prevState, formData) {
return prevState + 5;
},
);
function Form({action}) {
const [count, dispatch, isPending] = useFormState(action, 1);
return (
<form action={dispatch}>
{isPending ? 'Pending...' : ''}
{count}
</form>
);
}
function Client({action}) {
return (
<div>
<Form action={action} />
<Form action={action} />
<Form action={action} />
</div>
);
}
const ClientRef = await clientExports(Client);
const rscStream = ReactServerDOMServer.renderToReadableStream(
<ClientRef action={increaseBy1} />,
webpackMap,
);
const response = ReactServerDOMClient.createFromReadableStream(rscStream, {
ssrManifest: {
moduleMap: null,
moduleLoading: null,
},
});
const ssrStream = await ReactDOMServer.renderToReadableStream(response);
await readIntoContainer(ssrStream);
expect(container.textContent).toBe('111');
// There are three identical forms. We're going to submit the second one.
const form = container.getElementsByTagName('form')[1];
const {formState} = await submit(form);
// Simulate an MPA form submission by resetting the container and
// rendering again.
container.innerHTML = '';
// On the next page, a different server action is rendered. It should not
// reuse the state from the previous page.
const postbackRscStream = ReactServerDOMServer.renderToReadableStream(
<ClientRef action={increaseBy5} />,
webpackMap,
);
const postbackResponse = ReactServerDOMClient.createFromReadableStream(
postbackRscStream,
{
ssrManifest: {
moduleMap: null,
moduleLoading: null,
},
},
);
const postbackSsrStream = await ReactDOMServer.renderToReadableStream(
postbackResponse,
{formState: formState},
);
await readIntoContainer(postbackSsrStream);
// The state should not have been preserved because the action signatures
// are not the same.
expect(container.textContent).toBe('111');
});
// @gate enableFormActions
// @gate enableAsyncActions
it('when permalink is provided, useFormState compares that instead of the keypath', async () => {
const serverAction = serverExports(
async function action(prevState, formData) {
return prevState + 1;
},
);
function Form({action, permalink}) {
const [count, dispatch, isPending] = useFormState(action, 1, permalink);
return (
<form action={dispatch}>
{isPending ? 'Pending...' : ''}
{count}
</form>
);
}
function Page1({action, permalink}) {
return <Form action={action} permalink={permalink} />;
}
function Page2({action, permalink}) {
return <Form action={action} permalink={permalink} />;
}
const Page1Ref = await clientExports(Page1);
const Page2Ref = await clientExports(Page2);
const rscStream = ReactServerDOMServer.renderToReadableStream(
<Page1Ref action={serverAction} permalink="/permalink" />,
webpackMap,
);
const response = ReactServerDOMClient.createFromReadableStream(rscStream, {
ssrManifest: {
moduleMap: null,
moduleLoading: null,
},
});
const ssrStream = await ReactDOMServer.renderToReadableStream(response);
await readIntoContainer(ssrStream);
expect(container.textContent).toBe('1');
// Submit the form
const form = container.getElementsByTagName('form')[0];
const {formState} = await submit(form);
// Simulate an MPA form submission by resetting the container and
// rendering again.
container.innerHTML = '';
// On the next page, the same server action is rendered again, but in
// a different component tree. However, because a permalink option was
// passed, the state should be preserved.
const postbackRscStream = ReactServerDOMServer.renderToReadableStream(
<Page2Ref action={serverAction} permalink="/permalink" />,
webpackMap,
);
const postbackResponse = ReactServerDOMClient.createFromReadableStream(
postbackRscStream,
{
ssrManifest: {
moduleMap: null,
moduleLoading: null,
},
},
);
const postbackSsrStream = await ReactDOMServer.renderToReadableStream(
postbackResponse,
{formState: formState},
);
await readIntoContainer(postbackSsrStream);
expect(container.textContent).toBe('2');
// Now submit the form again. This time, the permalink will be different, so
// the state is not preserved.
const form2 = container.getElementsByTagName('form')[0];
const {formState: formState2} = await submit(form2);
container.innerHTML = '';
const postbackRscStream2 = ReactServerDOMServer.renderToReadableStream(
<Page1Ref action={serverAction} permalink="/some-other-permalink" />,
webpackMap,
);
const postbackResponse2 = ReactServerDOMClient.createFromReadableStream(
postbackRscStream2,
{
ssrManifest: {
moduleMap: null,
moduleLoading: null,
},
},
);
const postbackSsrStream2 = await ReactDOMServer.renderToReadableStream(
postbackResponse2,
{formState: formState2},
);
await readIntoContainer(postbackSsrStream2);
// The state was reset because the permalink didn't match
expect(container.textContent).toBe('1');
});
// @gate enableFormActions
// @gate enableAsyncActions
it('useFormState can change the action URL with the `permalink` argument', async () => {
const serverAction = serverExports(function action(prevState) {
return {state: prevState.count + 1};
});
const initialState = {count: 1};
function Client({action}) {
const [state, dispatch, isPending] = useFormState(
action,
initialState,
'/permalink',
);
return (
<form action={dispatch}>
<span>{isPending ? 'Pending...' : ''}</span>
<span>Count: {state.count}</span>
</form>
);
}
const ClientRef = await clientExports(Client);
const rscStream = ReactServerDOMServer.renderToReadableStream(
<ClientRef action={serverAction} />,
webpackMap,
);
const response = ReactServerDOMClient.createFromReadableStream(rscStream, {
ssrManifest: {
moduleMap: null,
moduleLoading: null,
},
});
const ssrStream = await ReactDOMServer.renderToReadableStream(response);
await readIntoContainer(ssrStream);
const form = container.getElementsByTagName('form')[0];
const pendingSpan = container.getElementsByTagName('span')[0];
const stateSpan = container.getElementsByTagName('span')[1];
expect(pendingSpan.textContent).toBe('');
expect(stateSpan.textContent).toBe('Count: 1');
expect(form.action).toBe('http://localhost/permalink');
});
// @gate enableFormActions
// @gate enableAsyncActions
it('useFormState `permalink` is coerced to string', async () => {
const serverAction = serverExports(function action(prevState) {
return {state: prevState.count + 1};
});
class Permalink {
toString() {
return '/permalink';
}
}
const permalink = new Permalink();
const initialState = {count: 1};
function Client({action}) {
const [state, dispatch, isPending] = useFormState(
action,
initialState,
permalink,
);
return (
<form action={dispatch}>
<span>{isPending ? 'Pending...' : ''}</span>
<span>Count: {state.count}</span>
</form>
);
}
const ClientRef = await clientExports(Client);
const rscStream = ReactServerDOMServer.renderToReadableStream(
<ClientRef action={serverAction} />,
webpackMap,
);
const response = ReactServerDOMClient.createFromReadableStream(rscStream, {
ssrManifest: {
moduleMap: null,
moduleLoading: null,
},
});
const ssrStream = await ReactDOMServer.renderToReadableStream(response);
await readIntoContainer(ssrStream);
const form = container.getElementsByTagName('form')[0];
const pendingSpan = container.getElementsByTagName('span')[0];
const stateSpan = container.getElementsByTagName('span')[1];
expect(pendingSpan.textContent).toBe('');
expect(stateSpan.textContent).toBe('Count: 1');
expect(form.action).toBe('http://localhost/permalink');
});
// @gate enableFormActions
// @gate enableAsyncActions
it('useFormState can return JSX state during MPA form submission', async () => {
const serverAction = serverExports(
async function action(prevState, formData) {
return <div>error message</div>;
},
);
function Form({action}) {
const [errorMsg, dispatch] = useFormState(action, null);
return <form action={dispatch}>{errorMsg}</form>;
}
const FormRef = await clientExports(Form);
const rscStream = ReactServerDOMServer.renderToReadableStream(
<FormRef action={serverAction} />,
webpackMap,
);
const response = ReactServerDOMClient.createFromReadableStream(rscStream, {
ssrManifest: {
moduleMap: null,
moduleLoading: null,
},
});
const ssrStream = await ReactDOMServer.renderToReadableStream(response);
await readIntoContainer(ssrStream);
const form1 = container.getElementsByTagName('form')[0];
expect(form1.textContent).toBe('');
async function submitTheForm() {
const form = container.getElementsByTagName('form')[0];
const {formState} = await submit(form);
// Simulate an MPA form submission by resetting the container and
// rendering again.
container.innerHTML = '';
const postbackRscStream = ReactServerDOMServer.renderToReadableStream(
<FormRef action={serverAction} />,
webpackMap,
);
const postbackResponse = ReactServerDOMClient.createFromReadableStream(
postbackRscStream,
{
ssrManifest: {
moduleMap: null,
moduleLoading: null,
},
},
);
const postbackSsrStream = await ReactDOMServer.renderToReadableStream(
postbackResponse,
{formState: formState},
);
await readIntoContainer(postbackSsrStream);
}
await expect(submitTheForm).toErrorDev(
'Warning: Failed to serialize an action for progressive enhancement:\n' +
'Error: React Element cannot be passed to Server Functions from the Client without a temporary reference set. Pass a TemporaryReferenceSet to the options.\n' +
' [<div/>]\n' +
' ^^^^^^',
);
// The error message was returned as JSX.
const form2 = container.getElementsByTagName('form')[0];
expect(form2.textContent).toBe('error message');
expect(form2.firstChild.tagName).toBe('DIV');
});
});