Skip to content

Commit

Permalink
Override .bind on Server References on the Client (#27282)
Browse files Browse the repository at this point in the history
That way when you bind arguments to a Server Reference, it's still a
server reference and works with progressive enhancement.

This already works on the Server (RSC) layer.
  • Loading branch information
sebmarkbage authored Aug 26, 2023
1 parent ab31a9e commit b798223
Show file tree
Hide file tree
Showing 3 changed files with 115 additions and 18 deletions.
13 changes: 2 additions & 11 deletions packages/react-client/src/ReactFlightClient.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,9 @@ import {
readPartialStringChunk,
readFinalStringChunk,
createStringDecoder,
usedWithSSR,
} from './ReactFlightClientConfig';

import {
encodeFormAction,
knownServerReferences,
} from './ReactFlightReplyClient';
import {registerServerReference} from './ReactFlightReplyClient';

import {
REACT_LAZY_TYPE,
Expand Down Expand Up @@ -545,12 +541,7 @@ function createServerReferenceProxy<A: Iterable<any>, T>(
return callServer(metaData.id, bound.concat(args));
});
};
// Expose encoder for use by SSR.
if (usedWithSSR) {
// Only expose this in builds that would actually use it. Not needed on the client.
(proxy: any).$$FORM_ACTION = encodeFormAction;
}
knownServerReferences.set(proxy, metaData);
registerServerReference(proxy, metaData);
return proxy;
}

Expand Down
48 changes: 41 additions & 7 deletions packages/react-client/src/ReactFlightReplyClient.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export type CallServerCallback = <A, T>(id: any, args: A) => Promise<T>;

export type ServerReferenceId = any;

export const knownServerReferences: WeakMap<
const knownServerReferences: WeakMap<
Function,
{id: ServerReferenceId, bound: null | Thenable<Array<any>>},
> = new WeakMap();
Expand Down Expand Up @@ -488,6 +488,45 @@ export function encodeFormAction(
};
}

export function registerServerReference(
proxy: any,
reference: {id: ServerReferenceId, bound: null | Thenable<Array<any>>},
) {
// Expose encoder for use by SSR, as well as a special bind that can be used to
// keep server capabilities.
if (usedWithSSR) {
// Only expose this in builds that would actually use it. Not needed on the client.
Object.defineProperties((proxy: any), {
$$FORM_ACTION: {value: encodeFormAction},
bind: {value: bind},
});
}
knownServerReferences.set(proxy, reference);
}

// $FlowFixMe[method-unbinding]
const FunctionBind = Function.prototype.bind;
// $FlowFixMe[method-unbinding]
const ArraySlice = Array.prototype.slice;
function bind(this: Function) {
// $FlowFixMe[unsupported-syntax]
const newFn = FunctionBind.apply(this, arguments);
const reference = knownServerReferences.get(this);
if (reference) {
const args = ArraySlice.call(arguments, 1);
let boundPromise = null;
if (reference.bound !== null) {
boundPromise = Promise.resolve((reference.bound: any)).then(boundArgs =>
boundArgs.concat(args),
);
} else {
boundPromise = Promise.resolve(args);
}
registerServerReference(newFn, {id: reference.id, bound: boundPromise});
}
return newFn;
}

export function createServerReference<A: Iterable<any>, T>(
id: ServerReferenceId,
callServer: CallServerCallback,
Expand All @@ -497,11 +536,6 @@ export function createServerReference<A: Iterable<any>, T>(
const args = Array.prototype.slice.call(arguments);
return callServer(id, args);
};
// Expose encoder for use by SSR.
if (usedWithSSR) {
// Only expose this in builds that would actually use it. Not needed on the client.
(proxy: any).$$FORM_ACTION = encodeFormAction;
}
knownServerReferences.set(proxy, {id: id, bound: null});
registerServerReference(proxy, {id, bound: null});
return proxy;
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@ global.TextDecoder = require('util').TextDecoder;
global.setTimeout = cb => cb();

let container;
let clientExports;
let serverExports;
let webpackMap;
let webpackServerMap;
let React;
let ReactDOMServer;
Expand All @@ -37,7 +39,9 @@ describe('ReactFlightDOMForm', () => {
require('react-server-dom-webpack/server.edge'),
);
const WebpackMock = require('./utils/WebpackMock');
clientExports = WebpackMock.clientExports;
serverExports = WebpackMock.serverExports;
webpackMap = WebpackMock.webpackMap;
webpackServerMap = WebpackMock.webpackServerMap;
React = require('react');
ReactServerDOMServer = require('react-server-dom-webpack/server.edge');
Expand Down Expand Up @@ -236,4 +240,72 @@ describe('ReactFlightDOMForm', () => {
expect(result).toBe('helloc');
expect(foo).toBe('barc');
});

// @gate enableFormActions
it('can bind an imported server action on the client without hydrating it', async () => {
let foo = null;

const ServerModule = serverExports(function action(bound, formData) {
foo = formData.get('foo') + bound.complex;
return 'hello';
});
const serverAction = ReactServerDOMClient.createServerReference(
ServerModule.$$id,
);
function Client() {
return (
<form action={serverAction.bind(null, {complex: 'object'})}>
<input type="text" name="foo" defaultValue="bar" />
</form>
);
}

const ssrStream = await ReactDOMServer.renderToReadableStream(<Client />);
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 bind a server action on the client without hydrating it', async () => {
let foo = null;

const serverAction = serverExports(function action(bound, formData) {
foo = formData.get('foo') + bound.complex;
return 'hello';
});

function Client({action}) {
return (
<form action={action.bind(null, {complex: 'object'})}>
<input type="text" name="foo" defaultValue="bar" />
</form>
);
}
const ClientRef = await clientExports(Client);

const rscStream = ReactServerDOMServer.renderToReadableStream(
<ClientRef action={serverAction} />,
webpackMap,
);
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');
});
});

0 comments on commit b798223

Please sign in to comment.