Simplify Webpack References by encoding file path + export name as single id (#26300)

We always look up these references in a map so it doesn't matter what
their value is. It could be a hash for example.

The loaders now encode a single $$id instead of filepath + name.

This changes the react-client-manifest to have a single level. The value
inside the map is still split into module id + export name because
that's what gets looked up in webpack.

The react-ssr-manifest is still two levels because that's a reverse
lookup.
This commit is contained in:
Sebastian Markbåge 2023-03-04 19:51:34 -05:00 committed by GitHub
parent 25685d8a90
commit e0241b6600
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 120 additions and 157 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

View File

@ -98,8 +98,8 @@ app.post('/', bodyParser.text(), async function (req, res) {
const {renderToPipeableStream} = await import(
'react-server-dom-webpack/server'
);
const serverReference = JSON.parse(req.get('rsc-action'));
const {filepath, name} = serverReference;
const serverReference = req.get('rsc-action');
const [filepath, name] = serverReference.split('#');
const action = (await import(filepath))[name];
// Validate that this is actually a function we intended to expose and
// not the client trying to invoke arbitrary functions. In a real app,

View File

@ -18,7 +18,7 @@ let data = ReactServerDOMReader.createFromFetch(
method: 'POST',
headers: {
Accept: 'text/x-component',
'rsc-action': JSON.stringify({filepath: id.id, name: id.name}),
'rsc-action': id,
},
body: JSON.stringify(args),
});

View File

@ -473,7 +473,7 @@ function createModelReject<T>(chunk: SomeChunk<T>): (error: mixed) => void {
function createServerReferenceProxy<A: Iterable<any>, T>(
response: Response,
metaData: any,
metaData: {id: any, bound: Thenable<Array<any>>},
): (...A) => Promise<T> {
const callServer = response._callServer;
const proxy = function (): Promise<T> {
@ -482,12 +482,14 @@ function createServerReferenceProxy<A: Iterable<any>, T>(
const p = metaData.bound;
if (p.status === INITIALIZED) {
const bound = p.value;
return callServer(metaData, bound.concat(args));
return callServer(metaData.id, bound.concat(args));
}
// Since this is a fake Promise whose .then doesn't chain, we have to wrap it.
// TODO: Remove the wrapper once that's fixed.
return Promise.resolve(p).then(function (bound) {
return callServer(metaData, bound.concat(args));
return ((Promise.resolve(p): any): Promise<Array<any>>).then(function (
bound,
) {
return callServer(metaData.id, bound.concat(args));
});
};
return proxy;

View File

@ -19,7 +19,7 @@ import isArray from 'shared/isArray';
export type ClientReference<T> = JSResourceReference<T>;
export type ServerReference<T> = T;
export type ServerReferenceMetadata = {};
export type ServerReferenceId = {};
import type {
Destination,
@ -69,7 +69,7 @@ export function resolveClientReferenceMetadata<T>(
export function resolveServerReferenceMetadata<T>(
config: BundlerConfig,
resource: ServerReference<T>,
): ServerReferenceMetadata {
): {id: ServerReferenceId, bound: Promise<Array<any>>} {
throw new Error('Not implemented.');
}

View File

@ -25,7 +25,6 @@ export opaque type ClientReferenceMetadata = {
id: string,
chunks: Array<string>,
name: string,
async: boolean,
};
// eslint-disable-next-line no-unused-vars

View File

@ -20,10 +20,7 @@ import {
close,
} from 'react-client/src/ReactFlightClientStream';
type CallServerCallback = <A, T>(
{filepath: string, name: string},
args: A,
) => Promise<T>;
type CallServerCallback = <A, T>(string, args: A) => Promise<T>;
export type Options = {
callServer?: CallServerCallback,

View File

@ -10,32 +10,24 @@
import type {ReactModel} from 'react-server/src/ReactFlightServer';
type WebpackMap = {
[filepath: string]: {
[name: string]: ClientReferenceMetadata,
},
[id: string]: ClientReferenceMetadata,
};
export type BundlerConfig = WebpackMap;
export type ServerReference<T: Function> = T & {
$$typeof: symbol,
$$filepath: string,
$$name: string,
$$id: string,
$$bound: Array<ReactModel>,
};
export type ServerReferenceMetadata = {
id: string,
name: string,
bound: Promise<Array<ReactModel>>,
};
export type ServerReferenceId = string;
// eslint-disable-next-line no-unused-vars
export type ClientReference<T> = {
$$typeof: symbol,
filepath: string,
name: string,
async: boolean,
$$id: string,
$$async: boolean,
};
export type ClientReferenceMetadata = {
@ -53,12 +45,7 @@ const SERVER_REFERENCE_TAG = Symbol.for('react.server.reference');
export function getClientReferenceKey(
reference: ClientReference<any>,
): ClientReferenceKey {
return (
reference.filepath +
'#' +
reference.name +
(reference.async ? '#async' : '')
);
return reference.$$async ? reference.$$id + '#async' : reference.$$id;
}
export function isClientReference(reference: Object): boolean {
@ -73,9 +60,8 @@ export function resolveClientReferenceMetadata<T>(
config: BundlerConfig,
clientReference: ClientReference<T>,
): ClientReferenceMetadata {
const resolvedModuleData =
config[clientReference.filepath][clientReference.name];
if (clientReference.async) {
const resolvedModuleData = config[clientReference.$$id];
if (clientReference.$$async) {
return {
id: resolvedModuleData.id,
chunks: resolvedModuleData.chunks,
@ -90,10 +76,9 @@ export function resolveClientReferenceMetadata<T>(
export function resolveServerReferenceMetadata<T>(
config: BundlerConfig,
serverReference: ServerReference<T>,
): ServerReferenceMetadata {
): {id: ServerReferenceId, bound: Promise<Array<any>>} {
return {
id: serverReference.$$filepath,
name: serverReference.$$name,
id: serverReference.$$id,
bound: Promise.resolve(serverReference.$$bound),
};
}

View File

@ -187,8 +187,7 @@ function transformServerModule(
}
newSrc += 'Object.defineProperties(' + local + ',{';
newSrc += '$$typeof: {value: Symbol.for("react.server.reference")},';
newSrc += '$$filepath: {value: ' + JSON.stringify(url) + '},';
newSrc += '$$name: { value: ' + JSON.stringify(exported) + '},';
newSrc += '$$id: {value: ' + JSON.stringify(url + '#' + exported) + '},';
newSrc += '$$bound: { value: [] }';
newSrc += '});\n';
});
@ -343,9 +342,8 @@ async function transformClientModule(
');';
}
newSrc += '},{';
newSrc += 'name: { value: ' + JSON.stringify(name) + '},';
newSrc += '$$typeof: {value: CLIENT_REFERENCE},';
newSrc += 'filepath: {value: ' + JSON.stringify(url) + '}';
newSrc += '$$id: {value: ' + JSON.stringify(url + '#' + name) + '}';
newSrc += '});\n';
}
return newSrc;

View File

@ -29,8 +29,7 @@ module.exports = function register() {
// $FlowFixMe[method-unbinding]
const args = Array.prototype.slice.call(arguments, 1);
newFn.$$typeof = SERVER_REFERENCE;
newFn.$$filepath = this.$$filepath;
newFn.$$name = this.$$name;
newFn.$$id = this.$$id;
newFn.$$bound = this.$$bound.concat(args);
}
return newFn;
@ -44,14 +43,14 @@ module.exports = function register() {
// These names are a little too common. We should probably have a way to
// have the Flight runtime extract the inner target instead.
return target.$$typeof;
case 'filepath':
return target.filepath;
case '$$id':
return target.$$id;
case '$$async':
return target.$$async;
case 'name':
return target.name;
case 'displayName':
return undefined;
case 'async':
return target.async;
// We need to special case this because createElement reads it if we pass this
// reference.
case 'defaultProps':
@ -69,20 +68,8 @@ module.exports = function register() {
`that itself renders a Client Context Provider.`,
);
}
let expression;
switch (target.name) {
case '':
// eslint-disable-next-line react-internal/safe-string-coercion
expression = String(name);
break;
case '*':
// eslint-disable-next-line react-internal/safe-string-coercion
expression = String(name);
break;
default:
// eslint-disable-next-line react-internal/safe-string-coercion
expression = String(target.name) + '.' + String(name);
}
// eslint-disable-next-line react-internal/safe-string-coercion
const expression = String(target.name) + '.' + String(name);
throw new Error(
`Cannot access ${expression} on the server. ` +
'You cannot dot into a client module from a server component. ' +
@ -103,15 +90,13 @@ module.exports = function register() {
switch (name) {
// These names are read by the Flight runtime if you end up using the exports object.
case '$$typeof':
// These names are a little too common. We should probably have a way to
// have the Flight runtime extract the inner target instead.
return target.$$typeof;
case 'filepath':
return target.filepath;
case '$$id':
return target.$$id;
case '$$async':
return target.$$async;
case 'name':
return target.name;
case 'async':
return target.async;
// We need to special case this because createElement reads it if we pass this
// reference.
case 'defaultProps':
@ -125,7 +110,7 @@ module.exports = function register() {
case '__esModule':
// Something is conditionally checking which export to use. We'll pretend to be
// an ESM compat module but then we'll check again on the client.
const moduleId = target.filepath;
const moduleId = target.$$id;
target.default = Object.defineProperties(
(function () {
throw new Error(
@ -136,12 +121,11 @@ module.exports = function register() {
);
}: any),
{
$$typeof: {value: CLIENT_REFERENCE},
// This a placeholder value that tells the client to conditionally use the
// whole object or just the default export.
name: {value: ''},
$$typeof: {value: CLIENT_REFERENCE},
filepath: {value: target.filepath},
async: {value: target.async},
$$id: {value: target.$$id + '#'},
$$async: {value: target.$$async},
},
);
return true;
@ -150,17 +134,15 @@ module.exports = function register() {
// Use a cached value
return target.then;
}
if (!target.async) {
if (!target.$$async) {
// If this module is expected to return a Promise (such as an AsyncModule) then
// we should resolve that with a client reference that unwraps the Promise on
// the client.
const clientReference = Object.defineProperties(({}: any), {
// Represents the whole Module object instead of a particular import.
name: {value: '*'},
$$typeof: {value: CLIENT_REFERENCE},
filepath: {value: target.filepath},
async: {value: true},
$$id: {value: target.$$id},
$$async: {value: true},
});
const proxy = new Proxy(clientReference, proxyHandlers);
@ -176,10 +158,9 @@ module.exports = function register() {
// If this is not used as a Promise but is treated as a reference to a `.then`
// export then we should treat it as a reference to that name.
{
name: {value: 'then'},
$$typeof: {value: CLIENT_REFERENCE},
filepath: {value: target.filepath},
async: {value: false},
$$id: {value: target.$$id + '#then'},
$$async: {value: false},
},
));
return then;
@ -206,8 +187,8 @@ module.exports = function register() {
{
name: {value: name},
$$typeof: {value: CLIENT_REFERENCE},
filepath: {value: target.filepath},
async: {value: target.async},
$$id: {value: target.$$id + '#' + name},
$$async: {value: target.$$async},
},
);
cachedReference = target[name] = new Proxy(
@ -284,11 +265,10 @@ module.exports = function register() {
if (useClient) {
const moduleId: string = (url.pathToFileURL(filename).href: any);
const clientReference = Object.defineProperties(({}: any), {
// Represents the whole Module object instead of a particular import.
name: {value: '*'},
$$typeof: {value: CLIENT_REFERENCE},
filepath: {value: moduleId},
async: {value: false},
// Represents the whole Module object instead of a particular import.
$$id: {value: moduleId},
$$async: {value: false},
});
// $FlowFixMe[incompatible-call] found when upgrading Flow
this.exports = new Proxy(clientReference, proxyHandlers);
@ -306,10 +286,9 @@ module.exports = function register() {
if (typeof exports === 'function') {
// The module exports a function directly,
Object.defineProperties((exports: any), {
// Represents the whole Module object instead of a particular import.
$$typeof: {value: SERVER_REFERENCE},
$$filepath: {value: moduleId},
$$name: {value: '*'},
// Represents the whole Module object instead of a particular import.
$$id: {value: moduleId},
$$bound: {value: []},
});
} else {
@ -320,8 +299,7 @@ module.exports = function register() {
if (typeof value === 'function') {
Object.defineProperties((value: any), {
$$typeof: {value: SERVER_REFERENCE},
$$filepath: {value: moduleId},
$$name: {value: key},
$$id: {value: moduleId + '#' + key},
$$bound: {value: []},
});
}

View File

@ -220,9 +220,7 @@ export default class ReactFlightWebpackPlugin {
}
const clientManifest: {
[string]: {
[string]: {chunks: $FlowFixMe, id: string, name: string},
},
[string]: {chunks: $FlowFixMe, id: string, name: string},
} = {};
const ssrManifest: {
[string]: {
@ -248,25 +246,35 @@ export default class ReactFlightWebpackPlugin {
.getExportsInfo(module)
.getProvidedExports();
const clientExports: {
[string]: {chunks: $FlowFixMe, id: $FlowFixMe, name: string},
} = {};
const ssrExports: {
[string]: {specifier: string, name: string},
} = {};
const href = pathToFileURL(module.resource).href;
if (href !== undefined) {
['', '*']
.concat(
Array.isArray(moduleProvidedExports)
? moduleProvidedExports
: [],
)
.forEach(function (name) {
clientExports[name] = {
const ssrExports: {
[string]: {specifier: string, name: string},
} = {};
clientManifest[href] = {
id,
chunks: chunkIds,
name: '*',
};
ssrExports['*'] = {
specifier: href,
name: '*',
};
clientManifest[href + '#'] = {
id,
chunks: chunkIds,
name: '',
};
ssrExports[''] = {
specifier: href,
name: '',
};
if (Array.isArray(moduleProvidedExports)) {
moduleProvidedExports.forEach(function (name) {
clientManifest[href + '#' + name] = {
id,
chunks: chunkIds,
name: name,
@ -276,8 +284,8 @@ export default class ReactFlightWebpackPlugin {
name: name,
};
});
}
clientManifest[href] = clientExports;
ssrManifest[id] = ssrExports;
}
}

View File

@ -755,7 +755,7 @@ describe('ReactFlightDOMBrowser', () => {
});
function requireServerRef(ref) {
const metaData = webpackServerMap[ref.id][ref.name];
const metaData = webpackServerMap[ref];
const mod = __webpack_require__(metaData.id);
if (metaData.name === '*') {
return mod;

View File

@ -61,12 +61,12 @@ describe('ReactFlightDOMEdge', () => {
const ClientComponentOnTheServer = clientExports(ClientComponent);
// In the SSR bundle this module won't exist. We simulate this by deleting it.
const clientId = webpackMap[ClientComponentOnTheClient.filepath]['*'].id;
const clientId = webpackMap[ClientComponentOnTheClient.$$id].id;
delete webpackModules[clientId];
// Instead, we have to provide a translation from the client meta data to the SSR
// meta data.
const ssrMetadata = webpackMap[ClientComponentOnTheServer.filepath]['*'];
const ssrMetadata = webpackMap[ClientComponentOnTheServer.$$id];
const translationMap = {
[clientId]: {
'*': ssrMetadata,

View File

@ -67,12 +67,12 @@ describe('ReactFlightDOMNode', () => {
const ClientComponentOnTheServer = clientExports(ClientComponent);
// In the SSR bundle this module won't exist. We simulate this by deleting it.
const clientId = webpackMap[ClientComponentOnTheClient.filepath]['*'].id;
const clientId = webpackMap[ClientComponentOnTheClient.$$id].id;
delete webpackModules[clientId];
// Instead, we have to provide a translation from the client meta data to the SSR
// meta data.
const ssrMetadata = webpackMap[ClientComponentOnTheServer.filepath]['*'];
const ssrMetadata = webpackMap[ClientComponentOnTheServer.$$id];
const translationMap = {
[clientId]: {
'*': ssrMetadata,

View File

@ -48,16 +48,14 @@ exports.clientModuleError = function clientModuleError(moduleError) {
webpackErroredModules[idx] = moduleError;
const path = url.pathToFileURL(idx).href;
webpackClientMap[path] = {
'': {
id: idx,
chunks: [],
name: '',
},
'*': {
id: idx,
chunks: [],
name: '*',
},
id: idx,
chunks: [],
name: '*',
};
webpackClientMap[path + '#'] = {
id: idx,
chunks: [],
name: '',
};
const mod = {exports: {}};
nodeCompile.call(mod, '"use client"', idx);
@ -69,22 +67,20 @@ exports.clientExports = function clientExports(moduleExports) {
webpackClientModules[idx] = moduleExports;
const path = url.pathToFileURL(idx).href;
webpackClientMap[path] = {
'': {
id: idx,
chunks: [],
name: '',
},
'*': {
id: idx,
chunks: [],
name: '*',
},
id: idx,
chunks: [],
name: '*',
};
webpackClientMap[path + '#'] = {
id: idx,
chunks: [],
name: '',
};
if (typeof moduleExports.then === 'function') {
moduleExports.then(
asyncModuleExports => {
for (const name in asyncModuleExports) {
webpackClientMap[path][name] = {
webpackClientMap[path + '#' + name] = {
id: idx,
chunks: [],
name: name,
@ -95,7 +91,7 @@ exports.clientExports = function clientExports(moduleExports) {
);
}
for (const name in moduleExports) {
webpackClientMap[path][name] = {
webpackClientMap[path + '#' + name] = {
id: idx,
chunks: [],
name: name,
@ -112,19 +108,17 @@ exports.serverExports = function serverExports(moduleExports) {
webpackServerModules[idx] = moduleExports;
const path = url.pathToFileURL(idx).href;
webpackServerMap[path] = {
'': {
id: idx,
chunks: [],
name: '',
},
'*': {
id: idx,
chunks: [],
name: '*',
},
id: idx,
chunks: [],
name: '*',
};
webpackServerMap[path + '#'] = {
id: idx,
chunks: [],
name: '',
};
for (const name in moduleExports) {
webpackServerMap[path][name] = {
webpackServerMap[path + '#' + name] = {
id: idx,
chunks: [],
name: name,

View File

@ -16,7 +16,7 @@ import JSResourceReferenceImpl from 'JSResourceReferenceImpl';
export type ClientReference<T> = JSResourceReference<T>;
export type ServerReference<T> = T;
export type ServerReferenceMetadata = {};
export type ServerReferenceId = {};
import type {
Destination,
@ -66,7 +66,7 @@ export function resolveClientReferenceMetadata<T>(
export function resolveServerReferenceMetadata<T>(
config: BundlerConfig,
resource: ServerReference<T>,
): ServerReferenceMetadata {
): {id: ServerReferenceId, bound: Promise<Array<any>>} {
throw new Error('Not implemented.');
}

View File

@ -15,7 +15,7 @@ import type {
ClientReference,
ClientReferenceKey,
ServerReference,
ServerReferenceMetadata,
ServerReferenceId,
} from './ReactFlightServerConfig';
import type {ContextSnapshot} from './ReactFlightNewContext';
import type {ThenableState} from './ReactFlightThenable';
@ -590,8 +590,10 @@ function serializeServerReference(
if (existingId !== undefined) {
return serializeServerReferenceID(existingId);
}
const serverReferenceMetadata: ServerReferenceMetadata =
resolveServerReferenceMetadata(request.bundlerConfig, serverReference);
const serverReferenceMetadata: {
id: ServerReferenceId,
bound: Promise<Array<any>>,
} = resolveServerReferenceMetadata(request.bundlerConfig, serverReference);
request.pendingChunks++;
const metadataId = request.nextChunkId++;
// We assume that this object doesn't suspend.

View File

@ -13,7 +13,7 @@ export opaque type BundlerConfig = mixed;
export opaque type ClientReference<T> = mixed; // eslint-disable-line no-unused-vars
export opaque type ServerReference<T> = mixed; // eslint-disable-line no-unused-vars
export opaque type ClientReferenceMetadata: any = mixed;
export opaque type ServerReferenceMetadata: any = mixed;
export opaque type ServerReferenceId: any = mixed;
export opaque type ClientReferenceKey: any = mixed;
export const isClientReference = $$$hostConfig.isClientReference;
export const isServerReference = $$$hostConfig.isServerReference;