@samitouri / QOS-React-2 / commits / f0cf832e1d

Update Flight Fixture to "use client" instead of .client.js (#26118)

This updates the Flight fixture to support the new ESM loaders in newer versions of Node.js. It also uses native fetch since react-fetch is gone now. (This part requires Node 18 to run the fixture.) I also updated everything to use the `"use client"` convention instead of file name based convention. The biggest hack here is that the Webpack plugin now just writes every `.js` file in the manifest. This needs to be more scoped. In practice, this new convention effectively requires you to traverse the server graph first to find the actual used files. This is enough to at least run our own fixture though. I didn't update the "blocks" fixture. More details in each commit message.

Sebastian Markbåge committed Feb 7, 2023 at 12:09 UTC f0cf832e1d0c8544c36aa8b310960885a11a847c
16 files changed +247 -171
fixtures/flight-browser/index.html
+4 -4
@@ -20,7 +20,7 @@
20 <script src="../../build/node_modules/react-dom/umd/react-dom.development.js"></script>
21 <script src="../../build/node_modules/react-dom/umd/react-dom-server.browser.development.js"></script>
22 <script src="../../build/node_modules/react-server-dom-webpack/umd/react-server-dom-webpack-server.browser.development.js"></script>
23 - <script src="../../build/node_modules/react-server-dom-webpack/umd/react-server-dom-webpack.development.js"></script>
23 + <script src="../../build/node_modules/react-server-dom-webpack/umd/react-server-dom-webpack-client.development.js"></script>
24 <script src="https://unpkg.com/babel-standalone@6/babel.js"></script>
25 <script type="text/babel">
26 let Suspense = React.Suspense;
@@ -60,7 +60,7 @@
60 content: <HTML />,
61 };
62
63 - let stream = ReactServerDOMWriter.renderToReadableStream(model);
63 + let stream = ReactServerDOMServer.renderToReadableStream(model);
64 let response = new Response(stream, {
65 headers: {'Content-Type': 'text/html'},
66 });
@@ -70,13 +70,13 @@
70 let blob = await responseToDisplay.blob();
71 let url = URL.createObjectURL(blob);
72
73 - let data = ReactServerDOMReader.createFromFetch(
73 + let data = ReactServerDOMClient.createFromFetch(
74 fetch(url)
75 );
76 // The client also supports XHR streaming.
77 // var xhr = new XMLHttpRequest();
78 // xhr.open('GET', url);
79 - // let data = ReactServerDOMReader.createFromXHR(xhr);
79 + // let data = ReactServerDOMClient.createFromXHR(xhr);
80 // xhr.send();
81
82 renderResult(data);
fixtures/flight/config/webpack.config.js
+8 -1
@@ -752,7 +752,14 @@ module.exports = function (webpackEnv) {
752 // },
753 // }),
754 // Fork Start
755 - new ReactFlightWebpackPlugin({isServer: false}),
755 + new ReactFlightWebpackPlugin({
756 + isServer: false,
757 + clientReferences: {
758 + directory: './src',
759 + recursive: true,
760 + include: /\.(js|ts|jsx|tsx)$/,
761 + },
762 + }),
763 // Fork End
764 ].filter(Boolean),
765 // Turn off performance processing because we utilize
fixtures/flight/loader/index.js
+26 -4
@@ -1,10 +1,11 @@
1 import {
2 resolve,
3 - getSource,
3 + load as reactLoad,
4 + getSource as getSourceImpl,
5 transformSource as reactTransformSource,
6 } from 'react-server-dom-webpack/node-loader';
7
7 -export {resolve, getSource};
8 +export {resolve};
9
10 import babel from '@babel/core';
11
@@ -17,6 +18,23 @@ const babelOptions = {
18 ],
19 };
20
21 +async function babelLoad(url, context, defaultLoad) {
22 + const {format} = context;
23 + const result = await defaultLoad(url, context, defaultLoad);
24 + if (result.format === 'module') {
25 + const opt = Object.assign({filename: url}, babelOptions);
26 + const {code} = await babel.transformAsync(result.source, opt);
27 + return {source: code, format: 'module'};
28 + }
29 + return defaultLoad(url, context, defaultLoad);
30 +}
31 +
32 +export async function load(url, context, defaultLoad) {
33 + return await reactLoad(url, context, (u, c) => {
34 + return babelLoad(u, c, defaultLoad);
35 + });
36 +}
37 +
38 async function babelTransformSource(source, context, defaultTransformSource) {
39 const {format} = context;
40 if (format === 'module') {
@@ -27,8 +45,12 @@ async function babelTransformSource(source, context, defaultTransformSource) {
45 return defaultTransformSource(source, context, defaultTransformSource);
46 }
47
30 -export async function transformSource(source, context, defaultTransformSource) {
31 - return reactTransformSource(source, context, (s, c) => {
48 +async function transformSourceImpl(source, context, defaultTransformSource) {
49 + return await reactTransformSource(source, context, (s, c) => {
50 return babelTransformSource(s, c, defaultTransformSource);
51 });
52 }
53 +
54 +export const transformSource =
55 + process.version < 'v16' ? transformSourceImpl : undefined;
56 +export const getSource = process.version < 'v16' ? getSourceImpl : undefined;
fixtures/flight/server/cli.js renamed
+1 -1
@@ -29,7 +29,7 @@ const app = express();
29
30 // Application
31 app.get('/', function (req, res) {
32 - require('./handler.server.js')(req, res);
32 + require('./handler.js')(req, res);
33 });
34
35 app.get('/todos', function (req, res) {
fixtures/flight/server/handler.js renamed
+2 -2
@@ -6,8 +6,8 @@ const {resolve} = require('path');
6 const React = require('react');
7
8 module.exports = function (req, res) {
9 - // const m = require('../src/App.server.js');
10 - import('../src/App.server.js').then(m => {
9 + // const m = require('../src/App.js');
10 + import('../src/App.js').then(m => {
11 const dist = process.env.NODE_ENV === 'development' ? 'dist' : 'build';
12 readFile(
13 resolve(__dirname, `../${dist}/react-client-manifest.json`),
fixtures/flight/server/package.json
+1 -1
@@ -1,4 +1,4 @@
1 {
2 "type": "commonjs",
3 - "main": "./cli.server.js"
3 + "main": "./cli.js"
4 }
fixtures/flight/src/App.js renamed
+6 -6
@@ -1,15 +1,15 @@
1 import * as React from 'react';
2 -import {fetch} from 'react-fetch';
2
3 import Container from './Container.js';
4
6 -import {Counter} from './Counter.client.js';
7 -import {Counter as Counter2} from './Counter2.client.js';
5 +import {Counter} from './Counter.js';
6 +import {Counter as Counter2} from './Counter2.js';
7
9 -import ShowMore from './ShowMore.client.js';
8 +import ShowMore from './ShowMore.js';
9
11 -export default function App() {
12 - const todos = fetch('http://localhost:3001/todos').json();
10 +export default async function App() {
11 + const res = await fetch('http://localhost:3001/todos');
12 + const todos = await res.json();
13 return (
14 <Container>
15 <h1>Hello, world</h1>
fixtures/flight/src/Counter.js renamed
+2
@@ -1,3 +1,5 @@
1 +'use client';
2 +
3 import * as React from 'react';
4
5 import Container from './Container.js';
fixtures/flight/src/Counter2.client.js deleted
-1
@@ -1 +0,0 @@
1 -export * from './Counter.client.js';
fixtures/flight/src/Counter2.js new
+3
@@ -0,0 +1,3 @@
1 +'use client';
2 +
3 +export * from './Counter.js';
fixtures/flight/src/ShowMore.js renamed
+2
@@ -1,3 +1,5 @@
1 +'use client';
2 +
3 import * as React from 'react';
4
5 import Container from './Container.js';
packages/react-server-dom-webpack/src/ReactFlightWebpackNodeLoader.js
+142 -91
@@ -41,6 +41,18 @@ type TransformSourceFunction = (
41 TransformSourceFunction,
42 ) => Promise<{source: Source}>;
43
44 +type LoadContext = {
45 + conditions: Array<string>,
46 + format: string | null | void,
47 + importAssertions: Object,
48 +};
49 +
50 +type LoadFunction = (
51 + string,
52 + LoadContext,
53 + LoadFunction,
54 +) => Promise<{format: string, shortCircuit?: boolean, source: Source}>;
55 +
56 type Source = string | ArrayBuffer | Uint8Array;
57
58 let warnedAboutConditionsFlag = false;
@@ -70,24 +82,7 @@ export async function resolve(
82 );
83 }
84 }
73 - const resolved = await defaultResolve(specifier, context, defaultResolve);
74 - if (resolved.url.endsWith('.server.js')) {
75 - const parentURL = context.parentURL;
76 - if (parentURL && !parentURL.endsWith('.server.js')) {
77 - let reason;
78 - if (specifier.endsWith('.server.js')) {
79 - reason = `"${specifier}"`;
80 - } else {
81 - reason = `"${specifier}" (which expands to "${resolved.url}")`;
82 - }
83 - throw new Error(
84 - `Cannot import ${reason} from "${parentURL}". ` +
85 - 'By react-server convention, .server.js files can only be imported from other .server.js files. ' +
86 - 'That way nobody accidentally sends these to the client by indirectly importing it.',
87 - );
88 - }
89 - }
90 - return resolved;
85 + return await defaultResolve(specifier, context, defaultResolve);
86 }
87
88 export async function getSource(
@@ -148,38 +143,12 @@ function resolveClientImport(
143 return stashedResolve(specifier, {conditions, parentURL}, stashedResolve);
144 }
145
151 -async function loadClientImport(
152 - url: string,
153 - defaultTransformSource: TransformSourceFunction,
154 -): Promise<{source: Source}> {
155 - if (stashedGetSource === null) {
156 - throw new Error(
157 - 'Expected getSource to have been called before transformSource',
158 - );
159 - }
160 - // TODO: Validate that this is another module by calling getFormat.
161 - const {source} = await stashedGetSource(
162 - url,
163 - {format: 'module'},
164 - stashedGetSource,
165 - );
166 - return defaultTransformSource(
167 - source,
168 - {format: 'module', url},
169 - defaultTransformSource,
170 - );
171 -}
172 -
146 async function parseExportNamesInto(
174 - transformedSource: string,
147 + body: any,
148 names: Array<string>,
149 parentURL: string,
177 - defaultTransformSource: TransformSourceFunction,
150 + loader: LoadFunction,
151 ): Promise<void> {
179 - const {body} = acorn.parse(transformedSource, {
180 - ecmaVersion: '2019',
181 - sourceType: 'module',
182 - });
152 for (let i = 0; i < body.length; i++) {
153 const node = body[i];
154 switch (node.type) {
@@ -189,11 +158,19 @@ async function parseExportNamesInto(
158 continue;
159 } else {
160 const {url} = await resolveClientImport(node.source.value, parentURL);
192 - const {source} = await loadClientImport(url, defaultTransformSource);
161 + const {source} = await loader(
162 + url,
163 + {format: 'module', conditions: [], importAssertions: {}},
164 + loader,
165 + );
166 if (typeof source !== 'string') {
167 throw new Error('Expected the transformed source to be a string.');
168 }
196 - parseExportNamesInto(source, names, url, defaultTransformSource);
169 + const {body: childBody} = acorn.parse(source, {
170 + ecmaVersion: '2019',
171 + sourceType: 'module',
172 + });
173 + await parseExportNamesInto(childBody, names, url, loader);
174 continue;
175 }
176 case 'ExportDefaultDeclaration':
@@ -221,6 +198,102 @@ async function parseExportNamesInto(
198 }
199 }
200
201 +async function transformClientModule(
202 + source: string,
203 + url: string,
204 + loader: LoadFunction,
205 +): Promise<string> {
206 + const names: Array<string> = [];
207 +
208 + // Do a quick check for the exact string. If it doesn't exist, don't
209 + // bother parsing.
210 + if (source.indexOf('use client') === -1) {
211 + return source;
212 + }
213 +
214 + const {body} = acorn.parse(source, {
215 + ecmaVersion: '2019',
216 + sourceType: 'module',
217 + });
218 +
219 + let useClient = false;
220 + for (let i = 0; i < body.length; i++) {
221 + const node = body[i];
222 + if (node.type !== 'ExpressionStatement' || !node.directive) {
223 + break;
224 + }
225 + if (node.directive === 'use client') {
226 + useClient = true;
227 + break;
228 + }
229 + }
230 +
231 + if (!useClient) {
232 + return source;
233 + }
234 +
235 + await parseExportNamesInto(body, names, url, loader);
236 +
237 + let newSrc =
238 + "const CLIENT_REFERENCE = Symbol.for('react.client.reference');\n";
239 + for (let i = 0; i < names.length; i++) {
240 + const name = names[i];
241 + if (name === 'default') {
242 + newSrc += 'export default ';
243 + newSrc += 'Object.defineProperties(function() {';
244 + newSrc +=
245 + 'throw new Error(' +
246 + JSON.stringify(
247 + `Attempted to call the default export of ${url} from the server` +
248 + `but it's on the client. It's not possible to invoke a client function from ` +
249 + `the server, it can only be rendered as a Component or passed to props of a` +
250 + `Client Component.`,
251 + ) +
252 + ');';
253 + } else {
254 + newSrc += 'export const ' + name + ' = ';
255 + newSrc += 'Object.defineProperties(function() {';
256 + newSrc +=
257 + 'throw new Error(' +
258 + JSON.stringify(
259 + `Attempted to call ${name}() from the server but ${name} is on the client. ` +
260 + `It's not possible to invoke a client function from the server, it can ` +
261 + `only be rendered as a Component or passed to props of a Client Component.`,
262 + ) +
263 + ');';
264 + }
265 + newSrc += '},{';
266 + newSrc += 'name: { value: ' + JSON.stringify(name) + '},';
267 + newSrc += '$$typeof: {value: CLIENT_REFERENCE},';
268 + newSrc += 'filepath: {value: ' + JSON.stringify(url) + '}';
269 + newSrc += '});\n';
270 + }
271 + return newSrc;
272 +}
273 +
274 +async function loadClientImport(
275 + url: string,
276 + defaultTransformSource: TransformSourceFunction,
277 +): Promise<{format: string, shortCircuit?: boolean, source: Source}> {
278 + if (stashedGetSource === null) {
279 + throw new Error(
280 + 'Expected getSource to have been called before transformSource',
281 + );
282 + }
283 + // TODO: Validate that this is another module by calling getFormat.
284 + const {source} = await stashedGetSource(
285 + url,
286 + {format: 'module'},
287 + stashedGetSource,
288 + );
289 + const result = await defaultTransformSource(
290 + source,
291 + {format: 'module', url},
292 + defaultTransformSource,
293 + );
294 + return {format: 'module', source: result.source};
295 +}
296 +
297 export async function transformSource(
298 source: Source,
299 context: TransformSourceContext,
@@ -231,57 +304,35 @@ export async function transformSource(
304 context,
305 defaultTransformSource,
306 );
234 - if (context.format === 'module' && context.url.endsWith('.client.js')) {
307 + if (context.format === 'module') {
308 const transformedSource = transformed.source;
309 if (typeof transformedSource !== 'string') {
310 throw new Error('Expected source to have been transformed to a string.');
311 }
239 -
240 - const names: Array<string> = [];
241 - await parseExportNamesInto(
312 + const newSrc = await transformClientModule(
313 transformedSource,
243 - names,
314 context.url,
245 - defaultTransformSource,
315 + (url: string, ctx: LoadContext, defaultLoad: LoadFunction) => {
316 + return loadClientImport(url, defaultTransformSource);
317 + },
318 );
247 -
248 - let newSrc =
249 - "const CLIENT_REFERENCE = Symbol.for('react.client.reference');\n";
250 - for (let i = 0; i < names.length; i++) {
251 - const name = names[i];
252 - if (name === 'default') {
253 - newSrc += 'export default ';
254 - newSrc += 'Object.defineProperties(function() {';
255 - newSrc +=
256 - 'throw new Error(' +
257 - JSON.stringify(
258 - `Attempted to call the default export of ${context.url} from the server` +
259 - `but it's on the client. It's not possible to invoke a client function from ` +
260 - `the server, it can only be rendered as a Component or passed to props of a` +
261 - `Client Component.`,
262 - ) +
263 - ');';
264 - } else {
265 - newSrc += 'export const ' + name + ' = ';
266 - newSrc += 'export default ';
267 - newSrc += 'Object.defineProperties(function() {';
268 - newSrc +=
269 - 'throw new Error(' +
270 - JSON.stringify(
271 - `Attempted to call ${name}() from the server but ${name} is on the client. ` +
272 - `It's not possible to invoke a client function from the server, it can ` +
273 - `only be rendered as a Component or passed to props of a Client Component.`,
274 - ) +
275 - ');';
276 - }
277 - newSrc += '},{';
278 - newSrc += 'name: { value: ' + JSON.stringify(name) + '},';
279 - newSrc += '$$typeof: {value: CLIENT_REFERENCE},';
280 - newSrc += 'filepath: {value: ' + JSON.stringify(context.url) + '}';
281 - newSrc += '});\n';
282 - }
283 -
319 return {source: newSrc};
320 }
321 return transformed;
322 }
323 +
324 +export async function load(
325 + url: string,
326 + context: LoadContext,
327 + defaultLoad: LoadFunction,
328 +): Promise<{format: string, shortCircuit?: boolean, source: Source}> {
329 + if (context.format === 'module') {
330 + const result = await defaultLoad(url, context, defaultLoad);
331 + if (typeof result.source !== 'string') {
332 + throw new Error('Expected source to have been loaded into a string.');
333 + }
334 + const newSrc = await transformClientModule(result.source, url, defaultLoad);
335 + return {format: 'module', source: newSrc};
336 + }
337 + return defaultLoad(url, context, defaultLoad);
338 +}
packages/react-server-dom-webpack/src/ReactFlightWebpackNodeRegister.js
+39 -32
@@ -7,6 +7,8 @@
7 * @flow
8 */
9
10 +const acorn = require('acorn');
11 +
12 const url = require('url');
13
14 const Module = require('module');
@@ -204,8 +206,42 @@ module.exports = function register() {
206 };
207
208 // $FlowFixMe[prop-missing] found when upgrading Flow
207 - Module._extensions['.client.js'] = function (module, path) {
208 - const moduleId: string = (url.pathToFileURL(path).href: any);
209 + const originalCompile = Module.prototype._compile;
210 +
211 + // $FlowFixMe[prop-missing] found when upgrading Flow
212 + Module.prototype._compile = function (
213 + this: any,
214 + content: string,
215 + filename: string,
216 + ): void {
217 + // Do a quick check for the exact string. If it doesn't exist, don't
218 + // bother parsing.
219 + if (content.indexOf('use client') === -1) {
220 + return originalCompile.apply(this, arguments);
221 + }
222 +
223 + const {body} = acorn.parse(content, {
224 + ecmaVersion: '2019',
225 + sourceType: 'source',
226 + });
227 +
228 + let useClient = false;
229 + for (let i = 0; i < body.length; i++) {
230 + const node = body[i];
231 + if (node.type !== 'ExpressionStatement' || !node.directive) {
232 + break;
233 + }
234 + if (node.directive === 'use client') {
235 + useClient = true;
236 + break;
237 + }
238 + }
239 +
240 + if (!useClient) {
241 + return originalCompile.apply(this, arguments);
242 + }
243 +
244 + const moduleId: string = (url.pathToFileURL(filename).href: any);
245 const clientReference = Object.defineProperties(({}: any), {
246 // Represents the whole Module object instead of a particular import.
247 name: {value: '*'},
@@ -214,35 +250,6 @@ module.exports = function register() {
250 async: {value: false},
251 });
252 // $FlowFixMe[incompatible-call] found when upgrading Flow
217 - module.exports = new Proxy(clientReference, proxyHandlers);
218 - };
219 -
220 - // $FlowFixMe[prop-missing] found when upgrading Flow
221 - const originalResolveFilename = Module._resolveFilename;
222 -
223 - // $FlowFixMe[prop-missing] found when upgrading Flow
224 - // $FlowFixMe[missing-this-annot]
225 - Module._resolveFilename = function (request, parent, isMain, options) {
226 - const resolved = originalResolveFilename.apply(this, arguments);
227 - if (resolved.endsWith('.server.js')) {
228 - if (
229 - parent &&
230 - parent.filename &&
231 - !parent.filename.endsWith('.server.js')
232 - ) {
233 - let reason;
234 - if (request.endsWith('.server.js')) {
235 - reason = `"${request}"`;
236 - } else {
237 - reason = `"${request}" (which expands to "${resolved}")`;
238 - }
239 - throw new Error(
240 - `Cannot import ${reason} from "${parent.filename}". ` +
241 - 'By react-server convention, .server.js files can only be imported from other .server.js files. ' +
242 - 'That way nobody accidentally sends these to the client by indirectly importing it.',
243 - );
244 - }
245 - }
246 - return resolved;
253 + this.exports = new Proxy(clientReference, proxyHandlers);
254 };
255 };
packages/react-server-dom-webpack/src/ReactFlightWebpackPlugin.js
+2 -2
@@ -79,7 +79,7 @@ export default class ReactFlightWebpackPlugin {
79 {
80 directory: '.',
81 recursive: true,
82 - include: /\.client\.(js|ts|jsx|tsx)$/,
82 + include: /\.(js|ts|jsx|tsx)$/,
83 },
84 ];
85 } else if (
@@ -231,7 +231,7 @@ export default class ReactFlightWebpackPlugin {
231 // That way we know by the type of dep whether to include.
232 // It also resolves conflicts when the same module is in multiple chunks.
233
234 - if (!/\.client\.(js|ts)x?$/.test(module.resource)) {
234 + if (!/\.(js|ts)x?$/.test(module.resource)) {
235 return;
236 }
237
packages/react-server-dom-webpack/src/__tests__/utils/WebpackMock.js
+8 -8
@@ -21,21 +21,21 @@ global.__webpack_require__ = function (id) {
21 return webpackModules[id];
22 };
23
24 -const previousLoader = Module._extensions['.client.js'];
24 +const previousCompile = Module.prototype._compile;
25
26 const register = require('react-server-dom-webpack/node-register');
27 -// Register node loader
27 +// Register node compile
28 register();
29
30 -const nodeLoader = Module._extensions['.client.js'];
30 +const nodeCompile = Module.prototype._compile;
31
32 -if (previousLoader === nodeLoader) {
32 +if (previousCompile === nodeCompile) {
33 throw new Error(
34 - 'Expected the Node loader to register the .client.js extension',
34 + 'Expected the Node loader to register the _compile extension',
35 );
36 }
37
38 -Module._extensions['.client.js'] = previousLoader;
38 +Module.prototype._compile = previousCompile;
39
40 exports.webpackMap = webpackMap;
41 exports.webpackModules = webpackModules;
@@ -57,7 +57,7 @@ exports.clientModuleError = function clientModuleError(moduleError) {
57 },
58 };
59 const mod = {exports: {}};
60 - nodeLoader(mod, idx);
60 + nodeCompile.call(mod, '"use client"', idx);
61 return mod.exports;
62 };
63
@@ -99,6 +99,6 @@ exports.clientExports = function clientExports(moduleExports) {
99 };
100 }
101 const mod = {exports: {}};
102 - nodeLoader(mod, idx);
102 + nodeCompile.call(mod, '"use client"', idx);
103 return mod.exports;
104 };
scripts/rollup/bundles.js
+1 -18
@@ -950,7 +950,7 @@ deepFreeze(bundles);
950 deepFreeze(bundleTypes);
951 deepFreeze(moduleTypes);
952
953 -function getOriginalFilename(bundle, bundleType) {
953 +function getFilename(bundle, bundleType) {
954 let name = bundle.name || bundle.entry;
955 const globalName = bundle.global;
956 // we do this to replace / to -, for react-dom/server
@@ -993,23 +993,6 @@ function getOriginalFilename(bundle, bundleType) {
993 }
994 }
995
996 -function getFilename(bundle, bundleType) {
997 - const originalFilename = getOriginalFilename(bundle, bundleType);
998 - // Ensure .server.js or .client.js is the final suffix.
999 - // This is important for the Server tooling convention.
1000 - if (originalFilename.indexOf('.server.') !== -1) {
1001 - return originalFilename
1002 - .replace('.server.', '.')
1003 - .replace('.js', '.server.js');
1004 - }
1005 - if (originalFilename.indexOf('.client.') !== -1) {
1006 - return originalFilename
1007 - .replace('.client.', '.')
1008 - .replace('.js', '.client.js');
1009 - }
1010 - return originalFilename;
1011 -}
1012 -
996 module.exports = {
997 bundleTypes,
998 moduleTypes,