[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.
This commit is contained in:
parent
c10010a6a0
commit
aef7ce5547
|
@ -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(
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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() {
|
|||
</head>
|
||||
<body>
|
||||
<Container>
|
||||
<h1>Hello, world</h1>
|
||||
<h1>{getServerState()}</h1>
|
||||
<Counter />
|
||||
<Counter2 />
|
||||
<ul>
|
||||
|
|
|
@ -7,12 +7,7 @@ import ErrorBoundary from './ErrorBoundary.js';
|
|||
function ButtonDisabledWhilePending({action, children}) {
|
||||
const {pending} = useFormStatus();
|
||||
return (
|
||||
<button
|
||||
disabled={pending}
|
||||
formAction={async () => {
|
||||
const result = await action();
|
||||
console.log(result);
|
||||
}}>
|
||||
<button disabled={pending} formAction={action}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
|
|
|
@ -14,11 +14,7 @@ export default function Form({action, children}) {
|
|||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<form
|
||||
action={async formData => {
|
||||
const result = await action(formData);
|
||||
alert(result);
|
||||
}}>
|
||||
<form action={action}>
|
||||
<label>
|
||||
Name: <input name="name" />
|
||||
</label>
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
let serverState = 'Hello World';
|
||||
|
||||
export function setServerState(message) {
|
||||
serverState = message;
|
||||
}
|
||||
|
||||
export function getServerState() {
|
||||
return serverState;
|
||||
}
|
|
@ -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}:
|
||||
|
|
|
@ -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, <Shell data={data} />);
|
||||
|
|
|
@ -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 = <A, T>(id: any, args: A) => Promise<T>;
|
||||
export type {CallServerCallback};
|
||||
|
||||
export type JSONValue =
|
||||
| number
|
||||
|
@ -500,6 +505,9 @@ function createServerReferenceProxy<A: Iterable<any>, 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;
|
||||
}
|
||||
|
|
|
@ -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> = T;
|
||||
|
||||
export type CallServerCallback = <A, T>(id: any, args: A) => Promise<T>;
|
||||
|
||||
export type ServerReferenceId = any;
|
||||
|
||||
export const knownServerReferences: WeakMap<
|
||||
Function,
|
||||
{id: ServerReferenceId, bound: null | Thenable<Array<any>>},
|
||||
> = 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<Array<any>>},
|
||||
Thenable<FormData>,
|
||||
> = new WeakMap();
|
||||
|
||||
function encodeFormData(reference: any): Thenable<FormData> {
|
||||
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<FormData> = 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<FormData> = (thenable: any);
|
||||
fulfilled.status = 'fulfilled';
|
||||
fulfilled.value = body;
|
||||
resolve(body);
|
||||
},
|
||||
e => {
|
||||
const rejected: RejectedThenable<FormData> = (thenable: any);
|
||||
rejected.status = 'rejected';
|
||||
rejected.reason = e;
|
||||
reject(e);
|
||||
},
|
||||
);
|
||||
return thenable;
|
||||
}
|
||||
|
||||
export function encodeFormAction(
|
||||
this: any => Promise<any>,
|
||||
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<A: Iterable<any>, T>(
|
||||
id: ServerReferenceId,
|
||||
callServer: CallServerCallback,
|
||||
): (...A) => Promise<T> {
|
||||
const proxy = function (): Promise<T> {
|
||||
// $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;
|
||||
}
|
||||
|
|
|
@ -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 = <A, T>(id: any, args: A) => Promise<T>;
|
||||
|
||||
type ServerReferenceId = any;
|
||||
|
||||
export const knownServerReferences: WeakMap<
|
||||
Function,
|
||||
{id: ServerReferenceId, bound: null | Thenable<Array<any>>},
|
||||
> = new WeakMap();
|
||||
|
||||
export function createServerReference<A: Iterable<any>, T>(
|
||||
id: ServerReferenceId,
|
||||
callServer: CallServerCallback,
|
||||
): (...A) => Promise<T> {
|
||||
const proxy = function (): Promise<T> {
|
||||
// $FlowFixMe[method-unbinding]
|
||||
const args = Array.prototype.slice.call(arguments);
|
||||
return callServer(id, args);
|
||||
};
|
||||
knownServerReferences.set(proxy, {id: id, bound: null});
|
||||
return proxy;
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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<T>(
|
|||
return getRoot(response);
|
||||
}
|
||||
|
||||
export {renderToReadableStream, decodeReply};
|
||||
export {renderToReadableStream, decodeReply, decodeAction};
|
||||
|
|
|
@ -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<T>(
|
|||
return getRoot(response);
|
||||
}
|
||||
|
||||
export {renderToReadableStream, decodeReply};
|
||||
export {renderToReadableStream, decodeReply, decodeAction};
|
||||
|
|
|
@ -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<T>(
|
|||
return getRoot(response);
|
||||
}
|
||||
|
||||
export {renderToPipeableStream, decodeReplyFromBusboy, decodeReply};
|
||||
export {
|
||||
renderToPipeableStream,
|
||||
decodeReplyFromBusboy,
|
||||
decodeReply,
|
||||
decodeAction,
|
||||
};
|
||||
|
|
|
@ -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 (
|
||||
<form action={serverAction}>
|
||||
<input type="text" name="foo" defaultValue="bar" />
|
||||
</form>
|
||||
);
|
||||
}
|
||||
const rscStream = ReactServerDOMServer.renderToReadableStream(<App />);
|
||||
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 (
|
||||
<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 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 (
|
||||
<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);
|
||||
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 (
|
||||
<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);
|
||||
const ssrStream = await ReactDOMServer.renderToReadableStream(response);
|
||||
await readIntoContainer(ssrStream);
|
||||
|
||||
const form = container.firstChild;
|
||||
|
||||
expect(foo).toBe(null);
|
||||
|
||||
const result = await submit(form.getElementsByTagName('button')[1]);
|
||||
|
||||
expect(result).toBe('helloc');
|
||||
expect(foo).toBe('barc');
|
||||
});
|
||||
});
|
|
@ -0,0 +1,110 @@
|
|||
/**
|
||||
* 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';
|
||||
|
||||
import type {
|
||||
ServerManifest,
|
||||
ClientReference as ServerReference,
|
||||
} from 'react-client/src/ReactFlightClientConfig';
|
||||
|
||||
import {
|
||||
resolveServerReference,
|
||||
preloadModule,
|
||||
requireModule,
|
||||
} from 'react-client/src/ReactFlightClientConfig';
|
||||
|
||||
import {createResponse, close, getRoot} from './ReactFlightReplyServer';
|
||||
|
||||
type ServerReferenceId = any;
|
||||
|
||||
function bindArgs(fn: any, args: any) {
|
||||
return fn.bind.apply(fn, [null].concat(args));
|
||||
}
|
||||
|
||||
function loadServerReference<T>(
|
||||
bundlerConfig: ServerManifest,
|
||||
id: ServerReferenceId,
|
||||
bound: null | Thenable<Array<any>>,
|
||||
): Promise<T> {
|
||||
const serverReference: ServerReference<T> =
|
||||
resolveServerReference<$FlowFixMe>(bundlerConfig, id);
|
||||
// We expect most servers to not really need this because you'd just have all
|
||||
// the relevant modules already loaded but it allows for lazy loading of code
|
||||
// if needed.
|
||||
const preloadPromise = preloadModule(serverReference);
|
||||
if (bound) {
|
||||
return Promise.all([(bound: any), preloadPromise]).then(
|
||||
([args]: Array<any>) => bindArgs(requireModule(serverReference), args),
|
||||
);
|
||||
} else if (preloadPromise) {
|
||||
return Promise.resolve(preloadPromise).then(() =>
|
||||
requireModule(serverReference),
|
||||
);
|
||||
} else {
|
||||
// Synchronously available
|
||||
return Promise.resolve(requireModule(serverReference));
|
||||
}
|
||||
}
|
||||
|
||||
export function decodeAction<T>(
|
||||
body: FormData,
|
||||
serverManifest: ServerManifest,
|
||||
): Promise<() => T> | null {
|
||||
// We're going to create a new formData object that holds all the fields except
|
||||
// the implementation details of the action data.
|
||||
const formData = new FormData();
|
||||
|
||||
let action: Promise<(formData: FormData) => T> | null = null;
|
||||
|
||||
// $FlowFixMe[prop-missing]
|
||||
body.forEach((value: string | File, key: string) => {
|
||||
if (!key.startsWith('$ACTION_')) {
|
||||
formData.append(key, value);
|
||||
return;
|
||||
}
|
||||
// Later actions may override earlier actions if a button is used to override the default
|
||||
// form action.
|
||||
if (key.startsWith('$ACTION_REF_')) {
|
||||
const formFieldPrefix = '$ACTION_' + key.slice(12) + ':';
|
||||
// The data for this reference is encoded in multiple fields under this prefix.
|
||||
const actionResponse = createResponse(
|
||||
serverManifest,
|
||||
formFieldPrefix,
|
||||
body,
|
||||
);
|
||||
close(actionResponse);
|
||||
const refPromise = getRoot<{
|
||||
id: ServerReferenceId,
|
||||
bound: null | Promise<Array<any>>,
|
||||
}>(actionResponse);
|
||||
// Force it to initialize
|
||||
// $FlowFixMe
|
||||
refPromise.then(() => {});
|
||||
if (refPromise.status !== 'fulfilled') {
|
||||
// $FlowFixMe
|
||||
throw refPromise.reason;
|
||||
}
|
||||
const metaData = refPromise.value;
|
||||
action = loadServerReference(serverManifest, metaData.id, metaData.bound);
|
||||
return;
|
||||
}
|
||||
if (key.startsWith('$ACTION_ID_')) {
|
||||
const id = key.slice(11);
|
||||
action = loadServerReference(serverManifest, id, null);
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
if (action === null) {
|
||||
return null;
|
||||
}
|
||||
// Return the action with the remaining FormData bound to the first argument.
|
||||
return action.then(fn => fn.bind(null, formData));
|
||||
}
|
|
@ -465,5 +465,6 @@
|
|||
"477": "React Internal Error: processHintChunk is not implemented for Native-Relay. The fact that this method was called means there is a bug in React.",
|
||||
"478": "Thenable should have already resolved. This is a bug in React.",
|
||||
"479": "Cannot update optimistic state while rendering.",
|
||||
"480": "File/Blob fields are not yet supported in progressive forms. It probably means you are closing over binary data or FormData in a Server Action."
|
||||
"480": "File/Blob fields are not yet supported in progressive forms. It probably means you are closing over binary data or FormData in a Server Action.",
|
||||
"481": "Tried to encode a Server Action from a different instance than the encoder is from. This is a bug in React."
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue