@samitouri / QOS-React-2 / commits / 1308e49a69

[Flight Plugin] Scan for "use client" (#26474)

## Summary Our toy webpack plugin for Server Components is pretty broken right now because, now that `.client.js` convention is gone, it ends up adding every single JS file it can find (including `node_modules`) as a potential async dependency. Instead, it should only look for files with the `'use client'` directive. The ideal way is to implement this by bundling the RSC graph first. Then, we would know which `'use client'` files were actually discovered — and so there would be no point to scanning the disk for them. That's how Next.js bundler does it. We're not doing that here. This toy plugin is very simple, and I'm not planning to do heavy lifting. I'm just bringing it up to date with the convention. The change is that we now read every file we discover (alas), bail if it has no `'use client'`, and parse it if it does (to verify it's actually used as a directive). I've changed to use `acorn-loose` because it's forgiving of JSX (and likely TypeScript/Flow). Otherwise, this wouldn't work on uncompiled source. ## Test plan Verified I can get our initial Server Components Demo running after this change. Previously, it would get stuck compiling and then emit thousands of errors. Also confirmed the fixture still works. (It doesn’t work correctly on the first load after dev server starts, but that’s already the case on main so seems unrelated.)

dan committed Mar 30, 2023 at 22:05 UTC 1308e49a6923d0dfd935dcd12cc420ec57239981
5 files changed +79 -9
packages/react-server-dom-webpack/package.json
+1 -1
@@ -84,7 +84,7 @@
84 "webpack": "^5.59.0"
85 },
86 "dependencies": {
87 - "acorn": "^6.2.1",
87 + "acorn-loose": "^8.3.0",
88 "neo-async": "^2.6.1",
89 "loose-envify": "^1.1.0"
90 },
packages/react-server-dom-webpack/src/ReactFlightWebpackNodeLoader.js
+1 -1
@@ -7,7 +7,7 @@
7 * @flow
8 */
9
10 -import * as acorn from 'acorn';
10 +import * as acorn from 'acorn-loose';
11
12 type ResolveContext = {
13 conditions: Array<string>,
packages/react-server-dom-webpack/src/ReactFlightWebpackNodeRegister.js
+1 -1
@@ -7,7 +7,7 @@
7 * @flow
8 */
9
10 -const acorn = require('acorn');
10 +const acorn = require('acorn-loose');
11
12 const url = require('url');
13
packages/react-server-dom-webpack/src/ReactFlightWebpackPlugin.js
+67 -4
@@ -9,8 +9,8 @@
9
10 import {join} from 'path';
11 import {pathToFileURL} from 'url';
12 -
12 import asyncLib from 'neo-async';
13 +import * as acorn from 'acorn-loose';
14
15 import ModuleDependency from 'webpack/lib/dependencies/ModuleDependency';
16 import NullDependency from 'webpack/lib/dependencies/NullDependency';
@@ -117,10 +117,12 @@ export default class ReactFlightWebpackPlugin {
117 PLUGIN_NAME,
118 ({contextModuleFactory}, callback) => {
119 const contextResolver = compiler.resolverFactory.get('context', {});
120 + const normalResolver = compiler.resolverFactory.get('normal');
121
122 _this.resolveAllClientFiles(
123 compiler.context,
124 contextResolver,
125 + normalResolver,
126 compiler.inputFileSystem,
127 contextModuleFactory,
128 function (err, resolvedClientRefs) {
@@ -219,6 +221,10 @@ export default class ReactFlightWebpackPlugin {
221 return;
222 }
223
224 + const resolvedClientFiles = new Set(
225 + (resolvedClientReferences || []).map(ref => ref.request),
226 + );
227 +
228 const clientManifest: {
229 [string]: {chunks: $FlowFixMe, id: string, name: string},
230 } = {};
@@ -237,8 +243,7 @@ export default class ReactFlightWebpackPlugin {
243 // TODO: Hook into deps instead of the target module.
244 // That way we know by the type of dep whether to include.
245 // It also resolves conflicts when the same module is in multiple chunks.
240 -
241 - if (!/\.(js|ts)x?$/.test(module.resource)) {
246 + if (!resolvedClientFiles.has(module.resource)) {
247 return;
248 }
249
@@ -328,6 +333,7 @@ export default class ReactFlightWebpackPlugin {
333 resolveAllClientFiles(
334 context: string,
335 contextResolver: any,
336 + normalResolver: any,
337 fs: any,
338 contextModuleFactory: any,
339 callback: (
@@ -335,6 +341,31 @@ export default class ReactFlightWebpackPlugin {
341 result?: $ReadOnlyArray<ClientReferenceDependency>,
342 ) => void,
343 ) {
344 + function hasUseClientDirective(source: string): boolean {
345 + if (source.indexOf('use client') === -1) {
346 + return false;
347 + }
348 + let body;
349 + try {
350 + body = acorn.parse(source, {
351 + ecmaVersion: '2024',
352 + sourceType: 'module',
353 + }).body;
354 + } catch (x) {
355 + return false;
356 + }
357 + for (let i = 0; i < body.length; i++) {
358 + const node = body[i];
359 + if (node.type !== 'ExpressionStatement' || !node.directive) {
360 + break;
361 + }
362 + if (node.directive === 'use client') {
363 + return true;
364 + }
365 + }
366 + return false;
367 + }
368 +
369 asyncLib.map(
370 this.clientReferences,
371 (
@@ -373,6 +404,7 @@ export default class ReactFlightWebpackPlugin {
404 options,
405 (err2: null | Error, deps: Array<any /*ModuleDependency*/>) => {
406 if (err2) return cb(err2);
407 +
408 const clientRefDeps = deps.map(dep => {
409 // use userRequest instead of request. request always end with undefined which is wrong
410 const request = join(resolvedDirectory, dep.userRequest);
@@ -380,7 +412,38 @@ export default class ReactFlightWebpackPlugin {
412 clientRefDep.userRequest = dep.userRequest;
413 return clientRefDep;
414 });
383 - cb(null, clientRefDeps);
415 +
416 + asyncLib.filter(
417 + clientRefDeps,
418 + (
419 + clientRefDep: ClientReferenceDependency,
420 + filterCb: (err: null | Error, truthValue: boolean) => void,
421 + ) => {
422 + normalResolver.resolve(
423 + {},
424 + context,
425 + clientRefDep.request,
426 + {},
427 + (err3: null | Error, resolvedPath: mixed) => {
428 + if (err3 || typeof resolvedPath !== 'string') {
429 + return filterCb(null, false);
430 + }
431 + fs.readFile(
432 + resolvedPath,
433 + 'utf-8',
434 + (err4: null | Error, content: string) => {
435 + if (err4 || typeof content !== 'string') {
436 + return filterCb(null, false);
437 + }
438 + const useClient = hasUseClientDirective(content);
439 + filterCb(null, useClient);
440 + },
441 + );
442 + },
443 + );
444 + },
445 + cb,
446 + );
447 },
448 );
449 },
yarn.lock
+9 -2
@@ -3434,12 +3434,19 @@ acorn-jsx@^5.3.1:
3434 resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937"
3435 integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==
3436
3437 +acorn-loose@^8.3.0:
3438 + version "8.3.0"
3439 + resolved "https://registry.yarnpkg.com/acorn-loose/-/acorn-loose-8.3.0.tgz#0cd62461d21dce4f069785f8d3de136d5525029a"
3440 + integrity sha512-75lAs9H19ldmW+fAbyqHdjgdCrz0pWGXKmnqFoh8PyVd1L2RIb4RzYrSjmopeqv3E1G3/Pimu6GgLlrGbrkF7w==
3441 + dependencies:
3442 + acorn "^8.5.0"
3443 +
3444 acorn-walk@^8.0.2:
3445 version "8.2.0"
3446 resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-8.2.0.tgz#741210f2e2426454508853a2f44d0ab83b7f69c1"
3447 integrity sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==
3448
3442 -acorn@^6.0.7, acorn@^6.2.1, acorn@^6.4.1:
3449 +acorn@^6.0.7, acorn@^6.4.1:
3450 version "6.4.2"
3451 resolved "https://registry.yarnpkg.com/acorn/-/acorn-6.4.2.tgz#35866fd710528e92de10cf06016498e47e39e1e6"
3452 integrity sha512-XtGIhXwF8YM8bJhGxG5kXgjkEuNGLTkoYqVE+KMR+aspr4KGYmKYg7yUe3KghyQ9yheNwLnjmzh/7+gfDBmHCQ==
@@ -3449,7 +3456,7 @@ acorn@^7.1.1, acorn@^7.4.0:
3456 resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.4.1.tgz#feaed255973d2e77555b83dbc08851a6c63520fa"
3457 integrity sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==
3458
3452 -acorn@^8.1.0, acorn@^8.8.1:
3459 +acorn@^8.1.0, acorn@^8.5.0, acorn@^8.8.1:
3460 version "8.8.2"
3461 resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.8.2.tgz#1b2f25db02af965399b9776b0c2c391276d37c4a"
3462 integrity sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw==