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