[Flight] Basic scan of the file system to find Client modules (#20383)
* Basic scan of the file system to find Client modules This does a rudimentary merge of the plugins. It still uses the global scan and writes to file system. Now the plugin accepts a search path or a list of referenced client files. In prod, the best practice is to provide a list of files that are actually referenced rather than including everything possibly reachable. Probably in dev too since it's faster. This is using the same convention as the upstream ContextModule - which powers the require.context helpers. * Add neo-async to dependencies
Sebastian Markbåge committed
Dec 7, 2020 at 12:10 UTC
30dfb860259dea126b3f43ab270ec51593824dd8
5 files changed
+241
-9
fixtures/flight/config/webpack.config.js
+8
-1
@@ -664,7 +664,14 @@ module.exports = function(webpackEnv) {
664
formatter: isEnvProduction ? typescriptFormatter : undefined,
665
}),
666
// Fork Start
667
- new ReactFlightWebpackPlugin({isServer: false}),
667
+ new ReactFlightWebpackPlugin({
668
+ isServer: false,
669
+ clientReferences: {
670
+ directory: './src/',
671
+ recursive: true,
672
+ include: /\.client\.js$/,
673
+ },
674
+ }),
675
// Fork End
676
].filter(Boolean),
677
// Some libraries import Node modules but don't use them in the browser.
fixtures/flight/src/index.js
-4
@@ -17,7 +17,3 @@ ReactDOM.render(
17
</Suspense>,
18
document.getElementById('root')
19
);
20
-
21
-// Create entry points for Client Components.
22
-// TODO: Webpack plugin should do this.
23
-require.context('./', true, /\.client\.js$/, 'lazy');
packages/react-transport-dom-webpack/package.json
+1
@@ -51,6 +51,7 @@
51
},
52
"dependencies": {
53
"acorn": "^6.2.1",
54
+ "neo-async": "^2.6.1",
55
"loose-envify": "^1.1.0",
56
"object-assign": "^4.1.1"
57
},
packages/react-transport-dom-webpack/src/ReactFlightWebpackPlugin.js
+231
-3
@@ -8,17 +8,169 @@
8
*/
9
10
import {mkdirSync, writeFileSync} from 'fs';
11
-import {dirname, resolve} from 'path';
11
+import {dirname, resolve, join} from 'path';
12
import {pathToFileURL} from 'url';
13
14
+import asyncLib from 'neo-async';
15
+
16
+import ModuleDependency from 'webpack/lib/dependencies/ModuleDependency';
17
+import NullDependency from 'webpack/lib/dependencies/NullDependency';
18
+import AsyncDependenciesBlock from 'webpack/lib/AsyncDependenciesBlock';
19
+import Template from 'webpack/lib/Template';
20
+
21
+class ClientReferenceDependency extends ModuleDependency {
22
+ constructor(request) {
23
+ super(request);
24
+ }
25
+
26
+ get type() {
27
+ return 'client-reference';
28
+ }
29
+}
30
+
31
+// This is the module that will be used to anchor all client references to.
32
+// I.e. it will have all the client files as async deps from this point on.
33
+// We use the Flight client implementation because you can't get to these
34
+// without the client runtime so it's the first time in the loading sequence
35
+// you might want them.
36
+const clientFileName = require.resolve('../');
37
+
38
+type ClientReferenceSearchPath = {
39
+ directory: string,
40
+ recursive?: boolean,
41
+ include: RegExp,
42
+ exclude?: RegExp,
43
+};
44
+
45
+type ClientReferencePath = string | ClientReferenceSearchPath;
46
+
47
+type Options = {
48
+ isServer: boolean,
49
+ clientReferences?: ClientReferencePath | $ReadOnlyArray<ClientReferencePath>,
50
+ chunkName?: string,
51
+};
52
+
53
+const PLUGIN_NAME = 'React Transport Plugin';
54
+
55
export default class ReactFlightWebpackPlugin {
15
- constructor(options: {isServer: boolean}) {}
56
+ clientReferences: $ReadOnlyArray<ClientReferencePath>;
57
+ chunkName: string;
58
+ constructor(options: Options) {
59
+ if (!options || typeof options.isServer !== 'boolean') {
60
+ throw new Error(
61
+ PLUGIN_NAME + ': You must specify the isServer option as a boolean.',
62
+ );
63
+ }
64
+ if (options.isServer) {
65
+ throw new Error('TODO: Implement the server compiler.');
66
+ }
67
+ if (!options.clientReferences) {
68
+ this.clientReferences = [
69
+ {
70
+ directory: '.',
71
+ recursive: true,
72
+ include: /\.client\.(js|ts|jsx|tsx)$/,
73
+ },
74
+ ];
75
+ } else if (
76
+ typeof options.clientReferences === 'string' ||
77
+ !Array.isArray(options.clientReferences)
78
+ ) {
79
+ this.clientReferences = [(options.clientReferences: $FlowFixMe)];
80
+ } else {
81
+ this.clientReferences = options.clientReferences;
82
+ }
83
+ if (typeof options.chunkName === 'string') {
84
+ this.chunkName = options.chunkName;
85
+ if (!/\[(index|request)\]/.test(this.chunkName)) {
86
+ this.chunkName += '[index]';
87
+ }
88
+ } else {
89
+ this.chunkName = 'client[index]';
90
+ }
91
+ }
92
93
apply(compiler: any) {
18
- compiler.hooks.emit.tap('React Transport Plugin', compilation => {
94
+ const run = (params, callback) => {
95
+ // First we need to find all client files on the file system. We do this early so
96
+ // that we have them synchronously available later when we need them. This might
97
+ // not be needed anymore since we no longer need to compile the module itself in
98
+ // a special way. So it's probably better to do this lazily and in parallel with
99
+ // other compilation.
100
+ const contextResolver = compiler.resolverFactory.get('context', {});
101
+ this.resolveAllClientFiles(
102
+ compiler.context,
103
+ contextResolver,
104
+ compiler.inputFileSystem,
105
+ compiler.createContextModuleFactory(),
106
+ (err, resolvedClientReferences) => {
107
+ if (err) {
108
+ callback(err);
109
+ return;
110
+ }
111
+ compiler.hooks.compilation.tap(
112
+ PLUGIN_NAME,
113
+ (compilation, {normalModuleFactory}) => {
114
+ compilation.dependencyFactories.set(
115
+ ClientReferenceDependency,
116
+ normalModuleFactory,
117
+ );
118
+ compilation.dependencyTemplates.set(
119
+ ClientReferenceDependency,
120
+ new NullDependency.Template(),
121
+ );
122
+
123
+ compilation.hooks.buildModule.tap(PLUGIN_NAME, module => {
124
+ // We need to add all client references as dependency of something in the graph so
125
+ // Webpack knows which entries need to know about the relevant chunks and include the
126
+ // map in their runtime. The things that actually resolves the dependency is the Flight
127
+ // client runtime. So we add them as a dependency of the Flight client runtime.
128
+ // Anything that imports the runtime will be made aware of these chunks.
129
+ // TODO: Warn if we don't find this file anywhere in the compilation.
130
+ if (module.resource !== clientFileName) {
131
+ return;
132
+ }
133
+ if (resolvedClientReferences) {
134
+ for (let i = 0; i < resolvedClientReferences.length; i++) {
135
+ const dep = resolvedClientReferences[i];
136
+ const chunkName = this.chunkName
137
+ .replace(/\[index\]/g, '' + i)
138
+ .replace(
139
+ /\[request\]/g,
140
+ Template.toPath(dep.userRequest),
141
+ );
142
+
143
+ const block = new AsyncDependenciesBlock(
144
+ {
145
+ name: chunkName,
146
+ },
147
+ module,
148
+ null,
149
+ dep.require,
150
+ );
151
+ block.addDependency(dep);
152
+ module.addBlock(block);
153
+ }
154
+ }
155
+ });
156
+ },
157
+ );
158
+
159
+ callback();
160
+ },
161
+ );
162
+ };
163
+
164
+ compiler.hooks.run.tapAsync(PLUGIN_NAME, run);
165
+ compiler.hooks.watchRun.tapAsync(PLUGIN_NAME, run);
166
+
167
+ compiler.hooks.emit.tap(PLUGIN_NAME, compilation => {
168
const json = {};
169
compilation.chunks.forEach(chunk => {
170
chunk.getModules().forEach(mod => {
171
+ // TOOD: Hook into deps instead of the target module.
172
+ // That way we know by the type of dep whether to include.
173
+ // It also resolves conflicts when the same module is in multiple chunks.
174
if (!/\.client\.js$/.test(mod.resource)) {
175
return;
176
}
@@ -42,7 +194,83 @@ export default class ReactFlightWebpackPlugin {
194
'react-transport-manifest.json',
195
);
196
mkdirSync(dirname(filename), {recursive: true});
197
+ // TODO: Use webpack's emit API and read from the devserver.
198
writeFileSync(filename, output);
199
});
200
}
201
+
202
+ // This attempts to replicate the dynamic file path resolution used for other wildcard
203
+ // resolution in Webpack is using.
204
+ resolveAllClientFiles(
205
+ context: string,
206
+ contextResolver: any,
207
+ fs: any,
208
+ contextModuleFactory: any,
209
+ callback: (
210
+ err: null | Error,
211
+ result?: $ReadOnlyArray<ClientReferenceDependency>,
212
+ ) => void,
213
+ ) {
214
+ asyncLib.map(
215
+ this.clientReferences,
216
+ (
217
+ clientReferencePath: string | ClientReferenceSearchPath,
218
+ cb: (
219
+ err: null | Error,
220
+ result?: $ReadOnlyArray<ClientReferenceDependency>,
221
+ ) => void,
222
+ ): void => {
223
+ if (typeof clientReferencePath === 'string') {
224
+ cb(null, [new ClientReferenceDependency(clientReferencePath)]);
225
+ return;
226
+ }
227
+ const clientReferenceSearch: ClientReferenceSearchPath = clientReferencePath;
228
+ contextResolver.resolve(
229
+ {},
230
+ context,
231
+ clientReferencePath.directory,
232
+ {},
233
+ (err, resolvedDirectory) => {
234
+ if (err) return cb(err);
235
+ const options = {
236
+ resource: resolvedDirectory,
237
+ resourceQuery: '',
238
+ recursive:
239
+ clientReferenceSearch.recursive === undefined
240
+ ? true
241
+ : clientReferenceSearch.recursive,
242
+ regExp: clientReferenceSearch.include,
243
+ include: undefined,
244
+ exclude: clientReferenceSearch.exclude,
245
+ };
246
+ contextModuleFactory.resolveDependencies(
247
+ fs,
248
+ options,
249
+ (err2: null | Error, deps: Array<ModuleDependency>) => {
250
+ if (err2) return cb(err2);
251
+ const clientRefDeps = deps.map(dep => {
252
+ const request = join(resolvedDirectory, dep.request);
253
+ const clientRefDep = new ClientReferenceDependency(request);
254
+ clientRefDep.userRequest = dep.userRequest;
255
+ return clientRefDep;
256
+ });
257
+ cb(null, clientRefDeps);
258
+ },
259
+ );
260
+ },
261
+ );
262
+ },
263
+ (
264
+ err: null | Error,
265
+ result: $ReadOnlyArray<$ReadOnlyArray<ClientReferenceDependency>>,
266
+ ): void => {
267
+ if (err) return callback(err);
268
+ const flat = [];
269
+ for (let i = 0; i < result.length; i++) {
270
+ flat.push.apply(flat, result[i]);
271
+ }
272
+ callback(null, flat);
273
+ },
274
+ );
275
+ }
276
}
scripts/rollup/bundles.js
+1
-1
@@ -301,7 +301,7 @@ const bundles = [
301
moduleType: RENDERER_UTILS,
302
entry: 'react-transport-dom-webpack/plugin',
303
global: 'ReactFlightWebpackPlugin',
304
- externals: ['fs', 'path', 'url'],
304
+ externals: ['fs', 'path', 'url', 'neo-async'],
305
},
306
307
/******* React Transport DOM Webpack Node.js Loader *******/