[Flight]: Client-side `registerServerReference` must not break `.bind()` (#32565)
Hendrik Liebau committed
Mar 11, 2025 at 22:15 UTC
2398554c6034e6d0992fcaa1c2e95f1757cab53e
2 files changed
+94
-61
packages/react-client/src/ReactFlightReplyClient.js
+87
-61
@@ -63,10 +63,14 @@ export type EncodeFormActionCallback = <A>(
63
64
export type ServerReferenceId = any;
65
66
-const knownServerReferences: WeakMap<
67
- Function,
68
- {id: ServerReferenceId, bound: null | Thenable<Array<any>>},
69
-> = new WeakMap();
66
+type ServerReferenceClosure = {
67
+ id: ServerReferenceId,
68
+ originalBind: Function,
69
+ bound: null | Thenable<Array<any>>,
70
+};
71
+
72
+const knownServerReferences: WeakMap<Function, ServerReferenceClosure> =
73
+ new WeakMap();
74
75
// Serializable values
76
export type ReactServerValue =
@@ -760,16 +764,17 @@ export function processReply(
764
}
765
766
if (typeof value === 'function') {
763
- const metaData = knownServerReferences.get(value);
764
- if (metaData !== undefined) {
765
- const metaDataJSON = JSON.stringify(metaData, resolveToJSON);
767
+ const referenceClosure = knownServerReferences.get(value);
768
+ if (referenceClosure !== undefined) {
769
+ const {id, bound} = referenceClosure;
770
+ const referenceClosureJSON = JSON.stringify({id, bound}, resolveToJSON);
771
if (formData === null) {
772
// Upgrade to use FormData to allow us to stream this value.
773
formData = new FormData();
774
}
775
// The reference to this function came from the same client so we can pass it back.
776
const refId = nextPartId++;
772
- formData.set(formFieldPrefix + refId, metaDataJSON);
777
+ formData.set(formFieldPrefix + refId, referenceClosureJSON);
778
return serializeServerReferenceID(refId);
779
}
780
if (temporaryReferences !== undefined && key.indexOf(':') === -1) {
@@ -864,7 +869,7 @@ export function processReply(
869
}
870
871
const boundCache: WeakMap<
867
- {id: ServerReferenceId, bound: null | Thenable<Array<any>>},
872
+ ServerReferenceClosure,
873
Thenable<FormData>,
874
> = new WeakMap();
875
@@ -905,8 +910,8 @@ function defaultEncodeFormAction(
910
this: any => Promise<any>,
911
identifierPrefix: string,
912
): ReactCustomFormAction {
908
- const reference = knownServerReferences.get(this);
909
- if (!reference) {
913
+ const referenceClosure = knownServerReferences.get(this);
914
+ if (!referenceClosure) {
915
throw new Error(
916
'Tried to encode a Server Action from a different instance than the encoder is from. ' +
917
'This is a bug in React.',
@@ -914,12 +919,13 @@ function defaultEncodeFormAction(
919
}
920
let data: null | FormData = null;
921
let name;
917
- const boundPromise = reference.bound;
922
+ const boundPromise = referenceClosure.bound;
923
if (boundPromise !== null) {
919
- let thenable = boundCache.get(reference);
924
+ let thenable = boundCache.get(referenceClosure);
925
if (!thenable) {
921
- thenable = encodeFormData(reference);
922
- boundCache.set(reference, thenable);
926
+ const {id, bound} = referenceClosure;
927
+ thenable = encodeFormData({id, bound});
928
+ boundCache.set(referenceClosure, thenable);
929
}
930
if (thenable.status === 'rejected') {
931
throw thenable.reason;
@@ -941,7 +947,7 @@ function defaultEncodeFormAction(
947
name = '$ACTION_REF_' + identifierPrefix;
948
} else {
949
// This is the simple case so we can just encode the ID.
944
- name = '$ACTION_ID_' + reference.id;
950
+ name = '$ACTION_ID_' + referenceClosure.id;
951
}
952
return {
953
name: name,
@@ -952,22 +958,22 @@ function defaultEncodeFormAction(
958
}
959
960
function customEncodeFormAction(
955
- proxy: any => Promise<any>,
961
+ reference: any => Promise<any>,
962
identifierPrefix: string,
963
encodeFormAction: EncodeFormActionCallback,
964
): ReactCustomFormAction {
959
- const reference = knownServerReferences.get(proxy);
960
- if (!reference) {
965
+ const referenceClosure = knownServerReferences.get(reference);
966
+ if (!referenceClosure) {
967
throw new Error(
968
'Tried to encode a Server Action from a different instance than the encoder is from. ' +
969
'This is a bug in React.',
970
);
971
}
966
- let boundPromise: Promise<Array<any>> = (reference.bound: any);
972
+ let boundPromise: Promise<Array<any>> = (referenceClosure.bound: any);
973
if (boundPromise === null) {
974
boundPromise = Promise.resolve([]);
975
}
970
- return encodeFormAction(reference.id, boundPromise);
976
+ return encodeFormAction(referenceClosure.id, boundPromise);
977
}
978
979
function isSignatureEqual(
@@ -975,19 +981,19 @@ function isSignatureEqual(
981
referenceId: ServerReferenceId,
982
numberOfBoundArgs: number,
983
): boolean {
978
- const reference = knownServerReferences.get(this);
979
- if (!reference) {
984
+ const referenceClosure = knownServerReferences.get(this);
985
+ if (!referenceClosure) {
986
throw new Error(
987
'Tried to encode a Server Action from a different instance than the encoder is from. ' +
988
'This is a bug in React.',
989
);
990
}
985
- if (reference.id !== referenceId) {
991
+ if (referenceClosure.id !== referenceId) {
992
// These are different functions.
993
return false;
994
}
995
// Now check if the number of bound arguments is the same.
990
- const boundPromise = reference.bound;
996
+ const boundPromise = referenceClosure.bound;
997
if (boundPromise === null) {
998
// No bound arguments.
999
return numberOfBoundArgs === 0;
@@ -1131,10 +1137,20 @@ export function registerBoundServerReference<T: Function>(
1137
bound: null | Thenable<Array<any>>,
1138
encodeFormAction: void | EncodeFormActionCallback,
1139
): void {
1140
+ if (knownServerReferences.has(reference)) {
1141
+ return;
1142
+ }
1143
+
1144
+ knownServerReferences.set(reference, {
1145
+ id,
1146
+ originalBind: reference.bind,
1147
+ bound,
1148
+ });
1149
+
1150
// Expose encoder for use by SSR, as well as a special bind that can be used to
1151
// keep server capabilities.
1152
if (usedWithSSR) {
1137
- // Only expose this in builds that would actually use it. Not needed on the client.
1153
+ // Only expose this in builds that would actually use it. Not needed in the browser.
1154
const $$FORM_ACTION =
1155
encodeFormAction === undefined
1156
? defaultEncodeFormAction
@@ -1154,7 +1170,6 @@ export function registerBoundServerReference<T: Function>(
1170
bind: {value: bind},
1171
});
1172
}
1157
- knownServerReferences.set(reference, {id, bound});
1173
}
1174
1175
export function registerServerReference<T: Function>(
@@ -1171,43 +1186,54 @@ const FunctionBind = Function.prototype.bind;
1186
// $FlowFixMe[method-unbinding]
1187
const ArraySlice = Array.prototype.slice;
1188
function bind(this: Function): Function {
1174
- // $FlowFixMe[unsupported-syntax]
1175
- // $FlowFixMe[prop-missing]
1176
- const newFn = FunctionBind.apply(this, arguments);
1177
- const reference = knownServerReferences.get(this);
1178
- if (reference) {
1179
- if (__DEV__) {
1180
- const thisBind = arguments[0];
1181
- if (thisBind != null) {
1182
- // This doesn't warn in browser environments since it's not instrumented outside
1183
- // usedWithSSR. This makes this an SSR only warning which we don't generally do.
1184
- // TODO: Consider a DEV only instrumentation in the browser.
1185
- console.error(
1186
- 'Cannot bind "this" of a Server Action. Pass null or undefined as the first argument to .bind().',
1187
- );
1188
- }
1189
- }
1190
- const args = ArraySlice.call(arguments, 1);
1191
- let boundPromise = null;
1192
- if (reference.bound !== null) {
1193
- boundPromise = Promise.resolve((reference.bound: any)).then(boundArgs =>
1194
- boundArgs.concat(args),
1189
+ const referenceClosure = knownServerReferences.get(this);
1190
+
1191
+ if (!referenceClosure) {
1192
+ // $FlowFixMe[prop-missing]
1193
+ return FunctionBind.apply(this, arguments);
1194
+ }
1195
+
1196
+ const newFn = referenceClosure.originalBind.apply(this, arguments);
1197
+
1198
+ if (__DEV__) {
1199
+ const thisBind = arguments[0];
1200
+ if (thisBind != null) {
1201
+ // This doesn't warn in browser environments since it's not instrumented outside
1202
+ // usedWithSSR. This makes this an SSR only warning which we don't generally do.
1203
+ // TODO: Consider a DEV only instrumentation in the browser.
1204
+ console.error(
1205
+ 'Cannot bind "this" of a Server Action. Pass null or undefined as the first argument to .bind().',
1206
);
1196
- } else {
1197
- boundPromise = Promise.resolve(args);
1198
- }
1199
- // Expose encoder for use by SSR, as well as a special bind that can be used to
1200
- // keep server capabilities.
1201
- if (usedWithSSR) {
1202
- // Only expose this in builds that would actually use it. Not needed on the client.
1203
- Object.defineProperties((newFn: any), {
1204
- $$FORM_ACTION: {value: this.$$FORM_ACTION},
1205
- $$IS_SIGNATURE_EQUAL: {value: isSignatureEqual},
1206
- bind: {value: bind},
1207
- });
1207
}
1209
- knownServerReferences.set(newFn, {id: reference.id, bound: boundPromise});
1208
}
1209
+
1210
+ const args = ArraySlice.call(arguments, 1);
1211
+ let boundPromise = null;
1212
+ if (referenceClosure.bound !== null) {
1213
+ boundPromise = Promise.resolve((referenceClosure.bound: any)).then(
1214
+ boundArgs => boundArgs.concat(args),
1215
+ );
1216
+ } else {
1217
+ boundPromise = Promise.resolve(args);
1218
+ }
1219
+
1220
+ knownServerReferences.set(newFn, {
1221
+ id: referenceClosure.id,
1222
+ originalBind: newFn.bind,
1223
+ bound: boundPromise,
1224
+ });
1225
+
1226
+ // Expose encoder for use by SSR, as well as a special bind that can be used to
1227
+ // keep server capabilities.
1228
+ if (usedWithSSR) {
1229
+ // Only expose this in builds that would actually use it. Not needed on the client.
1230
+ Object.defineProperties((newFn: any), {
1231
+ $$FORM_ACTION: {value: this.$$FORM_ACTION},
1232
+ $$IS_SIGNATURE_EQUAL: {value: isSignatureEqual},
1233
+ bind: {value: bind},
1234
+ });
1235
+ }
1236
+
1237
return newFn;
1238
}
1239
packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js
+7
@@ -309,6 +309,13 @@ describe('ReactFlightDOMEdge', () => {
309
greet,
310
});
311
312
+ // Registering the server reference also with the client must not break
313
+ // subsequent `.bind` calls.
314
+ ReactServerDOMClient.registerServerReference(
315
+ ServerModule.greet,
316
+ ServerModule.greet.$$id,
317
+ );
318
+
319
const stream = await serverAct(() =>
320
ReactServerDOMServer.renderToReadableStream(
321
{