Skip to content

Commit

Permalink
Support named exports from client references (facebook#20312)
Browse files Browse the repository at this point in the history
* 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>
  • Loading branch information
2 people authored and koto committed Jun 15, 2021
1 parent 7b03647 commit e09c6b3
Show file tree
Hide file tree
Showing 14 changed files with 322 additions and 61 deletions.
14 changes: 12 additions & 2 deletions fixtures/flight/loader/index.js
Original file line number Diff line number Diff line change
@@ -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};

Expand All @@ -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);
Expand All @@ -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);
});
}
33 changes: 27 additions & 6 deletions fixtures/flight/server/handler.server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: '*',
},
},
});
};
4 changes: 3 additions & 1 deletion fixtures/flight/src/App.server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -11,6 +12,7 @@ export default function App() {
<Container>
<h1>Hello, world</h1>
<Counter />
<Counter2 />
<ShowMore>
<p>Lorem ipsum</p>
</ShowMore>
Expand Down
2 changes: 1 addition & 1 deletion fixtures/flight/src/Counter.client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
1 change: 1 addition & 0 deletions fixtures/flight/src/Counter2.client.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './Counter.client.js';
1 change: 1 addition & 0 deletions packages/react-transport-dom-webpack/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
"webpack": "^4.43.0"
},
"dependencies": {
"acorn": "^6.2.1",
"loose-envify": "^1.1.0",
"object-assign": "^4.1.1"
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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];
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,17 @@
*/

type WebpackMap = {
[filename: string]: ModuleMetaData,
[filepath: string]: {
[name: string]: ModuleMetaData,
},
};

export type BundlerConfig = WebpackMap;

// eslint-disable-next-line no-unused-vars
export type ModuleReference<T> = {
$$typeof: Symbol,
filepath: string,
name: string,
};

Expand All @@ -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 {
Expand All @@ -41,5 +44,5 @@ export function resolveModuleMetaData<T>(
config: BundlerConfig,
moduleReference: ModuleReference<T>,
): ModuleMetaData {
return config[moduleReference.name];
return config[moduleReference.filepath][moduleReference.name];
}
200 changes: 189 additions & 11 deletions packages/react-transport-dom-webpack/src/ReactFlightWebpackNodeLoader.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
* @flow
*/

import acorn from 'acorn';

type ResolveContext = {
conditions: Array<string>,
parentURL: string | void,
Expand All @@ -16,11 +18,10 @@ type ResolveFunction = (
string,
ResolveContext,
ResolveFunction,
) => Promise<string>;
) => Promise<{url: string}>;

type GetSourceContext = {
format: string,
url: string,
};

type GetSourceFunction = (
Expand All @@ -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,
Expand Down Expand Up @@ -71,14 +89,174 @@ export async function getSource(
url: string,
context: GetSourceContext,
defaultGetSource: GetSourceFunction,
) {
// 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 (url.endsWith('.client.js')) {
// TODO: Named exports.
const src =
"export default { $$typeof: Symbol.for('react.module.reference'), name: " +
JSON.stringify(url) +
'}';
return {source: src};
if (stashedGetSource === null) {
throw new Error(
'Expected getSource to have been called before transformSource',
);
}
return defaultGetSource(url, context, defaultGetSource);
// 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;
}
Loading

0 comments on commit e09c6b3

Please sign in to comment.