@samitouri / QOS-React / commits / 2398554c60

[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 {