Skip to content

Commit

Permalink
Encode $$FORM_ACTION on Server References so that they can be submitt…
Browse files Browse the repository at this point in the history
…ed before hydration
  • Loading branch information
sebmarkbage committed May 3, 2023
1 parent c10010a commit 6098ca0
Show file tree
Hide file tree
Showing 5 changed files with 262 additions and 42 deletions.
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
51 changes: 44 additions & 7 deletions packages/react-client/src/ReactFlightReplyClient.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -39,6 +34,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
Expand Down Expand Up @@ -363,4 +367,37 @@ export function processReply(
}
}

export {createServerReference};
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.',
);
}
return {
name: '$ACTION_' + reference.id,
method: 'POST',
encType: 'multipart/form-data',
data: null,
};
}

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;
}
32 changes: 0 additions & 32 deletions packages/react-client/src/ReactFlightServerReferenceRegistry.js

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
/**
* 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 actionResult;
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);
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);
}

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);
}
POST(formData);
return;
}
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);

submit(form);

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

// @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);

submit(form);

expect(foo).toBe('bar');

expect(await actionResult).toBe('hi');
});
});
3 changes: 2 additions & 1 deletion scripts/error-codes/codes.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}

0 comments on commit 6098ca0

Please sign in to comment.