Override .bind on Server References on the Client (#27282)
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.
Sebastian Markbåge committed
Aug 25, 2023 at 21:39 UTC
b798223a620dad77077d27e53cc99cbd54312d99
3 files changed
+115
-18
packages/react-client/src/ReactFlightClient.js
+2
-11
@@ -33,13 +33,9 @@ import {
33
readPartialStringChunk,
34
readFinalStringChunk,
35
createStringDecoder,
36
- usedWithSSR,
36
} from './ReactFlightClientConfig';
37
39
-import {
40
- encodeFormAction,
41
- knownServerReferences,
42
-} from './ReactFlightReplyClient';
38
+import {registerServerReference} from './ReactFlightReplyClient';
39
40
import {
41
REACT_LAZY_TYPE,
@@ -545,12 +541,7 @@ function createServerReferenceProxy<A: Iterable<any>, T>(
541
return callServer(metaData.id, bound.concat(args));
542
});
543
};
548
- // Expose encoder for use by SSR.
549
- if (usedWithSSR) {
550
- // Only expose this in builds that would actually use it. Not needed on the client.
551
- (proxy: any).$$FORM_ACTION = encodeFormAction;
552
- }
553
- knownServerReferences.set(proxy, metaData);
544
+ registerServerReference(proxy, metaData);
545
return proxy;
546
}
547
packages/react-client/src/ReactFlightReplyClient.js
+41
-7
@@ -44,7 +44,7 @@ export type CallServerCallback = <A, T>(id: any, args: A) => Promise<T>;
44
45
export type ServerReferenceId = any;
46
47
-export const knownServerReferences: WeakMap<
47
+const knownServerReferences: WeakMap<
48
Function,
49
{id: ServerReferenceId, bound: null | Thenable<Array<any>>},
50
> = new WeakMap();
@@ -488,6 +488,45 @@ export function encodeFormAction(
488
};
489
}
490
491
+export function registerServerReference(
492
+ proxy: any,
493
+ reference: {id: ServerReferenceId, bound: null | Thenable<Array<any>>},
494
+) {
495
+ // Expose encoder for use by SSR, as well as a special bind that can be used to
496
+ // keep server capabilities.
497
+ if (usedWithSSR) {
498
+ // Only expose this in builds that would actually use it. Not needed on the client.
499
+ Object.defineProperties((proxy: any), {
500
+ $$FORM_ACTION: {value: encodeFormAction},
501
+ bind: {value: bind},
502
+ });
503
+ }
504
+ knownServerReferences.set(proxy, reference);
505
+}
506
+
507
+// $FlowFixMe[method-unbinding]
508
+const FunctionBind = Function.prototype.bind;
509
+// $FlowFixMe[method-unbinding]
510
+const ArraySlice = Array.prototype.slice;
511
+function bind(this: Function) {
512
+ // $FlowFixMe[unsupported-syntax]
513
+ const newFn = FunctionBind.apply(this, arguments);
514
+ const reference = knownServerReferences.get(this);
515
+ if (reference) {
516
+ const args = ArraySlice.call(arguments, 1);
517
+ let boundPromise = null;
518
+ if (reference.bound !== null) {
519
+ boundPromise = Promise.resolve((reference.bound: any)).then(boundArgs =>
520
+ boundArgs.concat(args),
521
+ );
522
+ } else {
523
+ boundPromise = Promise.resolve(args);
524
+ }
525
+ registerServerReference(newFn, {id: reference.id, bound: boundPromise});
526
+ }
527
+ return newFn;
528
+}
529
+
530
export function createServerReference<A: Iterable<any>, T>(
531
id: ServerReferenceId,
532
callServer: CallServerCallback,
@@ -497,11 +536,6 @@ export function createServerReference<A: Iterable<any>, T>(
536
const args = Array.prototype.slice.call(arguments);
537
return callServer(id, args);
538
};
500
- // Expose encoder for use by SSR.
501
- if (usedWithSSR) {
502
- // Only expose this in builds that would actually use it. Not needed on the client.
503
- (proxy: any).$$FORM_ACTION = encodeFormAction;
504
- }
505
- knownServerReferences.set(proxy, {id: id, bound: null});
539
+ registerServerReference(proxy, {id, bound: null});
540
return proxy;
541
}
packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMForm-test.js
+72
@@ -22,7 +22,9 @@ global.TextDecoder = require('util').TextDecoder;
22
global.setTimeout = cb => cb();
23
24
let container;
25
+let clientExports;
26
let serverExports;
27
+let webpackMap;
28
let webpackServerMap;
29
let React;
30
let ReactDOMServer;
@@ -37,7 +39,9 @@ describe('ReactFlightDOMForm', () => {
39
require('react-server-dom-webpack/server.edge'),
40
);
41
const WebpackMock = require('./utils/WebpackMock');
42
+ clientExports = WebpackMock.clientExports;
43
serverExports = WebpackMock.serverExports;
44
+ webpackMap = WebpackMock.webpackMap;
45
webpackServerMap = WebpackMock.webpackServerMap;
46
React = require('react');
47
ReactServerDOMServer = require('react-server-dom-webpack/server.edge');
@@ -236,4 +240,72 @@ describe('ReactFlightDOMForm', () => {
240
expect(result).toBe('helloc');
241
expect(foo).toBe('barc');
242
});
243
+
244
+ // @gate enableFormActions
245
+ it('can bind an imported server action on the client without hydrating it', async () => {
246
+ let foo = null;
247
+
248
+ const ServerModule = serverExports(function action(bound, formData) {
249
+ foo = formData.get('foo') + bound.complex;
250
+ return 'hello';
251
+ });
252
+ const serverAction = ReactServerDOMClient.createServerReference(
253
+ ServerModule.$$id,
254
+ );
255
+ function Client() {
256
+ return (
257
+ <form action={serverAction.bind(null, {complex: 'object'})}>
258
+ <input type="text" name="foo" defaultValue="bar" />
259
+ </form>
260
+ );
261
+ }
262
+
263
+ const ssrStream = await ReactDOMServer.renderToReadableStream(<Client />);
264
+ await readIntoContainer(ssrStream);
265
+
266
+ const form = container.firstChild;
267
+
268
+ expect(foo).toBe(null);
269
+
270
+ const result = await submit(form);
271
+
272
+ expect(result).toBe('hello');
273
+ expect(foo).toBe('barobject');
274
+ });
275
+
276
+ // @gate enableFormActions
277
+ it('can bind a server action on the client without hydrating it', async () => {
278
+ let foo = null;
279
+
280
+ const serverAction = serverExports(function action(bound, formData) {
281
+ foo = formData.get('foo') + bound.complex;
282
+ return 'hello';
283
+ });
284
+
285
+ function Client({action}) {
286
+ return (
287
+ <form action={action.bind(null, {complex: 'object'})}>
288
+ <input type="text" name="foo" defaultValue="bar" />
289
+ </form>
290
+ );
291
+ }
292
+ const ClientRef = await clientExports(Client);
293
+
294
+ const rscStream = ReactServerDOMServer.renderToReadableStream(
295
+ <ClientRef action={serverAction} />,
296
+ webpackMap,
297
+ );
298
+ const response = ReactServerDOMClient.createFromReadableStream(rscStream);
299
+ const ssrStream = await ReactDOMServer.renderToReadableStream(response);
300
+ await readIntoContainer(ssrStream);
301
+
302
+ const form = container.firstChild;
303
+
304
+ expect(foo).toBe(null);
305
+
306
+ const result = await submit(form);
307
+
308
+ expect(result).toBe('hello');
309
+ expect(foo).toBe('barobject');
310
+ });
311
});