Skip to content

Commit

Permalink
Override .bind on Server References on the Client
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 committed Aug 25, 2023
1 parent ab31a9e commit e945988
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 e945988

Please sign in to comment.