Skip to content

Commit

Permalink
[Flight] Progressively Enhanced Server Actions (#26774)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
sebmarkbage authored May 3, 2023
1 parent c10010a commit aef7ce5
Show file tree
Hide file tree
Showing 18 changed files with 589 additions and 96 deletions.
2 changes: 2 additions & 0 deletions fixtures/flight/server/global.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
88 changes: 63 additions & 25 deletions fixtures/flight/server/region.js
Original file line number Diff line number Diff line change
Expand Up @@ -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());

Expand All @@ -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'
);
Expand Down Expand Up @@ -91,37 +92,74 @@ app.get('/', async function (req, res) {
),
React.createElement(App),
];
const {pipe} = renderToPipeableStream(root, moduleMap);
// For client-invoked server actions we refresh the tree and return a return value.
const payload = returnValue ? {returnValue, root} : root;
const {pipe} = renderToPipeableStream(payload, moduleMap);
pipe(res);
}

app.get('/', async function (req, res) {
await renderApp(res, null);
});

app.post('/', bodyParser.text(), async function (req, res) {
const {renderToPipeableStream, decodeReply, decodeReplyFromBusboy} =
await import('react-server-dom-webpack/server');
const {
renderToPipeableStream,
decodeReply,
decodeReplyFromBusboy,
decodeAction,
} = await import('react-server-dom-webpack/server');
const serverReference = req.get('rsc-action');
const [filepath, name] = serverReference.split('#');
const action = (await import(filepath))[name];
// Validate that this is actually a function we intended to expose and
// not the client trying to invoke arbitrary functions. In a real app,
// you'd have a manifest verifying this before even importing it.
if (action.$$typeof !== Symbol.for('react.server.reference')) {
throw new Error('Invalid action');
}

let args;
if (req.is('multipart/form-data')) {
// Use busboy to streamingly parse the reply from form-data.
const bb = busboy({headers: req.headers});
const reply = decodeReplyFromBusboy(bb);
req.pipe(bb);
args = await reply;
if (serverReference) {
// This is the client-side case
const [filepath, name] = serverReference.split('#');
const action = (await import(filepath))[name];
// Validate that this is actually a function we intended to expose and
// not the client trying to invoke arbitrary functions. In a real app,
// you'd have a manifest verifying this before even importing it.
if (action.$$typeof !== Symbol.for('react.server.reference')) {
throw new Error('Invalid action');
}

let args;
if (req.is('multipart/form-data')) {
// Use busboy to streamingly parse the reply from form-data.
const bb = busboy({headers: req.headers});
const reply = decodeReplyFromBusboy(bb);
req.pipe(bb);
args = await reply;
} else {
args = await decodeReply(req.body);
}
const result = action.apply(null, args);
try {
// Wait for any mutations
await result;
} catch (x) {
// We handle the error on the client
}
// Refresh the client and return the value
renderApp(res, result);
} else {
args = await decodeReply(req.body);
// This is the progressive enhancement case
const UndiciRequest = require('undici').Request;
const fakeRequest = new UndiciRequest('http://localhost', {
method: 'POST',
headers: {'Content-Type': req.headers['content-type']},
body: Readable.toWeb(req),
duplex: 'half',
});
const formData = await fakeRequest.formData();
const action = await decodeAction(formData);
try {
// Wait for any mutations
await action();
} catch (x) {
const {setServerState} = await import('../src/ServerState.js');
setServerState('Error: ' + x.message);
}
renderApp(res, null);
}

const result = action.apply(null, args);
const {pipe} = renderToPipeableStream(result, {});
pipe(res);
});

app.get('/todos', function (req, res) {
Expand Down
4 changes: 3 additions & 1 deletion fixtures/flight/src/App.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -23,7 +25,7 @@ export default async function App() {
</head>
<body>
<Container>
<h1>Hello, world</h1>
<h1>{getServerState()}</h1>
<Counter />
<Counter2 />
<ul>
Expand Down
7 changes: 1 addition & 6 deletions fixtures/flight/src/Button.js
Original file line number Diff line number Diff line change
Expand Up @@ -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>
);
Expand Down
6 changes: 1 addition & 5 deletions fixtures/flight/src/Form.js
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
9 changes: 9 additions & 0 deletions fixtures/flight/src/ServerState.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
let serverState = 'Hello World';

export function setServerState(message) {
serverState = message;
}

export function getServerState() {
return serverState;
}
4 changes: 4 additions & 0 deletions fixtures/flight/src/actions.js
Original file line number Diff line number Diff line change
@@ -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}:
Expand Down
36 changes: 23 additions & 13 deletions fixtures/flight/src/index.js
Original file line number Diff line number Diff line change
@@ -1,34 +1,44 @@
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: {
Accept: 'text/x-component',
},
}),
{
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} />);
12 changes: 10 additions & 2 deletions packages/react-client/src/ReactFlightClient.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import type {

import type {HintModel} from 'react-server/src/ReactFlightServerConfig';

import type {CallServerCallback} from './ReactFlightReplyClient';

import {
resolveClientReference,
preloadModule,
Expand All @@ -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
Expand Down Expand Up @@ -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;
}
Expand Down
Loading

0 comments on commit aef7ce5

Please sign in to comment.