[Flight] Add serverModuleMap option for mapping ServerReferences (#31300)
Stacked on #31299. We already have an option for resolving Client References to other Client References when consuming an RSC payload on the server. This lets you resolve Server References on the consuming side when the environment where you're consuming the RSC payload also has access to those Server References. Basically they becomes like Client References for this consumer but for another consumer they wouldn't be.
Sebastian Markbåge committed
Oct 19, 2024 at 21:10 UTC
22b2b1a05a86f599d2eea9d0419ac57db510e134
12 files changed
+239
-12
packages/react-client/src/ReactFlightClient.js
+153
-12
@@ -21,6 +21,7 @@ import type {
21
ClientReference,
22
ClientReferenceMetadata,
23
ServerConsumerModuleMap,
24
+ ServerManifest,
25
StringDecoder,
26
ModuleLoading,
27
} from './ReactFlightClientConfig';
@@ -51,6 +52,7 @@ import {
52
53
import {
54
resolveClientReference,
55
+ resolveServerReference,
56
preloadModule,
57
requireModule,
58
dispatchHint,
@@ -270,6 +272,7 @@ export type FindSourceMapURLCallback = (
272
273
export type Response = {
274
_bundlerConfig: ServerConsumerModuleMap,
275
+ _serverReferenceConfig: null | ServerManifest,
276
_moduleLoading: ModuleLoading,
277
_callServer: CallServerCallback,
278
_encodeFormAction: void | EncodeFormActionCallback,
@@ -896,7 +899,7 @@ function waitForReference<T>(
899
parentObject: Object,
900
key: string,
901
response: Response,
899
- map: (response: Response, model: any) => T,
902
+ map: (response: Response, model: any, parentObject: Object, key: string) => T,
903
path: Array<string>,
904
): T {
905
let handler: InitializationHandler;
@@ -938,7 +941,7 @@ function waitForReference<T>(
941
}
942
value = value[path[i]];
943
}
941
- const mappedValue = map(response, value);
944
+ const mappedValue = map(response, value, parentObject, key);
945
parentObject[key] = mappedValue;
946
947
// If this is the root object for a model reference, where `handler.value`
@@ -1041,7 +1044,7 @@ function waitForReference<T>(
1044
return (null: any);
1045
}
1046
1044
-function createServerReferenceProxy<A: Iterable<any>, T>(
1047
+function loadServerReference<A: Iterable<any>, T>(
1048
response: Response,
1049
metaData: {
1050
id: any,
@@ -1050,13 +1053,147 @@ function createServerReferenceProxy<A: Iterable<any>, T>(
1053
env?: string, // DEV-only
1054
location?: ReactCallSite, // DEV-only
1055
},
1056
+ parentObject: Object,
1057
+ key: string,
1058
): (...A) => Promise<T> {
1054
- return createBoundServerReference(
1055
- metaData,
1056
- response._callServer,
1057
- response._encodeFormAction,
1058
- __DEV__ ? response._debugFindSourceMapURL : undefined,
1059
- );
1059
+ if (!response._serverReferenceConfig) {
1060
+ // In the normal case, we can't load this Server Reference in the current environment and
1061
+ // we just return a proxy to it.
1062
+ return createBoundServerReference(
1063
+ metaData,
1064
+ response._callServer,
1065
+ response._encodeFormAction,
1066
+ __DEV__ ? response._debugFindSourceMapURL : undefined,
1067
+ );
1068
+ }
1069
+ // If we have a module mapping we can load the real version of this Server Reference.
1070
+ const serverReference: ClientReference<T> =
1071
+ resolveServerReference<$FlowFixMe>(
1072
+ response._serverReferenceConfig,
1073
+ metaData.id,
1074
+ );
1075
+
1076
+ const promise = preloadModule(serverReference);
1077
+ if (!promise) {
1078
+ return (requireModule(serverReference): any);
1079
+ }
1080
+
1081
+ let handler: InitializationHandler;
1082
+ if (initializingHandler) {
1083
+ handler = initializingHandler;
1084
+ handler.deps++;
1085
+ } else {
1086
+ handler = initializingHandler = {
1087
+ parent: null,
1088
+ chunk: null,
1089
+ value: null,
1090
+ deps: 1,
1091
+ errored: false,
1092
+ };
1093
+ }
1094
+
1095
+ function fulfill(): void {
1096
+ const resolvedValue = (requireModule(serverReference): any);
1097
+ parentObject[key] = resolvedValue;
1098
+
1099
+ // If this is the root object for a model reference, where `handler.value`
1100
+ // is a stale `null`, the resolved value can be used directly.
1101
+ if (key === '' && handler.value === null) {
1102
+ handler.value = resolvedValue;
1103
+ }
1104
+
1105
+ // If the parent object is an unparsed React element tuple, we also need to
1106
+ // update the props and owner of the parsed element object (i.e.
1107
+ // handler.value).
1108
+ if (
1109
+ parentObject[0] === REACT_ELEMENT_TYPE &&
1110
+ typeof handler.value === 'object' &&
1111
+ handler.value !== null &&
1112
+ handler.value.$$typeof === REACT_ELEMENT_TYPE
1113
+ ) {
1114
+ const element: any = handler.value;
1115
+ switch (key) {
1116
+ case '3':
1117
+ element.props = resolvedValue;
1118
+ break;
1119
+ case '4':
1120
+ if (__DEV__) {
1121
+ element._owner = resolvedValue;
1122
+ }
1123
+ break;
1124
+ }
1125
+ }
1126
+
1127
+ handler.deps--;
1128
+
1129
+ if (handler.deps === 0) {
1130
+ const chunk = handler.chunk;
1131
+ if (chunk === null || chunk.status !== BLOCKED) {
1132
+ return;
1133
+ }
1134
+ const resolveListeners = chunk.value;
1135
+ const initializedChunk: InitializedChunk<T> = (chunk: any);
1136
+ initializedChunk.status = INITIALIZED;
1137
+ initializedChunk.value = handler.value;
1138
+ if (resolveListeners !== null) {
1139
+ wakeChunk(resolveListeners, handler.value);
1140
+ }
1141
+ }
1142
+ }
1143
+
1144
+ function reject(error: mixed): void {
1145
+ if (handler.errored) {
1146
+ // We've already errored. We could instead build up an AggregateError
1147
+ // but if there are multiple errors we just take the first one like
1148
+ // Promise.all.
1149
+ return;
1150
+ }
1151
+ const blockedValue = handler.value;
1152
+ handler.errored = true;
1153
+ handler.value = error;
1154
+ const chunk = handler.chunk;
1155
+ if (chunk === null || chunk.status !== BLOCKED) {
1156
+ return;
1157
+ }
1158
+
1159
+ if (__DEV__) {
1160
+ if (
1161
+ typeof blockedValue === 'object' &&
1162
+ blockedValue !== null &&
1163
+ blockedValue.$$typeof === REACT_ELEMENT_TYPE
1164
+ ) {
1165
+ const element = blockedValue;
1166
+ // Conceptually the error happened inside this Element but right before
1167
+ // it was rendered. We don't have a client side component to render but
1168
+ // we can add some DebugInfo to explain that this was conceptually a
1169
+ // Server side error that errored inside this element. That way any stack
1170
+ // traces will point to the nearest JSX that errored - e.g. during
1171
+ // serialization.
1172
+ const erroredComponent: ReactComponentInfo = {
1173
+ name: getComponentNameFromType(element.type) || '',
1174
+ owner: element._owner,
1175
+ };
1176
+ if (enableOwnerStacks) {
1177
+ // $FlowFixMe[cannot-write]
1178
+ erroredComponent.debugStack = element._debugStack;
1179
+ if (supportsCreateTask) {
1180
+ // $FlowFixMe[cannot-write]
1181
+ erroredComponent.debugTask = element._debugTask;
1182
+ }
1183
+ }
1184
+ const chunkDebugInfo: ReactDebugInfo =
1185
+ chunk._debugInfo || (chunk._debugInfo = []);
1186
+ chunkDebugInfo.push(erroredComponent);
1187
+ }
1188
+ }
1189
+
1190
+ triggerErrorOnChunk(chunk, error);
1191
+ }
1192
+
1193
+ promise.then(fulfill, reject);
1194
+
1195
+ // Return a place holder value for now.
1196
+ return (null: any);
1197
}
1198
1199
function getOutlinedModel<T>(
@@ -1064,7 +1201,7 @@ function getOutlinedModel<T>(
1201
reference: string,
1202
parentObject: Object,
1203
key: string,
1067
- map: (response: Response, model: any) => T,
1204
+ map: (response: Response, model: any, parentObject: Object, key: string) => T,
1205
): T {
1206
const path = reference.split(':');
1207
const id = parseInt(path[0], 16);
@@ -1099,7 +1236,7 @@ function getOutlinedModel<T>(
1236
}
1237
value = value[path[i]];
1238
}
1102
- const chunkValue = map(response, value);
1239
+ const chunkValue = map(response, value, parentObject, key);
1240
if (__DEV__ && chunk._debugInfo) {
1241
// If we have a direct reference to an object that was rendered by a synchronous
1242
// server component, it might have some debug info about how it was rendered.
@@ -1244,7 +1381,7 @@ function parseModelString(
1381
ref,
1382
parentObject,
1383
key,
1247
- createServerReferenceProxy,
1384
+ loadServerReference,
1385
);
1386
}
1387
case 'T': {
@@ -1421,6 +1558,7 @@ function missingCall() {
1558
function ResponseInstance(
1559
this: $FlowFixMe,
1560
bundlerConfig: ServerConsumerModuleMap,
1561
+ serverReferenceConfig: null | ServerManifest,
1562
moduleLoading: ModuleLoading,
1563
callServer: void | CallServerCallback,
1564
encodeFormAction: void | EncodeFormActionCallback,
@@ -1432,6 +1570,7 @@ function ResponseInstance(
1570
) {
1571
const chunks: Map<number, SomeChunk<any>> = new Map();
1572
this._bundlerConfig = bundlerConfig;
1573
+ this._serverReferenceConfig = serverReferenceConfig;
1574
this._moduleLoading = moduleLoading;
1575
this._callServer = callServer !== undefined ? callServer : missingCall;
1576
this._encodeFormAction = encodeFormAction;
@@ -1486,6 +1625,7 @@ function ResponseInstance(
1625
1626
export function createResponse(
1627
bundlerConfig: ServerConsumerModuleMap,
1628
+ serverReferenceConfig: null | ServerManifest,
1629
moduleLoading: ModuleLoading,
1630
callServer: void | CallServerCallback,
1631
encodeFormAction: void | EncodeFormActionCallback,
@@ -1498,6 +1638,7 @@ export function createResponse(
1638
// $FlowFixMe[invalid-constructor]: the shapes are exact here but Flow doesn't like constructors
1639
return new ResponseInstance(
1640
bundlerConfig,
1641
+ serverReferenceConfig,
1642
moduleLoading,
1643
callServer,
1644
encodeFormAction,
packages/react-markup/src/ReactMarkupServer.js
+1
@@ -173,6 +173,7 @@ export function experimental_renderToHTML(
173
undefined,
174
);
175
const flightResponse = createFlightResponse(
176
+ null,
177
null,
178
null,
179
noServerCallOrFormAction,
packages/react-noop-renderer/src/ReactNoopFlightClient.js
+1
@@ -62,6 +62,7 @@ function read<T>(source: Source, options: ReadOptions): Thenable<T> {
62
const response = createResponse(
63
source,
64
null,
65
+ null,
66
undefined,
67
undefined,
68
undefined,
packages/react-server-dom-esm/src/client/ReactFlightDOMClientBrowser.js
+1
@@ -51,6 +51,7 @@ function createResponseFromOptions(options: void | Options) {
51
return createResponse(
52
options && options.moduleBaseURL ? options.moduleBaseURL : '',
53
null,
54
+ null,
55
options && options.callServer ? options.callServer : undefined,
56
undefined, // encodeFormAction
57
undefined, // nonce
packages/react-server-dom-esm/src/client/ReactFlightDOMClientNode.js
+1
@@ -62,6 +62,7 @@ function createFromNodeStream<T>(
62
): Thenable<T> {
63
const response: Response = createResponse(
64
moduleRootPath,
65
+ null,
66
moduleBaseURL,
67
noServerCall,
68
options ? options.encodeFormAction : undefined,
packages/react-server-dom-turbopack/src/client/ReactFlightDOMClientBrowser.js
+1
@@ -48,6 +48,7 @@ export type Options = {
48
49
function createResponseFromOptions(options: void | Options) {
50
return createResponse(
51
+ null,
52
null,
53
null,
54
options && options.callServer ? options.callServer : undefined,
packages/react-server-dom-turbopack/src/client/ReactFlightDOMClientEdge.js
+3
@@ -19,11 +19,13 @@ import type {ReactServerValue} from 'react-client/src/ReactFlightReplyClient';
19
import type {
20
ServerConsumerModuleMap,
21
ModuleLoading,
22
+ ServerManifest,
23
} from 'react-client/src/ReactFlightClientConfig';
24
25
type ServerConsumerManifest = {
26
moduleMap: ServerConsumerModuleMap,
27
moduleLoading: ModuleLoading,
28
+ serverModuleMap: null | ServerManifest,
29
};
30
31
import {
@@ -78,6 +80,7 @@ export type Options = {
80
function createResponseFromOptions(options: Options) {
81
return createResponse(
82
options.serverManifest.moduleMap,
83
+ options.serverManifest.serverModuleMap,
84
options.serverManifest.moduleLoading,
85
noServerCall,
86
options.encodeFormAction,
packages/react-server-dom-turbopack/src/client/ReactFlightDOMClientNode.js
+3
@@ -17,11 +17,13 @@ import type {
17
import type {
18
ServerConsumerModuleMap,
19
ModuleLoading,
20
+ ServerManifest,
21
} from 'react-client/src/ReactFlightClientConfig';
22
23
type ServerConsumerManifest = {
24
moduleMap: ServerConsumerModuleMap,
25
moduleLoading: ModuleLoading,
26
+ serverModuleMap: null | ServerManifest,
27
};
28
29
import type {Readable} from 'stream';
@@ -71,6 +73,7 @@ function createFromNodeStream<T>(
73
): Thenable<T> {
74
const response: Response = createResponse(
75
serverConsumerManifest.moduleMap,
76
+ serverConsumerManifest.serverModuleMap,
77
serverConsumerManifest.moduleLoading,
78
noServerCall,
79
options ? options.encodeFormAction : undefined,
packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js
+68
@@ -26,6 +26,7 @@ global.AsyncLocalStorage = require('async_hooks').AsyncLocalStorage;
26
let serverExports;
27
let clientExports;
28
let webpackMap;
29
+let webpackServerMap;
30
let webpackModules;
31
let webpackModuleLoading;
32
let React;
@@ -63,6 +64,7 @@ describe('ReactFlightDOMEdge', () => {
64
serverExports = WebpackMock.serverExports;
65
clientExports = WebpackMock.clientExports;
66
webpackMap = WebpackMock.webpackMap;
67
+ webpackServerMap = WebpackMock.webpackServerMap;
68
webpackModules = WebpackMock.webpackModules;
69
webpackModuleLoading = WebpackMock.moduleLoading;
70
@@ -212,6 +214,72 @@ describe('ReactFlightDOMEdge', () => {
214
expect(result).toEqual('<span>Client Component</span>');
215
});
216
217
+ it('should be able to load a server reference on a consuming server if a mapping exists', async () => {
218
+ function greet() {
219
+ return 'hi';
220
+ }
221
+ const ServerModule = serverExports({
222
+ greet,
223
+ });
224
+
225
+ const stream = await serverAct(() =>
226
+ ReactServerDOMServer.renderToReadableStream(
227
+ {
228
+ method: ServerModule.greet,
229
+ },
230
+ webpackMap,
231
+ ),
232
+ );
233
+ const response = ReactServerDOMClient.createFromReadableStream(stream, {
234
+ serverConsumerManifest: {
235
+ moduleMap: webpackMap,
236
+ serverModuleMap: webpackServerMap,
237
+ moduleLoading: webpackModuleLoading,
238
+ },
239
+ });
240
+
241
+ const result = await response;
242
+
243
+ expect(result.method).toBe(greet);
244
+ });
245
+
246
+ it('should be able to load a server reference on a consuming server if a mapping exists (async)', async () => {
247
+ let resolve;
248
+ const chunkPromise = new Promise(r => (resolve = r));
249
+
250
+ function greet() {
251
+ return 'hi';
252
+ }
253
+ const ServerModule = serverExports(
254
+ {
255
+ greet,
256
+ },
257
+ chunkPromise,
258
+ );
259
+
260
+ const stream = await serverAct(() =>
261
+ ReactServerDOMServer.renderToReadableStream(
262
+ {
263
+ method: ServerModule.greet,
264
+ },
265
+ webpackMap,
266
+ ),
267
+ );
268
+ const response = ReactServerDOMClient.createFromReadableStream(stream, {
269
+ serverConsumerManifest: {
270
+ moduleMap: webpackMap,
271
+ serverModuleMap: webpackServerMap,
272
+ moduleLoading: webpackModuleLoading,
273
+ },
274
+ });
275
+
276
+ await resolve();
277
+
278
+ const result = await response;
279
+
280
+ expect(result.method).toBe(greet);
281
+ });
282
+
283
it('should encode long string in a compact format', async () => {
284
const testString = '"\n\t'.repeat(500) + '🙃';
285
const testString2 = 'hello'.repeat(400);
packages/react-server-dom-webpack/src/client/ReactFlightDOMClientBrowser.js
+1
@@ -48,6 +48,7 @@ export type Options = {
48
49
function createResponseFromOptions(options: void | Options) {
50
return createResponse(
51
+ null,
52
null,
53
null,
54
options && options.callServer ? options.callServer : undefined,
packages/react-server-dom-webpack/src/client/ReactFlightDOMClientEdge.js
+3
@@ -19,11 +19,13 @@ import type {ReactServerValue} from 'react-client/src/ReactFlightReplyClient';
19
import type {
20
ServerConsumerModuleMap,
21
ModuleLoading,
22
+ ServerManifest,
23
} from 'react-client/src/ReactFlightClientConfig';
24
25
type ServerConsumerManifest = {
26
moduleMap: ServerConsumerModuleMap,
27
moduleLoading: ModuleLoading,
28
+ serverModuleMap: null | ServerManifest,
29
};
30
31
import {
@@ -78,6 +80,7 @@ export type Options = {
80
function createResponseFromOptions(options: Options) {
81
return createResponse(
82
options.serverConsumerManifest.moduleMap,
83
+ options.serverConsumerManifest.serverModuleMap,
84
options.serverConsumerManifest.moduleLoading,
85
noServerCall,
86
options.encodeFormAction,
packages/react-server-dom-webpack/src/client/ReactFlightDOMClientNode.js
+3
@@ -17,11 +17,13 @@ import type {
17
import type {
18
ServerConsumerModuleMap,
19
ModuleLoading,
20
+ ServerManifest,
21
} from 'react-client/src/ReactFlightClientConfig';
22
23
type ServerConsumerManifest = {
24
moduleMap: ServerConsumerModuleMap,
25
moduleLoading: ModuleLoading,
26
+ serverModuleMap: null | ServerManifest,
27
};
28
29
import type {Readable} from 'stream';
@@ -72,6 +74,7 @@ function createFromNodeStream<T>(
74
): Thenable<T> {
75
const response: Response = createResponse(
76
serverConsumerManifest.moduleMap,
77
+ serverConsumerManifest.serverModuleMap,
78
serverConsumerManifest.moduleLoading,
79
noServerCall,
80
options ? options.encodeFormAction : undefined,