[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