diff --git a/packages/react-server-dom-turbopack/src/__tests__/ReactFlightTurbopackDOM-test.js b/packages/react-server-dom-turbopack/src/__tests__/ReactFlightTurbopackDOM-test.js index 0cf2876fedba6..d4fc59b826208 100644 --- a/packages/react-server-dom-turbopack/src/__tests__/ReactFlightTurbopackDOM-test.js +++ b/packages/react-server-dom-turbopack/src/__tests__/ReactFlightTurbopackDOM-test.js @@ -20,6 +20,7 @@ global.TextDecoder = require('util').TextDecoder; let act; let use; let clientExports; +let clientExportsESM; let turbopackMap; let Stream; let React; @@ -29,6 +30,7 @@ let ReactServerDOMClient; let Suspense; let ReactServerScheduler; let reactServerAct; +let ErrorBoundary; describe('ReactFlightTurbopackDOM', () => { beforeEach(() => { @@ -49,6 +51,7 @@ describe('ReactFlightTurbopackDOM', () => { const TurbopackMock = require('./utils/TurbopackMock'); clientExports = TurbopackMock.clientExports; + clientExportsESM = TurbopackMock.clientExportsESM; turbopackMap = TurbopackMock.turbopackMap; ReactServerDOMServer = require('react-server-dom-turbopack/server'); @@ -63,6 +66,22 @@ describe('ReactFlightTurbopackDOM', () => { Suspense = React.Suspense; ReactDOMClient = require('react-dom/client'); ReactServerDOMClient = require('react-server-dom-turbopack/client'); + + ErrorBoundary = class extends React.Component { + state = {hasError: false, error: null}; + static getDerivedStateFromError(error) { + return { + hasError: true, + error, + }; + } + render() { + if (this.state.hasError) { + return this.props.fallback(this.state.error); + } + return this.props.children; + } + }; }); async function serverAct(callback) { @@ -220,4 +239,105 @@ describe('ReactFlightTurbopackDOM', () => { }); expect(container.innerHTML).toBe('

Async: Module

'); }); + + it('should unwrap async ESM module references', async () => { + const AsyncModule = Promise.resolve(function AsyncModule({text}) { + return 'Async: ' + text; + }); + + const AsyncModule2 = Promise.resolve({ + exportName: 'Module', + }); + + function Print({response}) { + return

{use(response)}

; + } + + function App({response}) { + return ( + Loading...}> + + + ); + } + + const AsyncModuleRef = await clientExportsESM(AsyncModule); + const AsyncModuleRef2 = await clientExportsESM(AsyncModule2); + + const {writable, readable} = getTestStream(); + const {pipe} = await serverAct(() => + ReactServerDOMServer.renderToPipeableStream( + , + turbopackMap, + ), + ); + pipe(writable); + const response = ReactServerDOMClient.createFromReadableStream(readable); + + const container = document.createElement('div'); + const root = ReactDOMClient.createRoot(container); + await act(() => { + root.render(); + }); + expect(container.innerHTML).toBe('

Async: Module

'); + }); + + it('should error when a bundler uses async ESM modules with createClientModuleProxy', async () => { + const AsyncModule = Promise.resolve(function AsyncModule() { + return 'This should not be rendered'; + }); + + function Print({response}) { + return

{use(response)}

; + } + + function App({response}) { + return ( + ( +

+ {__DEV__ ? error.message + ' + ' : null} + {error.digest} +

+ )}> + Loading...}> + + +
+ ); + } + + const AsyncModuleRef = await clientExportsESM(AsyncModule, { + forceClientModuleProxy: true, + }); + + const {writable, readable} = getTestStream(); + const {pipe} = await serverAct(() => + ReactServerDOMServer.renderToPipeableStream( + , + turbopackMap, + { + onError(error) { + return __DEV__ ? 'a dev digest' : `digest(${error.message})`; + }, + }, + ), + ); + pipe(writable); + const response = ReactServerDOMClient.createFromReadableStream(readable); + + const container = document.createElement('div'); + const root = ReactDOMClient.createRoot(container); + await act(() => { + root.render(); + }); + + 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.`; + + expect(container.innerHTML).toBe( + __DEV__ + ? `

${errorMessage} + a dev digest

` + : `

digest(${errorMessage})

`, + ); + }); }); diff --git a/packages/react-server-dom-turbopack/src/__tests__/utils/TurbopackMock.js b/packages/react-server-dom-turbopack/src/__tests__/utils/TurbopackMock.js index 5ed6fa5357408..2e81d55fa692c 100644 --- a/packages/react-server-dom-turbopack/src/__tests__/utils/TurbopackMock.js +++ b/packages/react-server-dom-turbopack/src/__tests__/utils/TurbopackMock.js @@ -23,6 +23,7 @@ global.__turbopack_require__ = function (id) { }; const Server = require('react-server-dom-turbopack/server'); +const registerClientReference = Server.registerClientReference; const registerServerReference = Server.registerServerReference; const createClientModuleProxy = Server.createClientModuleProxy; @@ -83,6 +84,65 @@ exports.clientExports = function clientExports(moduleExports, chunkUrl) { return createClientModuleProxy(path); }; +exports.clientExportsESM = function clientExportsESM( + moduleExports, + options?: {forceClientModuleProxy?: boolean} = {}, +) { + const chunks = []; + const idx = '' + turbopackModuleIdx++; + turbopackClientModules[idx] = moduleExports; + const path = url.pathToFileURL(idx).href; + + const createClientReferencesForExports = ({exports, async}) => { + turbopackClientMap[path] = { + id: idx, + chunks, + name: '*', + async: true, + }; + + if (options.forceClientModuleProxy) { + return createClientModuleProxy(path); + } + + if (typeof exports === 'object') { + const references = {}; + + for (const name in exports) { + const id = path + '#' + name; + turbopackClientMap[path + '#' + name] = { + id: idx, + chunks, + name: name, + async, + }; + references[name] = registerClientReference(() => {}, id, name); + } + + return references; + } + + return registerClientReference(() => {}, path, '*'); + }; + + if ( + moduleExports && + typeof moduleExports === 'object' && + typeof moduleExports.then === 'function' + ) { + return moduleExports.then( + asyncModuleExports => + createClientReferencesForExports({ + exports: asyncModuleExports, + async: true, + }), + () => {}, + ); + } + + return createClientReferencesForExports({exports: moduleExports}); +}; + // This tests server to server references. There's another case of client to server references. exports.serverExports = function serverExports(moduleExports) { const idx = '' + turbopackModuleIdx++; diff --git a/packages/react-server-dom-turbopack/src/server/ReactFlightServerConfigTurbopackBundler.js b/packages/react-server-dom-turbopack/src/server/ReactFlightServerConfigTurbopackBundler.js index d8224aff341dc..219391f8f819e 100644 --- a/packages/react-server-dom-turbopack/src/server/ReactFlightServerConfigTurbopackBundler.js +++ b/packages/react-server-dom-turbopack/src/server/ReactFlightServerConfigTurbopackBundler.js @@ -71,7 +71,15 @@ export function resolveClientReferenceMetadata( ); } } - if (clientReference.$$async === true) { + if (resolvedModuleData.async === true && clientReference.$$async === true) { + throw new Error( + 'The module "' + + modulePath + + '" 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.', + ); + } + if (resolvedModuleData.async === true || clientReference.$$async === true) { return [resolvedModuleData.id, resolvedModuleData.chunks, name, 1]; } else { return [resolvedModuleData.id, resolvedModuleData.chunks, name]; diff --git a/packages/react-server-dom-turbopack/src/shared/ReactFlightImportMetadata.js b/packages/react-server-dom-turbopack/src/shared/ReactFlightImportMetadata.js index 60460d9c1d6d9..7cfce93deb25a 100644 --- a/packages/react-server-dom-turbopack/src/shared/ReactFlightImportMetadata.js +++ b/packages/react-server-dom-turbopack/src/shared/ReactFlightImportMetadata.js @@ -12,6 +12,7 @@ export type ImportManifestEntry = { // chunks is an array of filenames chunks: Array, name: string, + async?: boolean, }; // This is the parsed shape of the wire format which is why it is diff --git a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOM-test.js b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOM-test.js index 41fc0bfd41088..b59eb05c7b3fb 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOM-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOM-test.js @@ -21,6 +21,7 @@ global.TextDecoder = require('util').TextDecoder; let act; let use; let clientExports; +let clientExportsESM; let clientModuleError; let webpackMap; let Stream; @@ -68,6 +69,7 @@ describe('ReactFlightDOM', () => { } const WebpackMock = require('./utils/WebpackMock'); clientExports = WebpackMock.clientExports; + clientExportsESM = WebpackMock.clientExportsESM; clientModuleError = WebpackMock.clientModuleError; webpackMap = WebpackMock.webpackMap; @@ -583,6 +585,107 @@ describe('ReactFlightDOM', () => { expect(container.innerHTML).toBe('

Async Text

'); }); + it('should unwrap async ESM module references', async () => { + const AsyncModule = Promise.resolve(function AsyncModule({text}) { + return 'Async: ' + text; + }); + + const AsyncModule2 = Promise.resolve({ + exportName: 'Module', + }); + + function Print({response}) { + return

{use(response)}

; + } + + function App({response}) { + return ( + Loading...}> + + + ); + } + + const AsyncModuleRef = await clientExportsESM(AsyncModule); + const AsyncModuleRef2 = await clientExportsESM(AsyncModule2); + + const {writable, readable} = getTestStream(); + const {pipe} = await serverAct(() => + ReactServerDOMServer.renderToPipeableStream( + , + webpackMap, + ), + ); + pipe(writable); + const response = ReactServerDOMClient.createFromReadableStream(readable); + + const container = document.createElement('div'); + const root = ReactDOMClient.createRoot(container); + await act(() => { + root.render(); + }); + expect(container.innerHTML).toBe('

Async: Module

'); + }); + + it('should error when a bundler uses async ESM modules with createClientModuleProxy', async () => { + const AsyncModule = Promise.resolve(function AsyncModule() { + return 'This should not be rendered'; + }); + + function Print({response}) { + return

{use(response)}

; + } + + function App({response}) { + return ( + ( +

+ {__DEV__ ? error.message + ' + ' : null} + {error.digest} +

+ )}> + Loading...}> + + +
+ ); + } + + const AsyncModuleRef = await clientExportsESM(AsyncModule, { + forceClientModuleProxy: true, + }); + + const {writable, readable} = getTestStream(); + const {pipe} = await serverAct(() => + ReactServerDOMServer.renderToPipeableStream( + , + webpackMap, + { + onError(error) { + return __DEV__ ? 'a dev digest' : `digest(${error.message})`; + }, + }, + ), + ); + pipe(writable); + const response = ReactServerDOMClient.createFromReadableStream(readable); + + const container = document.createElement('div'); + const root = ReactDOMClient.createRoot(container); + await act(() => { + root.render(); + }); + + 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.`; + + expect(container.innerHTML).toBe( + __DEV__ + ? `

${errorMessage} + a dev digest

` + : `

digest(${errorMessage})

`, + ); + }); + it('should be able to import a name called "then"', async () => { const thenExports = { then: function then() { diff --git a/packages/react-server-dom-webpack/src/__tests__/utils/WebpackMock.js b/packages/react-server-dom-webpack/src/__tests__/utils/WebpackMock.js index 4527118c1de8b..654bcdc9b6d5c 100644 --- a/packages/react-server-dom-webpack/src/__tests__/utils/WebpackMock.js +++ b/packages/react-server-dom-webpack/src/__tests__/utils/WebpackMock.js @@ -44,6 +44,10 @@ if (previousCompile === nodeCompile) { Module.prototype._compile = previousCompile; +const Server = require('react-server-dom-webpack/server'); +const registerClientReference = Server.registerClientReference; +const createClientModuleProxy = Server.createClientModuleProxy; + exports.webpackMap = webpackClientMap; exports.webpackModules = webpackClientModules; exports.webpackServerMap = webpackServerMap; @@ -126,6 +130,65 @@ exports.clientExports = function clientExports( return mod.exports; }; +exports.clientExportsESM = function clientExportsESM( + moduleExports, + options?: {forceClientModuleProxy?: boolean} = {}, +) { + const chunks = []; + const idx = '' + webpackModuleIdx++; + webpackClientModules[idx] = moduleExports; + const path = url.pathToFileURL(idx).href; + + const createClientReferencesForExports = ({exports, async}) => { + webpackClientMap[path] = { + id: idx, + chunks, + name: '*', + async: true, + }; + + if (options.forceClientModuleProxy) { + return createClientModuleProxy(path); + } + + if (typeof exports === 'object') { + const references = {}; + + for (const name in exports) { + const id = path + '#' + name; + webpackClientMap[path + '#' + name] = { + id: idx, + chunks, + name: name, + async, + }; + references[name] = registerClientReference(() => {}, id, name); + } + + return references; + } + + return registerClientReference(() => {}, path, '*'); + }; + + if ( + moduleExports && + typeof moduleExports === 'object' && + typeof moduleExports.then === 'function' + ) { + return moduleExports.then( + asyncModuleExports => + createClientReferencesForExports({ + exports: asyncModuleExports, + async: true, + }), + () => {}, + ); + } + + return createClientReferencesForExports({exports: moduleExports}); +}; + // This tests server to server references. There's another case of client to server references. exports.serverExports = function serverExports(moduleExports, blockOnChunk) { const idx = '' + webpackModuleIdx++; diff --git a/packages/react-server-dom-webpack/src/server/ReactFlightServerConfigWebpackBundler.js b/packages/react-server-dom-webpack/src/server/ReactFlightServerConfigWebpackBundler.js index d29516ff946ea..f9d9bf4ea9169 100644 --- a/packages/react-server-dom-webpack/src/server/ReactFlightServerConfigWebpackBundler.js +++ b/packages/react-server-dom-webpack/src/server/ReactFlightServerConfigWebpackBundler.js @@ -71,7 +71,15 @@ export function resolveClientReferenceMetadata( ); } } - if (clientReference.$$async === true) { + if (resolvedModuleData.async === true && clientReference.$$async === true) { + throw new Error( + 'The module "' + + modulePath + + '" 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.', + ); + } + if (resolvedModuleData.async === true || clientReference.$$async === true) { return [resolvedModuleData.id, resolvedModuleData.chunks, name, 1]; } else { return [resolvedModuleData.id, resolvedModuleData.chunks, name]; diff --git a/packages/react-server-dom-webpack/src/shared/ReactFlightImportMetadata.js b/packages/react-server-dom-webpack/src/shared/ReactFlightImportMetadata.js index 08aafaf00c605..29b012f605204 100644 --- a/packages/react-server-dom-webpack/src/shared/ReactFlightImportMetadata.js +++ b/packages/react-server-dom-webpack/src/shared/ReactFlightImportMetadata.js @@ -12,6 +12,7 @@ export type ImportManifestEntry = { // chunks is a double indexed array of chunkId / chunkFilename pairs chunks: Array, name: string, + async?: boolean, }; // This is the parsed shape of the wire format which is why it is