From aef7ce5547c9489dc48e31f69b002cd17206e0cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Wed, 3 May 2023 18:36:57 -0400 Subject: [PATCH] [Flight] Progressively Enhanced Server Actions (#26774) This automatically exposes `$$FORM_ACTIONS` on Server References coming from Flight. So that when they're used in a form action, we can encode the ID for the server reference as a hidden field or as part of the name of a button. If the Server Action is a bound function it can have complex data associated with it. In this case this additional data is encoded as additional form fields. To process a POST on the server there's now a `decodeAction` helper that can take one of these progressive posts from FormData and give you a function that is prebound with the correct closure and FormData so that you can just invoke it. I updated the fixture which now has a "Server State" that gets automatically refreshed. This also lets us visualize form fields. There's no "Action State" here for showing error messages that are not thrown, that's still up to user space. --- fixtures/flight/server/global.js | 2 + fixtures/flight/server/region.js | 88 +++++-- fixtures/flight/src/App.js | 4 +- fixtures/flight/src/Button.js | 7 +- fixtures/flight/src/Form.js | 6 +- fixtures/flight/src/ServerState.js | 9 + fixtures/flight/src/actions.js | 4 + fixtures/flight/src/index.js | 36 ++- .../react-client/src/ReactFlightClient.js | 12 +- .../src/ReactFlightReplyClient.js | 122 ++++++++- .../src/ReactFlightServerReferenceRegistry.js | 32 --- .../src/server/ReactFizzConfigDOM.js | 2 +- .../src/ReactFlightDOMServerBrowser.js | 4 +- .../src/ReactFlightDOMServerEdge.js | 4 +- .../src/ReactFlightDOMServerNode.js | 9 +- .../src/__tests__/ReactFlightDOMForm-test.js | 231 ++++++++++++++++++ .../src/ReactFlightActionServer.js | 110 +++++++++ scripts/error-codes/codes.json | 3 +- 18 files changed, 589 insertions(+), 96 deletions(-) create mode 100644 fixtures/flight/src/ServerState.js delete mode 100644 packages/react-client/src/ReactFlightServerReferenceRegistry.js create mode 100644 packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMForm-test.js create mode 100644 packages/react-server/src/ReactFlightActionServer.js diff --git a/fixtures/flight/server/global.js b/fixtures/flight/server/global.js index a2f5697f4333e..f402174a684ff 100644 --- a/fixtures/flight/server/global.js +++ b/fixtures/flight/server/global.js @@ -95,6 +95,8 @@ app.all('/', async function (req, res, next) { if (req.get('rsc-action')) { proxiedHeaders['Content-type'] = req.get('Content-type'); proxiedHeaders['rsc-action'] = req.get('rsc-action'); + } else if (req.get('Content-type')) { + proxiedHeaders['Content-type'] = req.get('Content-type'); } const promiseForData = request( diff --git a/fixtures/flight/server/region.js b/fixtures/flight/server/region.js index 3481af3bf802d..d4d62f5202fc9 100644 --- a/fixtures/flight/server/region.js +++ b/fixtures/flight/server/region.js @@ -36,6 +36,7 @@ const bodyParser = require('body-parser'); const busboy = require('busboy'); const app = express(); const compress = require('compression'); +const {Readable} = require('node:stream'); app.use(compress()); @@ -45,7 +46,7 @@ const {readFile} = require('fs').promises; const React = require('react'); -app.get('/', async function (req, res) { +async function renderApp(res, returnValue) { const {renderToPipeableStream} = await import( 'react-server-dom-webpack/server' ); @@ -91,37 +92,74 @@ app.get('/', async function (req, res) { ), React.createElement(App), ]; - const {pipe} = renderToPipeableStream(root, moduleMap); + // For client-invoked server actions we refresh the tree and return a return value. + const payload = returnValue ? {returnValue, root} : root; + const {pipe} = renderToPipeableStream(payload, moduleMap); pipe(res); +} + +app.get('/', async function (req, res) { + await renderApp(res, null); }); app.post('/', bodyParser.text(), async function (req, res) { - const {renderToPipeableStream, decodeReply, decodeReplyFromBusboy} = - await import('react-server-dom-webpack/server'); + const { + renderToPipeableStream, + decodeReply, + decodeReplyFromBusboy, + decodeAction, + } = await import('react-server-dom-webpack/server'); const serverReference = req.get('rsc-action'); - const [filepath, name] = serverReference.split('#'); - const action = (await import(filepath))[name]; - // Validate that this is actually a function we intended to expose and - // not the client trying to invoke arbitrary functions. In a real app, - // you'd have a manifest verifying this before even importing it. - if (action.$$typeof !== Symbol.for('react.server.reference')) { - throw new Error('Invalid action'); - } - - let args; - if (req.is('multipart/form-data')) { - // Use busboy to streamingly parse the reply from form-data. - const bb = busboy({headers: req.headers}); - const reply = decodeReplyFromBusboy(bb); - req.pipe(bb); - args = await reply; + if (serverReference) { + // This is the client-side case + const [filepath, name] = serverReference.split('#'); + const action = (await import(filepath))[name]; + // Validate that this is actually a function we intended to expose and + // not the client trying to invoke arbitrary functions. In a real app, + // you'd have a manifest verifying this before even importing it. + if (action.$$typeof !== Symbol.for('react.server.reference')) { + throw new Error('Invalid action'); + } + + let args; + if (req.is('multipart/form-data')) { + // Use busboy to streamingly parse the reply from form-data. + const bb = busboy({headers: req.headers}); + const reply = decodeReplyFromBusboy(bb); + req.pipe(bb); + args = await reply; + } else { + args = await decodeReply(req.body); + } + const result = action.apply(null, args); + try { + // Wait for any mutations + await result; + } catch (x) { + // We handle the error on the client + } + // Refresh the client and return the value + renderApp(res, result); } else { - args = await decodeReply(req.body); + // This is the progressive enhancement case + const UndiciRequest = require('undici').Request; + const fakeRequest = new UndiciRequest('http://localhost', { + method: 'POST', + headers: {'Content-Type': req.headers['content-type']}, + body: Readable.toWeb(req), + duplex: 'half', + }); + const formData = await fakeRequest.formData(); + const action = await decodeAction(formData); + try { + // Wait for any mutations + await action(); + } catch (x) { + const {setServerState} = await import('../src/ServerState.js'); + setServerState('Error: ' + x.message); + } + renderApp(res, null); } - - const result = action.apply(null, args); - const {pipe} = renderToPipeableStream(result, {}); - pipe(res); }); app.get('/todos', function (req, res) { diff --git a/fixtures/flight/src/App.js b/fixtures/flight/src/App.js index 5e6fe4927d202..f4fba4591715f 100644 --- a/fixtures/flight/src/App.js +++ b/fixtures/flight/src/App.js @@ -11,6 +11,8 @@ import Form from './Form.js'; import {like, greet} from './actions.js'; +import {getServerState} from './ServerState.js'; + export default async function App() { const res = await fetch('http://localhost:3001/todos'); const todos = await res.json(); @@ -23,7 +25,7 @@ export default async function App() { -

Hello, world

+

{getServerState()}

    diff --git a/fixtures/flight/src/Button.js b/fixtures/flight/src/Button.js index 7e43bc2b16efd..999c84f580c42 100644 --- a/fixtures/flight/src/Button.js +++ b/fixtures/flight/src/Button.js @@ -7,12 +7,7 @@ import ErrorBoundary from './ErrorBoundary.js'; function ButtonDisabledWhilePending({action, children}) { const {pending} = useFormStatus(); return ( - ); diff --git a/fixtures/flight/src/Form.js b/fixtures/flight/src/Form.js index c166f5a16bb15..d98452c68304a 100644 --- a/fixtures/flight/src/Form.js +++ b/fixtures/flight/src/Form.js @@ -14,11 +14,7 @@ export default function Form({action, children}) { return ( -
    { - const result = await action(formData); - alert(result); - }}> + diff --git a/fixtures/flight/src/ServerState.js b/fixtures/flight/src/ServerState.js new file mode 100644 index 0000000000000..3d4c7162262dc --- /dev/null +++ b/fixtures/flight/src/ServerState.js @@ -0,0 +1,9 @@ +let serverState = 'Hello World'; + +export function setServerState(message) { + serverState = message; +} + +export function getServerState() { + return serverState; +} diff --git a/fixtures/flight/src/actions.js b/fixtures/flight/src/actions.js index 87cba005e0b72..3d26189979c2f 100644 --- a/fixtures/flight/src/actions.js +++ b/fixtures/flight/src/actions.js @@ -1,11 +1,15 @@ 'use server'; +import {setServerState} from './ServerState.js'; + export async function like() { + setServerState('Liked!'); return new Promise((resolve, reject) => resolve('Liked')); } export async function greet(formData) { const name = formData.get('name') || 'you'; + setServerState('Hi ' + name); const file = formData.get('file'); if (file) { return `Ok, ${name}, here is ${file.name}: diff --git a/fixtures/flight/src/index.js b/fixtures/flight/src/index.js index 3e8b7e5bcce28..d75feee56ec36 100644 --- a/fixtures/flight/src/index.js +++ b/fixtures/flight/src/index.js @@ -1,11 +1,29 @@ import * as React from 'react'; -import {use, Suspense} from 'react'; +import {use, Suspense, useState, startTransition} from 'react'; import ReactDOM from 'react-dom/client'; import {createFromFetch, encodeReply} from 'react-server-dom-webpack/client'; // TODO: This should be a dependency of the App but we haven't implemented CSS in Node yet. import './style.css'; +let updateRoot; +async function callServer(id, args) { + const response = fetch('/', { + method: 'POST', + headers: { + Accept: 'text/x-component', + 'rsc-action': id, + }, + body: await encodeReply(args), + }); + const {returnValue, root} = await createFromFetch(response, {callServer}); + // Refresh the tree with the new RSC payload. + startTransition(() => { + updateRoot(root); + }); + return returnValue; +} + let data = createFromFetch( fetch('/', { headers: { @@ -13,22 +31,14 @@ let data = createFromFetch( }, }), { - async callServer(id, args) { - const response = fetch('/', { - method: 'POST', - headers: { - Accept: 'text/x-component', - 'rsc-action': id, - }, - body: await encodeReply(args), - }); - return createFromFetch(response); - }, + callServer, } ); function Shell({data}) { - return use(data); + const [root, setRoot] = useState(use(data)); + updateRoot = setRoot; + return root; } ReactDOM.hydrateRoot(document, ); diff --git a/packages/react-client/src/ReactFlightClient.js b/packages/react-client/src/ReactFlightClient.js index e8034d257e790..4fdbc0d5cea8a 100644 --- a/packages/react-client/src/ReactFlightClient.js +++ b/packages/react-client/src/ReactFlightClient.js @@ -20,6 +20,8 @@ import type { import type {HintModel} from 'react-server/src/ReactFlightServerConfig'; +import type {CallServerCallback} from './ReactFlightReplyClient'; + import { resolveClientReference, preloadModule, @@ -28,13 +30,16 @@ import { dispatchHint, } from './ReactFlightClientConfig'; -import {knownServerReferences} from './ReactFlightServerReferenceRegistry'; +import { + encodeFormAction, + knownServerReferences, +} from './ReactFlightReplyClient'; import {REACT_LAZY_TYPE, REACT_ELEMENT_TYPE} from 'shared/ReactSymbols'; import {getOrCreateServerContext} from 'shared/ReactServerContextRegistry'; -export type CallServerCallback = (id: any, args: A) => Promise; +export type {CallServerCallback}; export type JSONValue = | number @@ -500,6 +505,9 @@ function createServerReferenceProxy, T>( return callServer(metaData.id, bound.concat(args)); }); }; + // Expose encoder for use by SSR. + // TODO: Only expose this in SSR builds and not the browser client. + proxy.$$FORM_ACTION = encodeFormAction; knownServerReferences.set(proxy, metaData); return proxy; } diff --git a/packages/react-client/src/ReactFlightReplyClient.js b/packages/react-client/src/ReactFlightReplyClient.js index 224af305d64b2..623a28760d4aa 100644 --- a/packages/react-client/src/ReactFlightReplyClient.js +++ b/packages/react-client/src/ReactFlightReplyClient.js @@ -7,12 +7,7 @@ * @flow */ -import type {Thenable} from 'shared/ReactTypes'; - -import { - knownServerReferences, - createServerReference, -} from './ReactFlightServerReferenceRegistry'; +import type {Thenable, ReactCustomFormAction} from 'shared/ReactTypes'; import { REACT_ELEMENT_TYPE, @@ -28,6 +23,10 @@ import { } from 'shared/ReactSerializationErrors'; import isArray from 'shared/isArray'; +import type { + FulfilledThenable, + RejectedThenable, +} from '../../shared/ReactTypes'; type ReactJSONValue = | string @@ -39,6 +38,15 @@ type ReactJSONValue = export opaque type ServerReference = T; +export type CallServerCallback = (id: any, args: A) => Promise; + +export type ServerReferenceId = any; + +export const knownServerReferences: WeakMap< + Function, + {id: ServerReferenceId, bound: null | Thenable>}, +> = new WeakMap(); + // Serializable values export type ReactServerValue = // References are passed by their value @@ -363,4 +371,104 @@ export function processReply( } } -export {createServerReference}; +const boundCache: WeakMap< + {id: ServerReferenceId, bound: null | Thenable>}, + Thenable, +> = new WeakMap(); + +function encodeFormData(reference: any): Thenable { + let resolve, reject; + // We need to have a handle on the thenable so that we can synchronously set + // its status from processReply, when it can complete synchronously. + const thenable: Thenable = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + processReply( + reference, + '', + (body: string | FormData) => { + if (typeof body === 'string') { + const data = new FormData(); + data.append('0', body); + body = data; + } + const fulfilled: FulfilledThenable = (thenable: any); + fulfilled.status = 'fulfilled'; + fulfilled.value = body; + resolve(body); + }, + e => { + const rejected: RejectedThenable = (thenable: any); + rejected.status = 'rejected'; + rejected.reason = e; + reject(e); + }, + ); + return thenable; +} + +export function encodeFormAction( + this: any => Promise, + identifierPrefix: string, +): ReactCustomFormAction { + const reference = knownServerReferences.get(this); + if (!reference) { + throw new Error( + 'Tried to encode a Server Action from a different instance than the encoder is from. ' + + 'This is a bug in React.', + ); + } + let data: null | FormData = null; + let name; + const boundPromise = reference.bound; + if (boundPromise !== null) { + let thenable = boundCache.get(reference); + if (!thenable) { + thenable = encodeFormData(reference); + boundCache.set(reference, thenable); + } + if (thenable.status === 'rejected') { + throw thenable.reason; + } else if (thenable.status !== 'fulfilled') { + throw thenable; + } + const encodedFormData = thenable.value; + // This is hacky but we need the identifier prefix to be added to + // all fields but the suspense cache would break since we might get + // a new identifier each time. So we just append it at the end instead. + const prefixedData = new FormData(); + // $FlowFixMe[prop-missing] + encodedFormData.forEach((value: string | File, key: string) => { + prefixedData.append('$ACTION_' + identifierPrefix + ':' + key, value); + }); + data = prefixedData; + // We encode the name of the prefix containing the data. + name = '$ACTION_REF_' + identifierPrefix; + } else { + // This is the simple case so we can just encode the ID. + name = '$ACTION_ID_' + reference.id; + } + return { + name: name, + method: 'POST', + encType: 'multipart/form-data', + data: data, + }; +} + +export function createServerReference, T>( + id: ServerReferenceId, + callServer: CallServerCallback, +): (...A) => Promise { + const proxy = function (): Promise { + // $FlowFixMe[method-unbinding] + const args = Array.prototype.slice.call(arguments); + return callServer(id, args); + }; + // Expose encoder for use by SSR. + // TODO: Only expose this in SSR builds and not the browser client. + proxy.$$FORM_ACTION = encodeFormAction; + knownServerReferences.set(proxy, {id: id, bound: null}); + return proxy; +} diff --git a/packages/react-client/src/ReactFlightServerReferenceRegistry.js b/packages/react-client/src/ReactFlightServerReferenceRegistry.js deleted file mode 100644 index 06ad06e9b3e46..0000000000000 --- a/packages/react-client/src/ReactFlightServerReferenceRegistry.js +++ /dev/null @@ -1,32 +0,0 @@ -/** - * 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. - * - * @flow - */ - -import type {Thenable} from 'shared/ReactTypes'; - -export type CallServerCallback = (id: any, args: A) => Promise; - -type ServerReferenceId = any; - -export const knownServerReferences: WeakMap< - Function, - {id: ServerReferenceId, bound: null | Thenable>}, -> = new WeakMap(); - -export function createServerReference, T>( - id: ServerReferenceId, - callServer: CallServerCallback, -): (...A) => Promise { - const proxy = function (): Promise { - // $FlowFixMe[method-unbinding] - const args = Array.prototype.slice.call(arguments); - return callServer(id, args); - }; - knownServerReferences.set(proxy, {id: id, bound: null}); - return proxy; -} diff --git a/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js b/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js index b0c23a5c8dea3..353a534090b69 100644 --- a/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js +++ b/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js @@ -672,7 +672,7 @@ function makeFormFieldPrefix(responseState: ResponseState): string { // I'm just reusing this counter. It's not really the same namespace as "name". // It could just be its own counter. const id = responseState.nextSuspenseID++; - return responseState.idPrefix + '$ACTION:' + id + ':'; + return responseState.idPrefix + id; } // Since this will likely be repeated a lot in the HTML, we use a more concise message diff --git a/packages/react-server-dom-webpack/src/ReactFlightDOMServerBrowser.js b/packages/react-server-dom-webpack/src/ReactFlightDOMServerBrowser.js index 777e4271e6e1e..24db03db06669 100644 --- a/packages/react-server-dom-webpack/src/ReactFlightDOMServerBrowser.js +++ b/packages/react-server-dom-webpack/src/ReactFlightDOMServerBrowser.js @@ -25,6 +25,8 @@ import { getRoot, } from 'react-server/src/ReactFlightReplyServer'; +import {decodeAction} from 'react-server/src/ReactFlightActionServer'; + type Options = { identifierPrefix?: string, signal?: AbortSignal, @@ -87,4 +89,4 @@ function decodeReply( return getRoot(response); } -export {renderToReadableStream, decodeReply}; +export {renderToReadableStream, decodeReply, decodeAction}; diff --git a/packages/react-server-dom-webpack/src/ReactFlightDOMServerEdge.js b/packages/react-server-dom-webpack/src/ReactFlightDOMServerEdge.js index 777e4271e6e1e..24db03db06669 100644 --- a/packages/react-server-dom-webpack/src/ReactFlightDOMServerEdge.js +++ b/packages/react-server-dom-webpack/src/ReactFlightDOMServerEdge.js @@ -25,6 +25,8 @@ import { getRoot, } from 'react-server/src/ReactFlightReplyServer'; +import {decodeAction} from 'react-server/src/ReactFlightActionServer'; + type Options = { identifierPrefix?: string, signal?: AbortSignal, @@ -87,4 +89,4 @@ function decodeReply( return getRoot(response); } -export {renderToReadableStream, decodeReply}; +export {renderToReadableStream, decodeReply, decodeAction}; diff --git a/packages/react-server-dom-webpack/src/ReactFlightDOMServerNode.js b/packages/react-server-dom-webpack/src/ReactFlightDOMServerNode.js index f23959b2f8bee..d98bf3baf1f9a 100644 --- a/packages/react-server-dom-webpack/src/ReactFlightDOMServerNode.js +++ b/packages/react-server-dom-webpack/src/ReactFlightDOMServerNode.js @@ -36,6 +36,8 @@ import { getRoot, } from 'react-server/src/ReactFlightReplyServer'; +import {decodeAction} from 'react-server/src/ReactFlightActionServer'; + function createDrainHandler(destination: Destination, request: Request) { return () => startFlowing(request, destination); } @@ -148,4 +150,9 @@ function decodeReply( return getRoot(response); } -export {renderToPipeableStream, decodeReplyFromBusboy, decodeReply}; +export { + renderToPipeableStream, + decodeReplyFromBusboy, + decodeReply, + decodeAction, +}; diff --git a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMForm-test.js b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMForm-test.js new file mode 100644 index 0000000000000..51d208bce3eb4 --- /dev/null +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMForm-test.js @@ -0,0 +1,231 @@ +/** + * 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; + +let container; +let serverExports; +let webpackServerMap; +let React; +let ReactDOMServer; +let ReactServerDOMServer; +let ReactServerDOMClient; + +describe('ReactFlightDOMReply', () => { + beforeEach(() => { + jest.resetModules(); + const WebpackMock = require('./utils/WebpackMock'); + serverExports = WebpackMock.serverExports; + webpackServerMap = WebpackMock.webpackServerMap; + React = require('react'); + ReactServerDOMServer = require('react-server-dom-webpack/server.browser'); + ReactServerDOMClient = require('react-server-dom-webpack/client'); + ReactDOMServer = require('react-dom/server.browser'); + container = document.createElement('div'); + document.body.appendChild(container); + }); + + afterEach(() => { + document.body.removeChild(container); + }); + + async function POST(formData) { + const boundAction = await ReactServerDOMServer.decodeAction( + formData, + webpackServerMap, + ); + return boundAction(); + } + + 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 ( + + + + ); + } + const rscStream = ReactServerDOMServer.renderToReadableStream(); + const response = ReactServerDOMClient.createFromReadableStream(rscStream); + const ssrStream = await ReactDOMServer.renderToReadableStream(response); + await readIntoContainer(ssrStream); + + const form = container.firstChild; + + expect(foo).toBe(null); + + const result = await submit(form); + + expect(result).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 ( +
    + +
    + ); + } + + const ssrStream = await ReactDOMServer.renderToReadableStream(); + await readIntoContainer(ssrStream); + + const form = container.firstChild; + + expect(foo).toBe(null); + + const result = await submit(form); + + expect(result).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 ( +
    + +
    + ); + } + const rscStream = ReactServerDOMServer.renderToReadableStream(); + const response = ReactServerDOMClient.createFromReadableStream(rscStream); + const ssrStream = await ReactDOMServer.renderToReadableStream(response); + await readIntoContainer(ssrStream); + + const form = container.firstChild; + + expect(foo).toBe(null); + + const result = await submit(form); + + expect(result).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 ( +
    + +