@samitouri / QOS-React / commits / 5deb78223a

[Flight] Respect `async` flag in client manifest (#30959)

In #26624, the ability to mark a client reference module as `async` in the React client manifest was removed because it was not utilized by Webpack, neither in `ReactFlightWebpackPlugin` nor in Next.js. However, some bundlers and frameworks are sophisticated enough to properly handle and identify async ESM modules (e.g., client component modules with top-level `await`), most notably Turbopack in Next.js. Therefore, we need to consider the `async` flag in the client manifest when resolving the client reference metadata on the server. The SSR manifest cannot override this flag, meaning that if a module is async, it must remain async in all client environments. x-ref: https://github.com/vercel/next.js/pull/70022

Hendrik Liebau committed Sep 13, 2024 at 22:33 UTC 5deb78223a269a6cb1706da8ec6aad8c007cab03
8 files changed +366 -2
packages/react-server-dom-turbopack/src/__tests__/ReactFlightTurbopackDOM-test.js
+120
@@ -20,6 +20,7 @@ global.TextDecoder = require('util').TextDecoder;
20 let act;
21 let use;
22 let clientExports;
23 +let clientExportsESM;
24 let turbopackMap;
25 let Stream;
26 let React;
@@ -29,6 +30,7 @@ let ReactServerDOMClient;
30 let Suspense;
31 let ReactServerScheduler;
32 let reactServerAct;
33 +let ErrorBoundary;
34
35 describe('ReactFlightTurbopackDOM', () => {
36 beforeEach(() => {
@@ -49,6 +51,7 @@ describe('ReactFlightTurbopackDOM', () => {
51
52 const TurbopackMock = require('./utils/TurbopackMock');
53 clientExports = TurbopackMock.clientExports;
54 + clientExportsESM = TurbopackMock.clientExportsESM;
55 turbopackMap = TurbopackMock.turbopackMap;
56
57 ReactServerDOMServer = require('react-server-dom-turbopack/server');
@@ -63,6 +66,22 @@ describe('ReactFlightTurbopackDOM', () => {
66 Suspense = React.Suspense;
67 ReactDOMClient = require('react-dom/client');
68 ReactServerDOMClient = require('react-server-dom-turbopack/client');
69 +
70 + ErrorBoundary = class extends React.Component {
71 + state = {hasError: false, error: null};
72 + static getDerivedStateFromError(error) {
73 + return {
74 + hasError: true,
75 + error,
76 + };
77 + }
78 + render() {
79 + if (this.state.hasError) {
80 + return this.props.fallback(this.state.error);
81 + }
82 + return this.props.children;
83 + }
84 + };
85 });
86
87 async function serverAct(callback) {
@@ -220,4 +239,105 @@ describe('ReactFlightTurbopackDOM', () => {
239 });
240 expect(container.innerHTML).toBe('<p>Async: Module</p>');
241 });
242 +
243 + it('should unwrap async ESM module references', async () => {
244 + const AsyncModule = Promise.resolve(function AsyncModule({text}) {
245 + return 'Async: ' + text;
246 + });
247 +
248 + const AsyncModule2 = Promise.resolve({
249 + exportName: 'Module',
250 + });
251 +
252 + function Print({response}) {
253 + return <p>{use(response)}</p>;
254 + }
255 +
256 + function App({response}) {
257 + return (
258 + <Suspense fallback={<h1>Loading...</h1>}>
259 + <Print response={response} />
260 + </Suspense>
261 + );
262 + }
263 +
264 + const AsyncModuleRef = await clientExportsESM(AsyncModule);
265 + const AsyncModuleRef2 = await clientExportsESM(AsyncModule2);
266 +
267 + const {writable, readable} = getTestStream();
268 + const {pipe} = await serverAct(() =>
269 + ReactServerDOMServer.renderToPipeableStream(
270 + <AsyncModuleRef text={AsyncModuleRef2.exportName} />,
271 + turbopackMap,
272 + ),
273 + );
274 + pipe(writable);
275 + const response = ReactServerDOMClient.createFromReadableStream(readable);
276 +
277 + const container = document.createElement('div');
278 + const root = ReactDOMClient.createRoot(container);
279 + await act(() => {
280 + root.render(<App response={response} />);
281 + });
282 + expect(container.innerHTML).toBe('<p>Async: Module</p>');
283 + });
284 +
285 + it('should error when a bundler uses async ESM modules with createClientModuleProxy', async () => {
286 + const AsyncModule = Promise.resolve(function AsyncModule() {
287 + return 'This should not be rendered';
288 + });
289 +
290 + function Print({response}) {
291 + return <p>{use(response)}</p>;
292 + }
293 +
294 + function App({response}) {
295 + return (
296 + <ErrorBoundary
297 + fallback={error => (
298 + <p>
299 + {__DEV__ ? error.message + ' + ' : null}
300 + {error.digest}
301 + </p>
302 + )}>
303 + <Suspense fallback={<h1>Loading...</h1>}>
304 + <Print response={response} />
305 + </Suspense>
306 + </ErrorBoundary>
307 + );
308 + }
309 +
310 + const AsyncModuleRef = await clientExportsESM(AsyncModule, {
311 + forceClientModuleProxy: true,
312 + });
313 +
314 + const {writable, readable} = getTestStream();
315 + const {pipe} = await serverAct(() =>
316 + ReactServerDOMServer.renderToPipeableStream(
317 + <AsyncModuleRef />,
318 + turbopackMap,
319 + {
320 + onError(error) {
321 + return __DEV__ ? 'a dev digest' : `digest(${error.message})`;
322 + },
323 + },
324 + ),
325 + );
326 + pipe(writable);
327 + const response = ReactServerDOMClient.createFromReadableStream(readable);
328 +
329 + const container = document.createElement('div');
330 + const root = ReactDOMClient.createRoot(container);
331 + await act(() => {
332 + root.render(<App response={response} />);
333 + });
334 +
335 + const errorMessage = `The module "${Object.keys(turbopackMap).at(0)}" is marked as an async ESM module but was loaded as a CJS proxy. This is probably a bug in the React Server Components bundler.`;
336 +
337 + expect(container.innerHTML).toBe(
338 + __DEV__
339 + ? `<p>${errorMessage} + a dev digest</p>`
340 + : `<p>digest(${errorMessage})</p>`,
341 + );
342 + });
343 });
packages/react-server-dom-turbopack/src/__tests__/utils/TurbopackMock.js
+60
@@ -23,6 +23,7 @@ global.__turbopack_require__ = function (id) {
23 };
24
25 const Server = require('react-server-dom-turbopack/server');
26 +const registerClientReference = Server.registerClientReference;
27 const registerServerReference = Server.registerServerReference;
28 const createClientModuleProxy = Server.createClientModuleProxy;
29
@@ -83,6 +84,65 @@ exports.clientExports = function clientExports(moduleExports, chunkUrl) {
84 return createClientModuleProxy(path);
85 };
86
87 +exports.clientExportsESM = function clientExportsESM(
88 + moduleExports,
89 + options?: {forceClientModuleProxy?: boolean} = {},
90 +) {
91 + const chunks = [];
92 + const idx = '' + turbopackModuleIdx++;
93 + turbopackClientModules[idx] = moduleExports;
94 + const path = url.pathToFileURL(idx).href;
95 +
96 + const createClientReferencesForExports = ({exports, async}) => {
97 + turbopackClientMap[path] = {
98 + id: idx,
99 + chunks,
100 + name: '*',
101 + async: true,
102 + };
103 +
104 + if (options.forceClientModuleProxy) {
105 + return createClientModuleProxy(path);
106 + }
107 +
108 + if (typeof exports === 'object') {
109 + const references = {};
110 +
111 + for (const name in exports) {
112 + const id = path + '#' + name;
113 + turbopackClientMap[path + '#' + name] = {
114 + id: idx,
115 + chunks,
116 + name: name,
117 + async,
118 + };
119 + references[name] = registerClientReference(() => {}, id, name);
120 + }
121 +
122 + return references;
123 + }
124 +
125 + return registerClientReference(() => {}, path, '*');
126 + };
127 +
128 + if (
129 + moduleExports &&
130 + typeof moduleExports === 'object' &&
131 + typeof moduleExports.then === 'function'
132 + ) {
133 + return moduleExports.then(
134 + asyncModuleExports =>
135 + createClientReferencesForExports({
136 + exports: asyncModuleExports,
137 + async: true,
138 + }),
139 + () => {},
140 + );
141 + }
142 +
143 + return createClientReferencesForExports({exports: moduleExports});
144 +};
145 +
146 // This tests server to server references. There's another case of client to server references.
147 exports.serverExports = function serverExports(moduleExports) {
148 const idx = '' + turbopackModuleIdx++;
packages/react-server-dom-turbopack/src/server/ReactFlightServerConfigTurbopackBundler.js
+9 -1
@@ -71,7 +71,15 @@ export function resolveClientReferenceMetadata<T>(
71 );
72 }
73 }
74 - if (clientReference.$$async === true) {
74 + if (resolvedModuleData.async === true && clientReference.$$async === true) {
75 + throw new Error(
76 + 'The module "' +
77 + modulePath +
78 + '" is marked as an async ESM module but was loaded as a CJS proxy. ' +
79 + 'This is probably a bug in the React Server Components bundler.',
80 + );
81 + }
82 + if (resolvedModuleData.async === true || clientReference.$$async === true) {
83 return [resolvedModuleData.id, resolvedModuleData.chunks, name, 1];
84 } else {
85 return [resolvedModuleData.id, resolvedModuleData.chunks, name];
packages/react-server-dom-turbopack/src/shared/ReactFlightImportMetadata.js
+1
@@ -12,6 +12,7 @@ export type ImportManifestEntry = {
12 // chunks is an array of filenames
13 chunks: Array<string>,
14 name: string,
15 + async?: boolean,
16 };
17
18 // This is the parsed shape of the wire format which is why it is
packages/react-server-dom-webpack/src/__tests__/ReactFlightDOM-test.js
+103
@@ -21,6 +21,7 @@ global.TextDecoder = require('util').TextDecoder;
21 let act;
22 let use;
23 let clientExports;
24 +let clientExportsESM;
25 let clientModuleError;
26 let webpackMap;
27 let Stream;
@@ -68,6 +69,7 @@ describe('ReactFlightDOM', () => {
69 }
70 const WebpackMock = require('./utils/WebpackMock');
71 clientExports = WebpackMock.clientExports;
72 + clientExportsESM = WebpackMock.clientExportsESM;
73 clientModuleError = WebpackMock.clientModuleError;
74 webpackMap = WebpackMock.webpackMap;
75
@@ -583,6 +585,107 @@ describe('ReactFlightDOM', () => {
585 expect(container.innerHTML).toBe('<p>Async Text</p>');
586 });
587
588 + it('should unwrap async ESM module references', async () => {
589 + const AsyncModule = Promise.resolve(function AsyncModule({text}) {
590 + return 'Async: ' + text;
591 + });
592 +
593 + const AsyncModule2 = Promise.resolve({
594 + exportName: 'Module',
595 + });
596 +
597 + function Print({response}) {
598 + return <p>{use(response)}</p>;
599 + }
600 +
601 + function App({response}) {
602 + return (
603 + <Suspense fallback={<h1>Loading...</h1>}>
604 + <Print response={response} />
605 + </Suspense>
606 + );
607 + }
608 +
609 + const AsyncModuleRef = await clientExportsESM(AsyncModule);
610 + const AsyncModuleRef2 = await clientExportsESM(AsyncModule2);
611 +
612 + const {writable, readable} = getTestStream();
613 + const {pipe} = await serverAct(() =>
614 + ReactServerDOMServer.renderToPipeableStream(
615 + <AsyncModuleRef text={AsyncModuleRef2.exportName} />,
616 + webpackMap,
617 + ),
618 + );
619 + pipe(writable);
620 + const response = ReactServerDOMClient.createFromReadableStream(readable);
621 +
622 + const container = document.createElement('div');
623 + const root = ReactDOMClient.createRoot(container);
624 + await act(() => {
625 + root.render(<App response={response} />);
626 + });
627 + expect(container.innerHTML).toBe('<p>Async: Module</p>');
628 + });
629 +
630 + it('should error when a bundler uses async ESM modules with createClientModuleProxy', async () => {
631 + const AsyncModule = Promise.resolve(function AsyncModule() {
632 + return 'This should not be rendered';
633 + });
634 +
635 + function Print({response}) {
636 + return <p>{use(response)}</p>;
637 + }
638 +
639 + function App({response}) {
640 + return (
641 + <ErrorBoundary
642 + fallback={error => (
643 + <p>
644 + {__DEV__ ? error.message + ' + ' : null}
645 + {error.digest}
646 + </p>
647 + )}>
648 + <Suspense fallback={<h1>Loading...</h1>}>
649 + <Print response={response} />
650 + </Suspense>
651 + </ErrorBoundary>
652 + );
653 + }
654 +
655 + const AsyncModuleRef = await clientExportsESM(AsyncModule, {
656 + forceClientModuleProxy: true,
657 + });
658 +
659 + const {writable, readable} = getTestStream();
660 + const {pipe} = await serverAct(() =>
661 + ReactServerDOMServer.renderToPipeableStream(
662 + <AsyncModuleRef />,
663 + webpackMap,
664 + {
665 + onError(error) {
666 + return __DEV__ ? 'a dev digest' : `digest(${error.message})`;
667 + },
668 + },
669 + ),
670 + );
671 + pipe(writable);
672 + const response = ReactServerDOMClient.createFromReadableStream(readable);
673 +
674 + const container = document.createElement('div');
675 + const root = ReactDOMClient.createRoot(container);
676 + await act(() => {
677 + root.render(<App response={response} />);
678 + });
679 +
680 + const errorMessage = `The module "${Object.keys(webpackMap).at(0)}" is marked as an async ESM module but was loaded as a CJS proxy. This is probably a bug in the React Server Components bundler.`;
681 +
682 + expect(container.innerHTML).toBe(
683 + __DEV__
684 + ? `<p>${errorMessage} + a dev digest</p>`
685 + : `<p>digest(${errorMessage})</p>`,
686 + );
687 + });
688 +
689 it('should be able to import a name called "then"', async () => {
690 const thenExports = {
691 then: function then() {
packages/react-server-dom-webpack/src/__tests__/utils/WebpackMock.js
+63
@@ -44,6 +44,10 @@ if (previousCompile === nodeCompile) {
44
45 Module.prototype._compile = previousCompile;
46
47 +const Server = require('react-server-dom-webpack/server');
48 +const registerClientReference = Server.registerClientReference;
49 +const createClientModuleProxy = Server.createClientModuleProxy;
50 +
51 exports.webpackMap = webpackClientMap;
52 exports.webpackModules = webpackClientModules;
53 exports.webpackServerMap = webpackServerMap;
@@ -126,6 +130,65 @@ exports.clientExports = function clientExports(
130 return mod.exports;
131 };
132
133 +exports.clientExportsESM = function clientExportsESM(
134 + moduleExports,
135 + options?: {forceClientModuleProxy?: boolean} = {},
136 +) {
137 + const chunks = [];
138 + const idx = '' + webpackModuleIdx++;
139 + webpackClientModules[idx] = moduleExports;
140 + const path = url.pathToFileURL(idx).href;
141 +
142 + const createClientReferencesForExports = ({exports, async}) => {
143 + webpackClientMap[path] = {
144 + id: idx,
145 + chunks,
146 + name: '*',
147 + async: true,
148 + };
149 +
150 + if (options.forceClientModuleProxy) {
151 + return createClientModuleProxy(path);
152 + }
153 +
154 + if (typeof exports === 'object') {
155 + const references = {};
156 +
157 + for (const name in exports) {
158 + const id = path + '#' + name;
159 + webpackClientMap[path + '#' + name] = {
160 + id: idx,
161 + chunks,
162 + name: name,
163 + async,
164 + };
165 + references[name] = registerClientReference(() => {}, id, name);
166 + }
167 +
168 + return references;
169 + }
170 +
171 + return registerClientReference(() => {}, path, '*');
172 + };
173 +
174 + if (
175 + moduleExports &&
176 + typeof moduleExports === 'object' &&
177 + typeof moduleExports.then === 'function'
178 + ) {
179 + return moduleExports.then(
180 + asyncModuleExports =>
181 + createClientReferencesForExports({
182 + exports: asyncModuleExports,
183 + async: true,
184 + }),
185 + () => {},
186 + );
187 + }
188 +
189 + return createClientReferencesForExports({exports: moduleExports});
190 +};
191 +
192 // This tests server to server references. There's another case of client to server references.
193 exports.serverExports = function serverExports(moduleExports, blockOnChunk) {
194 const idx = '' + webpackModuleIdx++;
packages/react-server-dom-webpack/src/server/ReactFlightServerConfigWebpackBundler.js
+9 -1
@@ -71,7 +71,15 @@ export function resolveClientReferenceMetadata<T>(
71 );
72 }
73 }
74 - if (clientReference.$$async === true) {
74 + if (resolvedModuleData.async === true && clientReference.$$async === true) {
75 + throw new Error(
76 + 'The module "' +
77 + modulePath +
78 + '" is marked as an async ESM module but was loaded as a CJS proxy. ' +
79 + 'This is probably a bug in the React Server Components bundler.',
80 + );
81 + }
82 + if (resolvedModuleData.async === true || clientReference.$$async === true) {
83 return [resolvedModuleData.id, resolvedModuleData.chunks, name, 1];
84 } else {
85 return [resolvedModuleData.id, resolvedModuleData.chunks, name];
packages/react-server-dom-webpack/src/shared/ReactFlightImportMetadata.js
+1
@@ -12,6 +12,7 @@ export type ImportManifestEntry = {
12 // chunks is a double indexed array of chunkId / chunkFilename pairs
13 chunks: Array<string>,
14 name: string,
15 + async?: boolean,
16 };
17
18 // This is the parsed shape of the wire format which is why it is