Skip to content

Commit 3f73dce

Browse files
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>
1 parent 565148d commit 3f73dce

14 files changed

+322
-61
lines changed

fixtures/flight/loader/index.js

+12-2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
import {resolve, getSource} from 'react-transport-dom-webpack/node-loader';
1+
import {
2+
resolve,
3+
getSource,
4+
transformSource as reactTransformSource,
5+
} from 'react-transport-dom-webpack/node-loader';
26

37
export {resolve, getSource};
48

@@ -13,7 +17,7 @@ const babelOptions = {
1317
],
1418
};
1519

16-
export async function transformSource(source, context, defaultTransformSource) {
20+
async function babelTransformSource(source, context, defaultTransformSource) {
1721
const {format} = context;
1822
if (format === 'module') {
1923
const opt = Object.assign({filename: context.url}, babelOptions);
@@ -22,3 +26,9 @@ export async function transformSource(source, context, defaultTransformSource) {
2226
}
2327
return defaultTransformSource(source, context, defaultTransformSource);
2428
}
29+
30+
export async function transformSource(source, context, defaultTransformSource) {
31+
return reactTransformSource(source, context, (s, c) => {
32+
return babelTransformSource(s, c, defaultTransformSource);
33+
});
34+
}

fixtures/flight/server/handler.server.js

+27-6
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,35 @@ module.exports = async function(req, res) {
1717
pipeToNodeWritable(<App />, res, {
1818
// TODO: Read from a map on the disk.
1919
[resolve('../src/Counter.client.js')]: {
20-
id: './src/Counter.client.js',
21-
chunks: ['1'],
22-
name: 'default',
20+
Counter: {
21+
id: './src/Counter.client.js',
22+
chunks: ['2'],
23+
name: 'Counter',
24+
},
25+
},
26+
[resolve('../src/Counter2.client.js')]: {
27+
Counter: {
28+
id: './src/Counter2.client.js',
29+
chunks: ['1'],
30+
name: 'Counter',
31+
},
2332
},
2433
[resolve('../src/ShowMore.client.js')]: {
25-
id: './src/ShowMore.client.js',
26-
chunks: ['2'],
27-
name: 'default',
34+
default: {
35+
id: './src/ShowMore.client.js',
36+
chunks: ['3'],
37+
name: 'default',
38+
},
39+
'': {
40+
id: './src/ShowMore.client.js',
41+
chunks: ['3'],
42+
name: '',
43+
},
44+
'*': {
45+
id: './src/ShowMore.client.js',
46+
chunks: ['3'],
47+
name: '*',
48+
},
2849
},
2950
});
3051
};

fixtures/flight/src/App.server.js

+3-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ import * as React from 'react';
22

33
import Container from './Container.js';
44

5-
import Counter from './Counter.client.js';
5+
import {Counter} from './Counter.client.js';
6+
import {Counter as Counter2} from './Counter2.client.js';
67

78
import ShowMore from './ShowMore.client.js';
89

@@ -11,6 +12,7 @@ export default function App() {
1112
<Container>
1213
<h1>Hello, world</h1>
1314
<Counter />
15+
<Counter2 />
1416
<ShowMore>
1517
<p>Lorem ipsum</p>
1618
</ShowMore>

fixtures/flight/src/Counter.client.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import * as React from 'react';
22

33
import Container from './Container.js';
44

5-
export default function Counter() {
5+
export function Counter() {
66
const [count, setCount] = React.useState(0);
77
return (
88
<Container>
+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './Counter.client.js';

packages/react-transport-dom-webpack/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
"webpack": "^4.43.0"
5151
},
5252
"dependencies": {
53+
"acorn": "^6.2.1",
5354
"loose-envify": "^1.1.0",
5455
"object-assign": "^4.1.1"
5556
},

packages/react-transport-dom-webpack/src/ReactFlightClientWebpackBundlerConfig.js

+12-1
Original file line numberDiff line numberDiff line change
@@ -59,5 +59,16 @@ export function requireModule<T>(moduleData: ModuleReference<T>): T {
5959
throw entry;
6060
}
6161
}
62-
return __webpack_require__(moduleData.id)[moduleData.name];
62+
const moduleExports = __webpack_require__(moduleData.id);
63+
if (moduleData.name === '*') {
64+
// This is a placeholder value that represents that the caller imported this
65+
// as a CommonJS module as is.
66+
return moduleExports;
67+
}
68+
if (moduleData.name === '') {
69+
// This is a placeholder value that represents that the caller accessed the
70+
// default property of this if it was an ESM interop module.
71+
return moduleExports.__esModule ? moduleExports.default : moduleExports;
72+
}
73+
return moduleExports[moduleData.name];
6374
}

packages/react-transport-dom-webpack/src/ReactFlightServerWebpackBundlerConfig.js

+6-3
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,17 @@
88
*/
99

1010
type WebpackMap = {
11-
[filename: string]: ModuleMetaData,
11+
[filepath: string]: {
12+
[name: string]: ModuleMetaData,
13+
},
1214
};
1315

1416
export type BundlerConfig = WebpackMap;
1517

1618
// eslint-disable-next-line no-unused-vars
1719
export type ModuleReference<T> = {
1820
$$typeof: Symbol,
21+
filepath: string,
1922
name: string,
2023
};
2124

@@ -30,7 +33,7 @@ export type ModuleKey = string;
3033
const MODULE_TAG = Symbol.for('react.module.reference');
3134

3235
export function getModuleKey(reference: ModuleReference<any>): ModuleKey {
33-
return reference.name;
36+
return reference.filepath + '#' + reference.name;
3437
}
3538

3639
export function isModuleReference(reference: Object): boolean {
@@ -41,5 +44,5 @@ export function resolveModuleMetaData<T>(
4144
config: BundlerConfig,
4245
moduleReference: ModuleReference<T>,
4346
): ModuleMetaData {
44-
return config[moduleReference.name];
47+
return config[moduleReference.filepath][moduleReference.name];
4548
}

packages/react-transport-dom-webpack/src/ReactFlightWebpackNodeLoader.js

+189-11
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
* @flow
88
*/
99

10+
import acorn from 'acorn';
11+
1012
type ResolveContext = {
1113
conditions: Array<string>,
1214
parentURL: string | void,
@@ -16,11 +18,10 @@ type ResolveFunction = (
1618
string,
1719
ResolveContext,
1820
ResolveFunction,
19-
) => Promise<string>;
21+
) => Promise<{url: string}>;
2022

2123
type GetSourceContext = {
2224
format: string,
23-
url: string,
2425
};
2526

2627
type GetSourceFunction = (
@@ -29,15 +30,32 @@ type GetSourceFunction = (
2930
GetSourceFunction,
3031
) => Promise<{source: Source}>;
3132

33+
type TransformSourceContext = {
34+
format: string,
35+
url: string,
36+
};
37+
38+
type TransformSourceFunction = (
39+
Source,
40+
TransformSourceContext,
41+
TransformSourceFunction,
42+
) => Promise<{source: Source}>;
43+
3244
type Source = string | ArrayBuffer | Uint8Array;
3345

3446
let warnedAboutConditionsFlag = false;
3547

48+
let stashedGetSource: null | GetSourceFunction = null;
49+
let stashedResolve: null | ResolveFunction = null;
50+
3651
export async function resolve(
3752
specifier: string,
3853
context: ResolveContext,
3954
defaultResolve: ResolveFunction,
40-
): Promise<string> {
55+
): Promise<{url: string}> {
56+
// We stash this in case we end up needing to resolve export * statements later.
57+
stashedResolve = defaultResolve;
58+
4159
if (!context.conditions.includes('react-server')) {
4260
context = {
4361
...context,
@@ -71,14 +89,174 @@ export async function getSource(
7189
url: string,
7290
context: GetSourceContext,
7391
defaultGetSource: GetSourceFunction,
92+
) {
93+
// We stash this in case we end up needing to resolve export * statements later.
94+
stashedGetSource = defaultGetSource;
95+
return defaultGetSource(url, context, defaultGetSource);
96+
}
97+
98+
function addExportNames(names, node) {
99+
switch (node.type) {
100+
case 'Identifier':
101+
names.push(node.name);
102+
return;
103+
case 'ObjectPattern':
104+
for (let i = 0; i < node.properties.length; i++)
105+
addExportNames(names, node.properties[i]);
106+
return;
107+
case 'ArrayPattern':
108+
for (let i = 0; i < node.elements.length; i++) {
109+
const element = node.elements[i];
110+
if (element) addExportNames(names, element);
111+
}
112+
return;
113+
case 'Property':
114+
addExportNames(names, node.value);
115+
return;
116+
case 'AssignmentPattern':
117+
addExportNames(names, node.left);
118+
return;
119+
case 'RestElement':
120+
addExportNames(names, node.argument);
121+
return;
122+
case 'ParenthesizedExpression':
123+
addExportNames(names, node.expression);
124+
return;
125+
}
126+
}
127+
128+
function resolveClientImport(
129+
specifier: string,
130+
parentURL: string,
131+
): Promise<{url: string}> {
132+
// Resolve an import specifier as if it was loaded by the client. This doesn't use
133+
// the overrides that this loader does but instead reverts to the default.
134+
// This resolution algorithm will not necessarily have the same configuration
135+
// as the actual client loader. It should mostly work and if it doesn't you can
136+
// always convert to explicit exported names instead.
137+
const conditions = ['node', 'import'];
138+
if (stashedResolve === null) {
139+
throw new Error(
140+
'Expected resolve to have been called before transformSource',
141+
);
142+
}
143+
return stashedResolve(specifier, {conditions, parentURL}, stashedResolve);
144+
}
145+
146+
async function loadClientImport(
147+
url: string,
148+
defaultTransformSource: TransformSourceFunction,
74149
): Promise<{source: Source}> {
75-
if (url.endsWith('.client.js')) {
76-
// TODO: Named exports.
77-
const src =
78-
"export default { $$typeof: Symbol.for('react.module.reference'), name: " +
79-
JSON.stringify(url) +
80-
'}';
81-
return {source: src};
150+
if (stashedGetSource === null) {
151+
throw new Error(
152+
'Expected getSource to have been called before transformSource',
153+
);
82154
}
83-
return defaultGetSource(url, context, defaultGetSource);
155+
// TODO: Validate that this is another module by calling getFormat.
156+
const {source} = await stashedGetSource(
157+
url,
158+
{format: 'module'},
159+
stashedGetSource,
160+
);
161+
return defaultTransformSource(
162+
source,
163+
{format: 'module', url},
164+
defaultTransformSource,
165+
);
166+
}
167+
168+
async function parseExportNamesInto(
169+
transformedSource: string,
170+
names: Array<string>,
171+
parentURL: string,
172+
defaultTransformSource,
173+
): Promise<void> {
174+
const {body} = acorn.parse(transformedSource, {
175+
ecmaVersion: '2019',
176+
sourceType: 'module',
177+
});
178+
for (let i = 0; i < body.length; i++) {
179+
const node = body[i];
180+
switch (node.type) {
181+
case 'ExportAllDeclaration':
182+
if (node.exported) {
183+
addExportNames(names, node.exported);
184+
continue;
185+
} else {
186+
const {url} = await resolveClientImport(node.source.value, parentURL);
187+
const {source} = await loadClientImport(url, defaultTransformSource);
188+
if (typeof source !== 'string') {
189+
throw new Error('Expected the transformed source to be a string.');
190+
}
191+
parseExportNamesInto(source, names, url, defaultTransformSource);
192+
continue;
193+
}
194+
case 'ExportDefaultDeclaration':
195+
names.push('default');
196+
continue;
197+
case 'ExportNamedDeclaration':
198+
if (node.declaration) {
199+
if (node.declaration.type === 'VariableDeclaration') {
200+
const declarations = node.declaration.declarations;
201+
for (let j = 0; j < declarations.length; j++) {
202+
addExportNames(names, declarations[j].id);
203+
}
204+
} else {
205+
addExportNames(names, node.declaration.id);
206+
}
207+
}
208+
if (node.specificers) {
209+
const specificers = node.specificers;
210+
for (let j = 0; j < specificers.length; j++) {
211+
addExportNames(names, specificers[j].exported);
212+
}
213+
}
214+
continue;
215+
}
216+
}
217+
}
218+
219+
export async function transformSource(
220+
source: Source,
221+
context: TransformSourceContext,
222+
defaultTransformSource: TransformSourceFunction,
223+
): Promise<{source: Source}> {
224+
const transformed = await defaultTransformSource(
225+
source,
226+
context,
227+
defaultTransformSource,
228+
);
229+
if (context.format === 'module' && context.url.endsWith('.client.js')) {
230+
const transformedSource = transformed.source;
231+
if (typeof transformedSource !== 'string') {
232+
throw new Error('Expected source to have been transformed to a string.');
233+
}
234+
235+
const names = [];
236+
await parseExportNamesInto(
237+
transformedSource,
238+
names,
239+
context.url,
240+
defaultTransformSource,
241+
);
242+
243+
let newSrc =
244+
"const MODULE_REFERENCE = Symbol.for('react.module.reference');\n";
245+
for (let i = 0; i < names.length; i++) {
246+
const name = names[i];
247+
if (name === 'default') {
248+
newSrc += 'export default ';
249+
} else {
250+
newSrc += 'export const ' + name + ' = ';
251+
}
252+
newSrc += '{ $$typeof: MODULE_REFERENCE, filepath: ';
253+
newSrc += JSON.stringify(context.url);
254+
newSrc += ', name: ';
255+
newSrc += JSON.stringify(name);
256+
newSrc += '};\n';
257+
}
258+
259+
return {source: newSrc};
260+
}
261+
return transformed;
84262
}

0 commit comments

Comments
 (0)