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

Lines changed: 322 additions & 61 deletions

‎fixtures/flight/loader/index.js‎

Lines changed: 12 additions & 2 deletions
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+
transformSourceasreactTransformSource,
5+
}from'react-transport-dom-webpack/node-loader';
26

37
export{resolve,getSource};
48

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

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

‎fixtures/flight/server/handler.server.js‎

Lines changed: 27 additions & 6 deletions
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‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ import * as React from 'react';
22

33
importContainerfrom'./Container.js';
44

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

78
importShowMorefrom'./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‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import * as React from 'react';
22

33
importContainerfrom'./Container.js';
44

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

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

Lines changed: 1 addition & 0 deletions
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‎

Lines changed: 12 additions & 1 deletion
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+
constmoduleExports=__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+
returnmoduleExports.__esModule ? moduleExports.default : moduleExports;
72+
}
73+
returnmoduleExports[moduleData.name];
6374
}

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

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,17 @@
88
*/
99

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

1416
exporttypeBundlerConfig=WebpackMap;
1517

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

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

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

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

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

Lines changed: 189 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
* @flow
88
*/
99

10+
importacornfrom'acorn';
11+
1012
typeResolveContext={
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
typeGetSourceContext={
2224
format: string,
23-
url: string,
2425
};
2526

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

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

3446
letwarnedAboutConditionsFlag=false;
3547

48+
let stashedGetSource: null|GetSourceFunction=null;
49+
let stashedResolve: null|ResolveFunction=null;
50+
3651
exportasyncfunctionresolve(
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+
returndefaultGetSource(url,context,defaultGetSource);
96+
}
97+
98+
functionaddExportNames(names,node){
99+
switch(node.type){
100+
case'Identifier':
101+
names.push(node.name);
102+
return;
103+
case'ObjectPattern':
104+
for(leti=0;i<node.properties.length;i++)
105+
addExportNames(names,node.properties[i]);
106+
return;
107+
case'ArrayPattern':
108+
for(leti=0;i<node.elements.length;i++){
109+
constelement=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+
functionresolveClientImport(
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+
thrownewError(
140+
'Expected resolve to have been called before transformSource',
141+
);
142+
}
143+
returnstashedResolve(specifier,{conditions, parentURL},stashedResolve);
144+
}
145+
146+
asyncfunctionloadClientImport(
147+
url: string,
148+
defaultTransformSource: TransformSourceFunction,
74149
): Promise<{source: Source}>{
75-
if(url.endsWith('.client.js')){
76-
// TODO: Named exports.
77-
constsrc=
78-
"export default { $$typeof: Symbol.for('react.module.reference'), name: "+
79-
JSON.stringify(url)+
80-
'}';
81-
return{source: src};
150+
if(stashedGetSource===null){
151+
thrownewError(
152+
'Expected getSource to have been called before transformSource',
153+
);
82154
}
83-
returndefaultGetSource(url,context,defaultGetSource);
155+
// TODO: Validate that this is another module by calling getFormat.
156+
const{source}=awaitstashedGetSource(
157+
url,
158+
{format: 'module'},
159+
stashedGetSource,
160+
);
161+
returndefaultTransformSource(
162+
source,
163+
{format: 'module', url},
164+
defaultTransformSource,
165+
);
166+
}
167+
168+
asyncfunctionparseExportNamesInto(
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(leti=0;i<body.length;i++){
179+
constnode=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}=awaitresolveClientImport(node.source.value,parentURL);
187+
const{source}=awaitloadClientImport(url,defaultTransformSource);
188+
if(typeofsource!=='string'){
189+
thrownewError('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+
constdeclarations=node.declaration.declarations;
201+
for(letj=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+
constspecificers=node.specificers;
210+
for(letj=0;j<specificers.length;j++){
211+
addExportNames(names,specificers[j].exported);
212+
}
213+
}
214+
continue;
215+
}
216+
}
217+
}
218+
219+
exportasyncfunctiontransformSource(
220+
source: Source,
221+
context: TransformSourceContext,
222+
defaultTransformSource: TransformSourceFunction,
223+
): Promise<{source: Source}>{
224+
consttransformed=awaitdefaultTransformSource(
225+
source,
226+
context,
227+
defaultTransformSource,
228+
);
229+
if(context.format==='module'&&context.url.endsWith('.client.js')){
230+
consttransformedSource=transformed.source;
231+
if(typeoftransformedSource!=='string'){
232+
thrownewError('Expected source to have been transformed to a string.');
233+
}
234+
235+
constnames=[];
236+
awaitparseExportNamesInto(
237+
transformedSource,
238+
names,
239+
context.url,
240+
defaultTransformSource,
241+
);
242+
243+
letnewSrc=
244+
"const MODULE_REFERENCE = Symbol.for('react.module.reference');\n";
245+
for(leti=0;i<names.length;i++){
246+
constname=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+
returntransformed;
84262
}

0 commit comments

Comments
 (0)