Skip to content

Commit

Permalink
Encode action ids and closures automatically and expose decodeAction
Browse files Browse the repository at this point in the history
decodeAction lets you take a form that was posted back to the server and
automatically decode the first action's server reference and its bound
arguments.

We must wait to fill up the FormData since we're going to pass them all as
arguments to the action and we don't know how many there will be.
  • Loading branch information
sebmarkbage committed May 3, 2023
1 parent 6098ca0 commit 5a1b033
Show file tree
Hide file tree
Showing 7 changed files with 274 additions and 58 deletions.
75 changes: 73 additions & 2 deletions packages/react-client/src/ReactFlightReplyClient.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ import {
} from 'shared/ReactSerializationErrors';

import isArray from 'shared/isArray';
import type {
FulfilledThenable,
RejectedThenable,
} from '../../shared/ReactTypes';

type ReactJSONValue =
| string
Expand Down Expand Up @@ -367,6 +371,43 @@ export function processReply(
}
}

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,
Expand All @@ -378,11 +419,41 @@ export function encodeFormAction(
'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: '$ACTION_' + reference.id,
name: name,
method: 'POST',
encType: 'multipart/form-data',
data: null,
data: data,
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ import {
getRoot,
} from 'react-server/src/ReactFlightReplyServer';

import {decodeAction} from 'react-server/src/ReactFlightActionServer';

type Options = {
identifierPrefix?: string,
signal?: AbortSignal,
Expand Down Expand Up @@ -87,4 +89,4 @@ function decodeReply<T>(
return getRoot(response);
}

export {renderToReadableStream, decodeReply};
export {renderToReadableStream, decodeReply, decodeAction};
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ import {
getRoot,
} from 'react-server/src/ReactFlightReplyServer';

import {decodeAction} from 'react-server/src/ReactFlightActionServer';

type Options = {
identifierPrefix?: string,
signal?: AbortSignal,
Expand Down Expand Up @@ -87,4 +89,4 @@ function decodeReply<T>(
return getRoot(response);
}

export {renderToReadableStream, decodeReply};
export {renderToReadableStream, decodeReply, decodeAction};
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -148,4 +150,9 @@ function decodeReply<T>(
return getRoot(response);
}

export {renderToPipeableStream, decodeReplyFromBusboy, decodeReply};
export {
renderToPipeableStream,
decodeReplyFromBusboy,
decodeReply,
decodeAction,
};
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ global.ReadableStream =
global.TextEncoder = require('util').TextEncoder;
global.TextDecoder = require('util').TextDecoder;

let actionResult;
let container;
let serverExports;
let webpackServerMap;
Expand All @@ -38,57 +37,18 @@ describe('ReactFlightDOMReply', () => {
ReactDOMServer = require('react-dom/server.browser');
container = document.createElement('div');
document.body.appendChild(container);
actionResult = undefined;
});

afterEach(() => {
document.body.removeChild(container);
});

function requireServerRef(ref) {
let name = '';
let resolvedModuleData = webpackServerMap[ref];
if (resolvedModuleData) {
// The potentially aliased name.
name = resolvedModuleData.name;
} else {
// We didn't find this specific export name but we might have the * export
// which contains this name as well.
// TODO: It's unfortunate that we now have to parse this string. We should
// probably go back to encoding path and name separately on the client reference.
const idx = ref.lastIndexOf('#');
if (idx !== -1) {
name = ref.slice(idx + 1);
resolvedModuleData = webpackServerMap[ref.slice(0, idx)];
}
if (!resolvedModuleData) {
throw new Error(
'Could not find the module "' +
ref +
'" in the React Client Manifest. ' +
'This is probably a bug in the React Server Components bundler.',
);
}
}
const mod = __webpack_require__(resolvedModuleData.id);
if (name === '*') {
return mod;
}
return mod[name];
}

function POST(formData) {
let actionId = null;
formData.forEach((v, key) => {
if (key.startsWith('$ACTION_') && actionId === null) {
actionId = key.slice(8);
}
});
if (actionId === null) {
throw new Error('Missing action');
}
const action = requireServerRef(actionId);
actionResult = action(formData);
async function POST(formData) {
const boundAction = await ReactServerDOMServer.decodeAction(
formData,
webpackServerMap,
);
return boundAction();
}

function submit(submitter) {
Expand Down Expand Up @@ -119,8 +79,7 @@ describe('ReactFlightDOMReply', () => {
} else {
formData = new FormData(form);
}
POST(formData);
return;
return POST(formData);
}
throw new Error('Navigate to: ' + action);
}
Expand Down Expand Up @@ -165,10 +124,10 @@ describe('ReactFlightDOMReply', () => {

expect(foo).toBe(null);

submit(form);
const result = await submit(form);

expect(result).toBe('hello');
expect(foo).toBe('bar');
expect(await actionResult).toBe('hello');
});

// @gate enableFormActions
Expand Down Expand Up @@ -197,10 +156,76 @@ describe('ReactFlightDOMReply', () => {

expect(foo).toBe(null);

submit(form);
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(await actionResult).toBe('hi');
expect(result).toBe('helloc');
expect(foo).toBe('barc');
});
});
Loading

0 comments on commit 5a1b033

Please sign in to comment.