diff --git a/fixtures/flight/server/global.js b/fixtures/flight/server/global.js index a2f5697f43..f402174a68 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 3481af3bf8..d4d62f5202 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'); - } + 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; + 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 5e6fe4927d..f4fba45917 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 7e43bc2b16..999c84f580 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 c166f5a16b..d98452c683 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 0000000000..3d4c716226 --- /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 87cba005e0..3d26189979 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 3e8b7e5bcc..d75feee56e 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 e8034d257e..4fdbc0d5ce 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 224af305d6..623a28760d 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 06ad06e9b3..0000000000 --- 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 b0c23a5c8d..353a534090 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 777e4271e6..24db03db06 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 777e4271e6..24db03db06 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 f23959b2f8..d98bf3baf1 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 0000000000..51d208bce3 --- /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 ( +
    + +