Support named exports from client references (#20312)

* Rename "name"->"filepath" field on Webpack module references

This field name will get confused with the imported name or the module id.

* Switch back to transformSource instead of getSource

getSource would be more efficient in the cases where we don't need to read
the original file but we'll need to most of the time.

Even then, we can't return a JS file if we're trying to support non-JS
loader because it'll end up being transformed.

Similarly, we'll need to parse the file and we can't parse it before it's
transformed. So we need to chain with other loaders that know how.

* Add acorn dependency

This should be the version used by Webpack since we have a dependency on
Webpack anyway.

* Parse exported names of ESM modules

We need to statically resolve the names that a client component will
export so that we can export a module reference for each of the names.

For export * from, this gets tricky because we need to also load the
source of the next file to parse that. We don't know exactly how the
client is built so we guess it's somewhat default.

* Handle imported names one level deep in CommonJS using a Proxy

We use a proxy to see what property the server access and that will tell
us which property we'll want to import on the client.

* Add export name to module reference and Webpack map

To support named exports each name needs to be encoded as a separate
reference. It's possible with module splitting that different exports end
up in different chunks.

It's also possible that the export is renamed as part of minification.
So the map also includes a map from the original to the bundled name.

* Special case plain CJS requires and conditional imports using __esModule

This models if the server tries to import .default or a plain require.
We should replicate the same thing on the client when we load that
module reference.

* Dedupe acorn-related deps

Co-authored-by: Mateusz Burzyński <mateuszburzynski@gmail.com>
This commit is contained in:
Sebastian Markbåge
2020-11-30 14:37:27 -08:00
committed by GitHub
co-authored by Mateusz Burzyński
parent 565148d751
commit 3f73dcee37
14 changed files with 323 additions and 62 deletions
+12 -2
View File
@@ -1,4 +1,8 @@
import {resolve, getSource} from 'react-transport-dom-webpack/node-loader';
import {
resolve,
getSource,
transformSource as reactTransformSource,
} from 'react-transport-dom-webpack/node-loader';
export {resolve, getSource};
@@ -13,7 +17,7 @@ const babelOptions = {
],
};
export async function transformSource(source, context, defaultTransformSource) {
async function babelTransformSource(source, context, defaultTransformSource) {
const {format} = context;
if (format === 'module') {
const opt = Object.assign({filename: context.url}, babelOptions);
@@ -22,3 +26,9 @@ export async function transformSource(source, context, defaultTransformSource) {
}
return defaultTransformSource(source, context, defaultTransformSource);
}
export async function transformSource(source, context, defaultTransformSource) {
return reactTransformSource(source, context, (s, c) => {
return babelTransformSource(s, c, defaultTransformSource);
});
}
+27 -6
View File
@@ -17,14 +17,35 @@ module.exports = async function(req, res) {
pipeToNodeWritable(<App />, res, {
// TODO: Read from a map on the disk.
[resolve('../src/Counter.client.js')]: {
id: './src/Counter.client.js',
chunks: ['1'],
name: 'default',
Counter: {
id: './src/Counter.client.js',
chunks: ['2'],
name: 'Counter',
},
},
[resolve('../src/Counter2.client.js')]: {
Counter: {
id: './src/Counter2.client.js',
chunks: ['1'],
name: 'Counter',
},
},
[resolve('../src/ShowMore.client.js')]: {
id: './src/ShowMore.client.js',
chunks: ['2'],
name: 'default',
default: {
id: './src/ShowMore.client.js',
chunks: ['3'],
name: 'default',
},
'': {
id: './src/ShowMore.client.js',
chunks: ['3'],
name: '',
},
'*': {
id: './src/ShowMore.client.js',
chunks: ['3'],
name: '*',
},
},
});
};
+3 -1
View File
@@ -2,7 +2,8 @@ import * as React from 'react';
import Container from './Container.js';
import Counter from './Counter.client.js';
import {Counter} from './Counter.client.js';
import {Counter as Counter2} from './Counter2.client.js';
import ShowMore from './ShowMore.client.js';
@@ -11,6 +12,7 @@ export default function App() {
<Container>
<h1>Hello, world</h1>
<Counter />
<Counter2 />
<ShowMore>
<p>Lorem ipsum</p>
</ShowMore>
+1 -1
View File
@@ -2,7 +2,7 @@ import * as React from 'react';
import Container from './Container.js';
export default function Counter() {
export function Counter() {
const [count, setCount] = React.useState(0);
return (
<Container>
+1
View File
@@ -0,0 +1 @@
export * from './Counter.client.js';
@@ -50,6 +50,7 @@
"webpack": "^4.43.0"
},
"dependencies": {
"acorn": "^6.2.1",
"loose-envify": "^1.1.0",
"object-assign": "^4.1.1"
},
@@ -59,5 +59,16 @@ export function requireModule<T>(moduleData: ModuleReference<T>): T {
throw entry;
}
}
return __webpack_require__(moduleData.id)[moduleData.name];
const moduleExports = __webpack_require__(moduleData.id);
if (moduleData.name === '*') {
// This is a placeholder value that represents that the caller imported this
// as a CommonJS module as is.
return moduleExports;
}
if (moduleData.name === '') {
// This is a placeholder value that represents that the caller accessed the
// default property of this if it was an ESM interop module.
return moduleExports.__esModule ? moduleExports.default : moduleExports;
}
return moduleExports[moduleData.name];
}
@@ -8,7 +8,9 @@
*/
type WebpackMap = {
[filename: string]: ModuleMetaData,
[filepath: string]: {
[name: string]: ModuleMetaData,
},
};
export type BundlerConfig = WebpackMap;
@@ -16,6 +18,7 @@ export type BundlerConfig = WebpackMap;
// eslint-disable-next-line no-unused-vars
export type ModuleReference<T> = {
$$typeof: Symbol,
filepath: string,
name: string,
};
@@ -30,7 +33,7 @@ export type ModuleKey = string;
const MODULE_TAG = Symbol.for('react.module.reference');
export function getModuleKey(reference: ModuleReference<any>): ModuleKey {
return reference.name;
return reference.filepath + '#' + reference.name;
}
export function isModuleReference(reference: Object): boolean {
@@ -41,5 +44,5 @@ export function resolveModuleMetaData<T>(
config: BundlerConfig,
moduleReference: ModuleReference<T>,
): ModuleMetaData {
return config[moduleReference.name];
return config[moduleReference.filepath][moduleReference.name];
}
@@ -7,6 +7,8 @@
* @flow
*/
import acorn from 'acorn';
type ResolveContext = {
conditions: Array<string>,
parentURL: string | void,
@@ -16,11 +18,10 @@ type ResolveFunction = (
string,
ResolveContext,
ResolveFunction,
) => Promise<string>;
) => Promise<{url: string}>;
type GetSourceContext = {
format: string,
url: string,
};
type GetSourceFunction = (
@@ -29,15 +30,32 @@ type GetSourceFunction = (
GetSourceFunction,
) => Promise<{source: Source}>;
type TransformSourceContext = {
format: string,
url: string,
};
type TransformSourceFunction = (
Source,
TransformSourceContext,
TransformSourceFunction,
) => Promise<{source: Source}>;
type Source = string | ArrayBuffer | Uint8Array;
let warnedAboutConditionsFlag = false;
let stashedGetSource: null | GetSourceFunction = null;
let stashedResolve: null | ResolveFunction = null;
export async function resolve(
specifier: string,
context: ResolveContext,
defaultResolve: ResolveFunction,
): Promise<string> {
): Promise<{url: string}> {
// We stash this in case we end up needing to resolve export * statements later.
stashedResolve = defaultResolve;
if (!context.conditions.includes('react-server')) {
context = {
...context,
@@ -71,14 +89,174 @@ export async function getSource(
url: string,
context: GetSourceContext,
defaultGetSource: GetSourceFunction,
): Promise<{source: Source}> {
if (url.endsWith('.client.js')) {
// TODO: Named exports.
const src =
"export default { $$typeof: Symbol.for('react.module.reference'), name: " +
JSON.stringify(url) +
'}';
return {source: src};
}
) {
// We stash this in case we end up needing to resolve export * statements later.
stashedGetSource = defaultGetSource;
return defaultGetSource(url, context, defaultGetSource);
}
function addExportNames(names, node) {
switch (node.type) {
case 'Identifier':
names.push(node.name);
return;
case 'ObjectPattern':
for (let i = 0; i < node.properties.length; i++)
addExportNames(names, node.properties[i]);
return;
case 'ArrayPattern':
for (let i = 0; i < node.elements.length; i++) {
const element = node.elements[i];
if (element) addExportNames(names, element);
}
return;
case 'Property':
addExportNames(names, node.value);
return;
case 'AssignmentPattern':
addExportNames(names, node.left);
return;
case 'RestElement':
addExportNames(names, node.argument);
return;
case 'ParenthesizedExpression':
addExportNames(names, node.expression);
return;
}
}
function resolveClientImport(
specifier: string,
parentURL: string,
): Promise<{url: string}> {
// Resolve an import specifier as if it was loaded by the client. This doesn't use
// the overrides that this loader does but instead reverts to the default.
// This resolution algorithm will not necessarily have the same configuration
// as the actual client loader. It should mostly work and if it doesn't you can
// always convert to explicit exported names instead.
const conditions = ['node', 'import'];
if (stashedResolve === null) {
throw new Error(
'Expected resolve to have been called before transformSource',
);
}
return stashedResolve(specifier, {conditions, parentURL}, stashedResolve);
}
async function loadClientImport(
url: string,
defaultTransformSource: TransformSourceFunction,
): Promise<{source: Source}> {
if (stashedGetSource === null) {
throw new Error(
'Expected getSource to have been called before transformSource',
);
}
// TODO: Validate that this is another module by calling getFormat.
const {source} = await stashedGetSource(
url,
{format: 'module'},
stashedGetSource,
);
return defaultTransformSource(
source,
{format: 'module', url},
defaultTransformSource,
);
}
async function parseExportNamesInto(
transformedSource: string,
names: Array<string>,
parentURL: string,
defaultTransformSource,
): Promise<void> {
const {body} = acorn.parse(transformedSource, {
ecmaVersion: '2019',
sourceType: 'module',
});
for (let i = 0; i < body.length; i++) {
const node = body[i];
switch (node.type) {
case 'ExportAllDeclaration':
if (node.exported) {
addExportNames(names, node.exported);
continue;
} else {
const {url} = await resolveClientImport(node.source.value, parentURL);
const {source} = await loadClientImport(url, defaultTransformSource);
if (typeof source !== 'string') {
throw new Error('Expected the transformed source to be a string.');
}
parseExportNamesInto(source, names, url, defaultTransformSource);
continue;
}
case 'ExportDefaultDeclaration':
names.push('default');
continue;
case 'ExportNamedDeclaration':
if (node.declaration) {
if (node.declaration.type === 'VariableDeclaration') {
const declarations = node.declaration.declarations;
for (let j = 0; j < declarations.length; j++) {
addExportNames(names, declarations[j].id);
}
} else {
addExportNames(names, node.declaration.id);
}
}
if (node.specificers) {
const specificers = node.specificers;
for (let j = 0; j < specificers.length; j++) {
addExportNames(names, specificers[j].exported);
}
}
continue;
}
}
}
export async function transformSource(
source: Source,
context: TransformSourceContext,
defaultTransformSource: TransformSourceFunction,
): Promise<{source: Source}> {
const transformed = await defaultTransformSource(
source,
context,
defaultTransformSource,
);
if (context.format === 'module' && context.url.endsWith('.client.js')) {
const transformedSource = transformed.source;
if (typeof transformedSource !== 'string') {
throw new Error('Expected source to have been transformed to a string.');
}
const names = [];
await parseExportNamesInto(
transformedSource,
names,
context.url,
defaultTransformSource,
);
let newSrc =
"const MODULE_REFERENCE = Symbol.for('react.module.reference');\n";
for (let i = 0; i < names.length; i++) {
const name = names[i];
if (name === 'default') {
newSrc += 'export default ';
} else {
newSrc += 'export const ' + name + ' = ';
}
newSrc += '{ $$typeof: MODULE_REFERENCE, filepath: ';
newSrc += JSON.stringify(context.url);
newSrc += ', name: ';
newSrc += JSON.stringify(name);
newSrc += '};\n';
}
return {source: newSrc};
}
return transformed;
}
@@ -13,11 +13,58 @@ const url = require('url');
const Module = require('module');
module.exports = function register() {
const MODULE_REFERENCE = Symbol.for('react.module.reference');
const proxyHandlers = {
get: function(target, name, receiver) {
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 'name':
return target.name;
// We need to special case this because createElement reads it if we pass this
// reference.
case 'defaultProps':
return undefined;
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.
target.default = {
$$typeof: MODULE_REFERENCE,
filepath: target.filepath,
// This a placeholder value that tells the client to conditionally use the
// whole object or just the default export.
name: '',
};
return true;
}
let cachedReference = target[name];
if (!cachedReference) {
cachedReference = target[name] = {
$$typeof: MODULE_REFERENCE,
filepath: target.filepath,
name: name,
};
}
return cachedReference;
},
set: function() {
throw new Error('Cannot assign to a client module from a server module.');
},
};
(require: any).extensions['.client.js'] = function(module, path) {
module.exports = {
$$typeof: Symbol.for('react.module.reference'),
name: url.pathToFileURL(path).href,
const moduleId = url.pathToFileURL(path).href;
const moduleReference: {[string]: any} = {
$$typeof: MODULE_REFERENCE,
filepath: moduleId,
name: '*', // Represents the whole object instead of a particular import.
};
module.exports = new Proxy(moduleReference, proxyHandlers);
};
const originalResolveFilename = Module._resolveFilename;
@@ -68,12 +68,14 @@ describe('ReactFlightDOM', () => {
d: moduleExport,
};
webpackMap['path/' + idx] = {
id: '' + idx,
chunks: [],
name: 'd',
default: {
id: '' + idx,
chunks: [],
name: 'd',
},
};
const MODULE_TAG = Symbol.for('react.module.reference');
return {$$typeof: MODULE_TAG, name: 'path/' + idx};
return {$$typeof: MODULE_TAG, filepath: 'path/' + idx, name: 'default'};
}
async function waitForSuspense(fn) {
+1 -1
View File
@@ -68,4 +68,4 @@ declare module 'EventListener' {
}
declare function __webpack_chunk_load__(id: string): Promise<mixed>;
declare function __webpack_require__(id: string): {default: any};
declare function __webpack_require__(id: string): any;
+1 -1
View File
@@ -301,7 +301,7 @@ const bundles = [
moduleType: RENDERER_UTILS,
entry: 'react-transport-dom-webpack/node-loader',
global: 'ReactFlightWebpackNodeLoader',
externals: [],
externals: ['acorn'],
},
/******* React Transport DOM Webpack Node.js CommonJS Loader *******/
+12 -27
View File
@@ -2617,15 +2617,10 @@ acorn-globals@^6.0.0:
acorn "^7.1.1"
acorn-walk "^7.1.1"
acorn-jsx@^5.0.0:
version "5.1.0"
resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.1.0.tgz#294adb71b57398b0680015f0a38c563ee1db5384"
integrity sha512-tMUqwBWfLFbJbizRmEcWSLw6HnFzfdJs2sOJEOwwtVPMoH/0Ay+E703oZz78VSXZiiDcZrQ5XKjPIUQixhmgVw==
acorn-jsx@^5.2.0:
version "5.2.0"
resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.2.0.tgz#4c66069173d6fdd68ed85239fc256226182b2ebe"
integrity sha512-HiUX/+K2YpkpJ+SzBffkM/AQ2YE03S0U1kjTLVpoJdhZMOWy8qvXVN9JdLqv2QsaQ6MPYQIuNmwD8zOiYUofLQ==
acorn-jsx@^5.0.0, acorn-jsx@^5.2.0:
version "5.3.1"
resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.1.tgz#fc8661e11b7ac1539c47dbfea2e72b3af34d267b"
integrity sha512-K0Ptm/47OKfQRpNQ2J/oIN/3QYiK6FwW+eJbILhsdxh2WTLdl+30o8aGdTbm5JbffpFFAg/g+zi1E+jvJha5ng==
acorn-walk@^6.0.1:
version "6.2.0"
@@ -2637,25 +2632,15 @@ acorn-walk@^7.1.1:
resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-7.2.0.tgz#0de889a601203909b0fbe07b8938dc21d2e967bc"
integrity sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA==
acorn@^6.0.1, acorn@^6.0.7:
version "6.3.0"
resolved "https://registry.yarnpkg.com/acorn/-/acorn-6.3.0.tgz#0087509119ffa4fc0a0041d1e93a417e68cb856e"
integrity sha512-/czfa8BwS88b9gWQVhc8eknunSA2DoJpJyTQkhheIf5E48u1N0R4q/YxxsAeqRrmK9TQ/uYfgLDfZo91UlANIA==
acorn@^6.0.1, acorn@^6.0.7, acorn@^6.2.1, acorn@^6.4.1:
version "6.4.2"
resolved "https://registry.yarnpkg.com/acorn/-/acorn-6.4.2.tgz#35866fd710528e92de10cf06016498e47e39e1e6"
integrity sha512-XtGIhXwF8YM8bJhGxG5kXgjkEuNGLTkoYqVE+KMR+aspr4KGYmKYg7yUe3KghyQ9yheNwLnjmzh/7+gfDBmHCQ==
acorn@^6.4.1:
version "6.4.1"
resolved "https://registry.yarnpkg.com/acorn/-/acorn-6.4.1.tgz#531e58ba3f51b9dacb9a6646ca4debf5b14ca474"
integrity sha512-ZVA9k326Nwrj3Cj9jlh3wGFutC2ZornPNARZwsNYqQYgN0EsV2d53w5RN/co65Ohn4sUAUtb1rSUAOD6XN9idA==
acorn@^7.1.0:
version "7.1.0"
resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.1.0.tgz#949d36f2c292535da602283586c2477c57eb2d6c"
integrity sha512-kL5CuoXA/dgxlBbVrflsflzQ3PAas7RYZB52NOm/6839iVYJgKMJ3cQJD+t2i5+qFa8h3MDpEOJiS64E8JLnSQ==
acorn@^7.1.1, acorn@^7.4.0:
version "7.4.0"
resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.4.0.tgz#e1ad486e6c54501634c6c397c5c121daa383607c"
integrity sha512-+G7P8jJmCHr+S+cLfQxygbWhXy+8YTVGzAkpEbcLo2mLoL7tij/VG41QSHACSf5QgYRhMZYHuNc6drJaO0Da+w==
acorn@^7.1.0, acorn@^7.1.1, acorn@^7.4.0:
version "7.4.1"
resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.4.1.tgz#feaed255973d2e77555b83dbc08851a6c63520fa"
integrity sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==
adbkit-logcat@^1.1.0:
version "1.1.0"