Skip to content

Commit

Permalink
Fixture with progressive enhancement in forms
Browse files Browse the repository at this point in the history
This uses an in-memory Server State that gets mutated and then the RSC
payload is refreshed after a mutation takes place.
  • Loading branch information
sebmarkbage committed May 3, 2023
1 parent 5a1b033 commit c99b29a
Show file tree
Hide file tree
Showing 8 changed files with 106 additions and 50 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} />);

0 comments on commit c99b29a

Please sign in to comment.