[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')) {
|
if (req.get('rsc-action')) {
|
||||||
proxiedHeaders['Content-type'] = req.get('Content-type');
|
proxiedHeaders['Content-type'] = req.get('Content-type');
|
||||||
proxiedHeaders['rsc-action'] = req.get('rsc-action');
|
proxiedHeaders['rsc-action'] = req.get('rsc-action');
|
||||||
|
} else if (req.get('Content-type')) {
|
||||||
|
proxiedHeaders['Content-type'] = req.get('Content-type');
|
||||||
}
|
}
|
||||||
|
|
||||||
const promiseForData = request(
|
const promiseForData = request(
|
||||||
|
|
|
@ -36,6 +36,7 @@ const bodyParser = require('body-parser');
|
||||||
const busboy = require('busboy');
|
const busboy = require('busboy');
|
||||||
const app = express();
|
const app = express();
|
||||||
const compress = require('compression');
|
const compress = require('compression');
|
||||||
|
const {Readable} = require('node:stream');
|
||||||
|
|
||||||
app.use(compress());
|
app.use(compress());
|
||||||
|
|
||||||
|
@ -45,7 +46,7 @@ const {readFile} = require('fs').promises;
|
||||||
|
|
||||||
const React = require('react');
|
const React = require('react');
|
||||||
|
|
||||||
app.get('/', async function (req, res) {
|
async function renderApp(res, returnValue) {
|
||||||
const {renderToPipeableStream} = await import(
|
const {renderToPipeableStream} = await import(
|
||||||
'react-server-dom-webpack/server'
|
'react-server-dom-webpack/server'
|
||||||
);
|
);
|
||||||
|
@ -91,37 +92,74 @@ app.get('/', async function (req, res) {
|
||||||
),
|
),
|
||||||
React.createElement(App),
|
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);
|
pipe(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
app.get('/', async function (req, res) {
|
||||||
|
await renderApp(res, null);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/', bodyParser.text(), async function (req, res) {
|
app.post('/', bodyParser.text(), async function (req, res) {
|
||||||
const {renderToPipeableStream, decodeReply, decodeReplyFromBusboy} =
|
const {
|
||||||
await import('react-server-dom-webpack/server');
|
renderToPipeableStream,
|
||||||
|
decodeReply,
|
||||||
|
decodeReplyFromBusboy,
|
||||||
|
decodeAction,
|
||||||
|
} = await import('react-server-dom-webpack/server');
|
||||||
const serverReference = req.get('rsc-action');
|
const serverReference = req.get('rsc-action');
|
||||||
const [filepath, name] = serverReference.split('#');
|
if (serverReference) {
|
||||||
const action = (await import(filepath))[name];
|
// This is the client-side case
|
||||||
// Validate that this is actually a function we intended to expose and
|
const [filepath, name] = serverReference.split('#');
|
||||||
// not the client trying to invoke arbitrary functions. In a real app,
|
const action = (await import(filepath))[name];
|
||||||
// you'd have a manifest verifying this before even importing it.
|
// Validate that this is actually a function we intended to expose and
|
||||||
if (action.$$typeof !== Symbol.for('react.server.reference')) {
|
// not the client trying to invoke arbitrary functions. In a real app,
|
||||||
throw new Error('Invalid action');
|
// 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;
|
let args;
|
||||||
if (req.is('multipart/form-data')) {
|
if (req.is('multipart/form-data')) {
|
||||||
// Use busboy to streamingly parse the reply from form-data.
|
// Use busboy to streamingly parse the reply from form-data.
|
||||||
const bb = busboy({headers: req.headers});
|
const bb = busboy({headers: req.headers});
|
||||||
const reply = decodeReplyFromBusboy(bb);
|
const reply = decodeReplyFromBusboy(bb);
|
||||||
req.pipe(bb);
|
req.pipe(bb);
|
||||||
args = await reply;
|
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 {
|
} 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) {
|
app.get('/todos', function (req, res) {
|
||||||
|
|
|
@ -11,6 +11,8 @@ import Form from './Form.js';
|
||||||
|
|
||||||
import {like, greet} from './actions.js';
|
import {like, greet} from './actions.js';
|
||||||
|
|
||||||
|
import {getServerState} from './ServerState.js';
|
||||||
|
|
||||||
export default async function App() {
|
export default async function App() {
|
||||||
const res = await fetch('http://localhost:3001/todos');
|
const res = await fetch('http://localhost:3001/todos');
|
||||||
const todos = await res.json();
|
const todos = await res.json();
|
||||||
|
@ -23,7 +25,7 @@ export default async function App() {
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<Container>
|
<Container>
|
||||||
<h1>Hello, world</h1>
|
<h1>{getServerState()}</h1>
|
||||||
<Counter />
|
<Counter />
|
||||||
<Counter2 />
|
<Counter2 />
|
||||||
<ul>
|
<ul>
|
||||||
|
|
|
@ -7,12 +7,7 @@ import ErrorBoundary from './ErrorBoundary.js';
|
||||||
function ButtonDisabledWhilePending({action, children}) {
|
function ButtonDisabledWhilePending({action, children}) {
|
||||||
const {pending} = useFormStatus();
|
const {pending} = useFormStatus();
|
||||||
return (
|
return (
|
||||||
<button
|
<button disabled={pending} formAction={action}>
|
||||||
disabled={pending}
|
|
||||||
formAction={async () => {
|
|
||||||
const result = await action();
|
|
||||||
console.log(result);
|
|
||||||
}}>
|
|
||||||
{children}
|
{children}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|
|
@ -14,11 +14,7 @@ export default function Form({action, children}) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<form
|
<form action={action}>
|
||||||
action={async formData => {
|
|
||||||
const result = await action(formData);
|
|
||||||
alert(result);
|
|
||||||
}}>
|
|
||||||
<label>
|
<label>
|
||||||
Name: <input name="name" />
|
Name: <input name="name" />
|
||||||
</label>
|
</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';
|
'use server';
|
||||||
|
|
||||||
|
import {setServerState} from './ServerState.js';
|
||||||
|
|
||||||
export async function like() {
|
export async function like() {
|
||||||
|
setServerState('Liked!');
|
||||||
return new Promise((resolve, reject) => resolve('Liked'));
|
return new Promise((resolve, reject) => resolve('Liked'));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function greet(formData) {
|
export async function greet(formData) {
|
||||||
const name = formData.get('name') || 'you';
|
const name = formData.get('name') || 'you';
|
||||||
|
setServerState('Hi ' + name);
|
||||||
const file = formData.get('file');
|
const file = formData.get('file');
|
||||||
if (file) {
|
if (file) {
|
||||||
return `Ok, ${name}, here is ${file.name}:
|
return `Ok, ${name}, here is ${file.name}:
|
||||||
|
|
|
@ -1,11 +1,29 @@
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import {use, Suspense} from 'react';
|
import {use, Suspense, useState, startTransition} from 'react';
|
||||||
import ReactDOM from 'react-dom/client';
|
import ReactDOM from 'react-dom/client';
|
||||||
import {createFromFetch, encodeReply} from 'react-server-dom-webpack/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.
|
// TODO: This should be a dependency of the App but we haven't implemented CSS in Node yet.
|
||||||
import './style.css';
|
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(
|
let data = createFromFetch(
|
||||||
fetch('/', {
|
fetch('/', {
|
||||||
headers: {
|
headers: {
|
||||||
|
@ -13,22 +31,14 @@ let data = createFromFetch(
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
async callServer(id, args) {
|
callServer,
|
||||||
const response = fetch('/', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
Accept: 'text/x-component',
|
|
||||||
'rsc-action': id,
|
|
||||||
},
|
|
||||||
body: await encodeReply(args),
|
|
||||||
});
|
|
||||||
return createFromFetch(response);
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
function Shell({data}) {
|
function Shell({data}) {
|
||||||
return use(data);
|
const [root, setRoot] = useState(use(data));
|
||||||
|
updateRoot = setRoot;
|
||||||
|
return root;
|
||||||
}
|
}
|
||||||
|
|
||||||
ReactDOM.hydrateRoot(document, <Shell data={data} />);
|
ReactDOM.hydrateRoot(document, <Shell data={data} />);
|
||||||
|
|
|
@ -20,6 +20,8 @@ import type {
|
||||||
|
|
||||||
import type {HintModel} from 'react-server/src/ReactFlightServerConfig';
|
import type {HintModel} from 'react-server/src/ReactFlightServerConfig';
|
||||||
|
|
||||||
|
import type {CallServerCallback} from './ReactFlightReplyClient';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
resolveClientReference,
|
resolveClientReference,
|
||||||
preloadModule,
|
preloadModule,
|
||||||
|
@ -28,13 +30,16 @@ import {
|
||||||
dispatchHint,
|
dispatchHint,
|
||||||
} from './ReactFlightClientConfig';
|
} from './ReactFlightClientConfig';
|
||||||
|
|
||||||
import {knownServerReferences} from './ReactFlightServerReferenceRegistry';
|
import {
|
||||||
|
encodeFormAction,
|
||||||
|
knownServerReferences,
|
||||||
|
} from './ReactFlightReplyClient';
|
||||||
|
|
||||||
import {REACT_LAZY_TYPE, REACT_ELEMENT_TYPE} from 'shared/ReactSymbols';
|
import {REACT_LAZY_TYPE, REACT_ELEMENT_TYPE} from 'shared/ReactSymbols';
|
||||||
|
|
||||||
import {getOrCreateServerContext} from 'shared/ReactServerContextRegistry';
|
import {getOrCreateServerContext} from 'shared/ReactServerContextRegistry';
|
||||||
|
|
||||||
export type CallServerCallback = <A, T>(id: any, args: A) => Promise<T>;
|
export type {CallServerCallback};
|
||||||
|
|
||||||
export type JSONValue =
|
export type JSONValue =
|
||||||
| number
|
| number
|
||||||
|
@ -500,6 +505,9 @@ function createServerReferenceProxy<A: Iterable<any>, T>(
|
||||||
return callServer(metaData.id, bound.concat(args));
|
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);
|
knownServerReferences.set(proxy, metaData);
|
||||||
return proxy;
|
return proxy;
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,12 +7,7 @@
|
||||||
* @flow
|
* @flow
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type {Thenable} from 'shared/ReactTypes';
|
import type {Thenable, ReactCustomFormAction} from 'shared/ReactTypes';
|
||||||
|
|
||||||
import {
|
|
||||||
knownServerReferences,
|
|
||||||
createServerReference,
|
|
||||||
} from './ReactFlightServerReferenceRegistry';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
REACT_ELEMENT_TYPE,
|
REACT_ELEMENT_TYPE,
|
||||||
|
@ -28,6 +23,10 @@ import {
|
||||||
} from 'shared/ReactSerializationErrors';
|
} from 'shared/ReactSerializationErrors';
|
||||||
|
|
||||||
import isArray from 'shared/isArray';
|
import isArray from 'shared/isArray';
|
||||||
|
import type {
|
||||||
|
FulfilledThenable,
|
||||||
|
RejectedThenable,
|
||||||
|
} from '../../shared/ReactTypes';
|
||||||
|
|
||||||
type ReactJSONValue =
|
type ReactJSONValue =
|
||||||
| string
|
| string
|
||||||
|
@ -39,6 +38,15 @@ type ReactJSONValue =
|
||||||
|
|
||||||
export opaque type ServerReference<T> = T;
|
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
|
// Serializable values
|
||||||
export type ReactServerValue =
|
export type ReactServerValue =
|
||||||
// References are passed by their value
|
// 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".
|
// I'm just reusing this counter. It's not really the same namespace as "name".
|
||||||
// It could just be its own counter.
|
// It could just be its own counter.
|
||||||
const id = responseState.nextSuspenseID++;
|
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
|
// Since this will likely be repeated a lot in the HTML, we use a more concise message
|
||||||
|
|
|
@ -25,6 +25,8 @@ import {
|
||||||
getRoot,
|
getRoot,
|
||||||
} from 'react-server/src/ReactFlightReplyServer';
|
} from 'react-server/src/ReactFlightReplyServer';
|
||||||
|
|
||||||
|
import {decodeAction} from 'react-server/src/ReactFlightActionServer';
|
||||||
|
|
||||||
type Options = {
|
type Options = {
|
||||||
identifierPrefix?: string,
|
identifierPrefix?: string,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
|
@ -87,4 +89,4 @@ function decodeReply<T>(
|
||||||
return getRoot(response);
|
return getRoot(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
export {renderToReadableStream, decodeReply};
|
export {renderToReadableStream, decodeReply, decodeAction};
|
||||||
|
|
|
@ -25,6 +25,8 @@ import {
|
||||||
getRoot,
|
getRoot,
|
||||||
} from 'react-server/src/ReactFlightReplyServer';
|
} from 'react-server/src/ReactFlightReplyServer';
|
||||||
|
|
||||||
|
import {decodeAction} from 'react-server/src/ReactFlightActionServer';
|
||||||
|
|
||||||
type Options = {
|
type Options = {
|
||||||
identifierPrefix?: string,
|
identifierPrefix?: string,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
|
@ -87,4 +89,4 @@ function decodeReply<T>(
|
||||||
return getRoot(response);
|
return getRoot(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
export {renderToReadableStream, decodeReply};
|
export {renderToReadableStream, decodeReply, decodeAction};
|
||||||
|
|
|
@ -36,6 +36,8 @@ import {
|
||||||
getRoot,
|
getRoot,
|
||||||
} from 'react-server/src/ReactFlightReplyServer';
|
} from 'react-server/src/ReactFlightReplyServer';
|
||||||
|
|
||||||
|
import {decodeAction} from 'react-server/src/ReactFlightActionServer';
|
||||||
|
|
||||||
function createDrainHandler(destination: Destination, request: Request) {
|
function createDrainHandler(destination: Destination, request: Request) {
|
||||||
return () => startFlowing(request, destination);
|
return () => startFlowing(request, destination);
|
||||||
}
|
}
|
||||||
|
@ -148,4 +150,9 @@ function decodeReply<T>(
|
||||||
return getRoot(response);
|
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.",
|
"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.",
|
"478": "Thenable should have already resolved. This is a bug in React.",
|
||||||
"479": "Cannot update optimistic state while rendering.",
|
"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