Skip to content

Commit 5deb782

Browse files
authored
[Flight] Respect async flag in client manifest (facebook#30959)
In facebook#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: vercel/next.js#70022
1 parent d9c4920 commit 5deb782

File tree

8 files changed

+366
-2
lines changed

8 files changed

+366
-2
lines changed

packages/react-server-dom-turbopack/src/__tests__/ReactFlightTurbopackDOM-test.js

+120
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ global.TextDecoder = require('util').TextDecoder;
2020
let act;
2121
let use;
2222
let clientExports;
23+
let clientExportsESM;
2324
let turbopackMap;
2425
let Stream;
2526
let React;
@@ -29,6 +30,7 @@ let ReactServerDOMClient;
2930
let Suspense;
3031
let ReactServerScheduler;
3132
let reactServerAct;
33+
let ErrorBoundary;
3234

3335
describe('ReactFlightTurbopackDOM', () => {
3436
beforeEach(() => {
@@ -49,6 +51,7 @@ describe('ReactFlightTurbopackDOM', () => {
4951

5052
const TurbopackMock = require('./utils/TurbopackMock');
5153
clientExports = TurbopackMock.clientExports;
54+
clientExportsESM = TurbopackMock.clientExportsESM;
5255
turbopackMap = TurbopackMock.turbopackMap;
5356

5457
ReactServerDOMServer = require('react-server-dom-turbopack/server');
@@ -63,6 +66,22 @@ describe('ReactFlightTurbopackDOM', () => {
6366
Suspense = React.Suspense;
6467
ReactDOMClient = require('react-dom/client');
6568
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+
};
6685
});
6786

6887
async function serverAct(callback) {
@@ -220,4 +239,105 @@ describe('ReactFlightTurbopackDOM', () => {
220239
});
221240
expect(container.innerHTML).toBe('<p>Async: Module</p>');
222241
});
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+
});
223343
});

packages/react-server-dom-turbopack/src/__tests__/utils/TurbopackMock.js

+60
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ global.__turbopack_require__ = function (id) {
2323
};
2424

2525
const Server = require('react-server-dom-turbopack/server');
26+
const registerClientReference = Server.registerClientReference;
2627
const registerServerReference = Server.registerServerReference;
2728
const createClientModuleProxy = Server.createClientModuleProxy;
2829

@@ -83,6 +84,65 @@ exports.clientExports = function clientExports(moduleExports, chunkUrl) {
8384
return createClientModuleProxy(path);
8485
};
8586

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+
86146
// This tests server to server references. There's another case of client to server references.
87147
exports.serverExports = function serverExports(moduleExports) {
88148
const idx = '' + turbopackModuleIdx++;

packages/react-server-dom-turbopack/src/server/ReactFlightServerConfigTurbopackBundler.js

+9-1
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,15 @@ export function resolveClientReferenceMetadata<T>(
7171
);
7272
}
7373
}
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) {
7583
return [resolvedModuleData.id, resolvedModuleData.chunks, name, 1];
7684
} else {
7785
return [resolvedModuleData.id, resolvedModuleData.chunks, name];

packages/react-server-dom-turbopack/src/shared/ReactFlightImportMetadata.js

+1
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export type ImportManifestEntry = {
1212
// chunks is an array of filenames
1313
chunks: Array<string>,
1414
name: string,
15+
async?: boolean,
1516
};
1617

1718
// 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
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ global.TextDecoder = require('util').TextDecoder;
2121
let act;
2222
let use;
2323
let clientExports;
24+
let clientExportsESM;
2425
let clientModuleError;
2526
let webpackMap;
2627
let Stream;
@@ -68,6 +69,7 @@ describe('ReactFlightDOM', () => {
6869
}
6970
const WebpackMock = require('./utils/WebpackMock');
7071
clientExports = WebpackMock.clientExports;
72+
clientExportsESM = WebpackMock.clientExportsESM;
7173
clientModuleError = WebpackMock.clientModuleError;
7274
webpackMap = WebpackMock.webpackMap;
7375

@@ -583,6 +585,107 @@ describe('ReactFlightDOM', () => {
583585
expect(container.innerHTML).toBe('<p>Async Text</p>');
584586
});
585587

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+
586689
it('should be able to import a name called "then"', async () => {
587690
const thenExports = {
588691
then: function then() {

0 commit comments

Comments
 (0)