Skip to content

Commit

Permalink
[Flight] Respect async flag in client manifest (#30959)
Browse files Browse the repository at this point in the history
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: vercel/next.js#70022
  • Loading branch information
unstubbable authored Sep 13, 2024
1 parent d9c4920 commit 5deb782
Show file tree
Hide file tree
Showing 8 changed files with 366 additions and 2 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ global.TextDecoder = require('util').TextDecoder;
let act;
let use;
let clientExports;
let clientExportsESM;
let turbopackMap;
let Stream;
let React;
Expand All @@ -29,6 +30,7 @@ let ReactServerDOMClient;
let Suspense;
let ReactServerScheduler;
let reactServerAct;
let ErrorBoundary;

describe('ReactFlightTurbopackDOM', () => {
beforeEach(() => {
Expand All @@ -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');
Expand All @@ -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) {
Expand Down Expand Up @@ -220,4 +239,105 @@ describe('ReactFlightTurbopackDOM', () => {
});
expect(container.innerHTML).toBe('<p>Async: Module</p>');
});

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 <p>{use(response)}</p>;
}

function App({response}) {
return (
<Suspense fallback={<h1>Loading...</h1>}>
<Print response={response} />
</Suspense>
);
}

const AsyncModuleRef = await clientExportsESM(AsyncModule);
const AsyncModuleRef2 = await clientExportsESM(AsyncModule2);

const {writable, readable} = getTestStream();
const {pipe} = await serverAct(() =>
ReactServerDOMServer.renderToPipeableStream(
<AsyncModuleRef text={AsyncModuleRef2.exportName} />,
turbopackMap,
),
);
pipe(writable);
const response = ReactServerDOMClient.createFromReadableStream(readable);

const container = document.createElement('div');
const root = ReactDOMClient.createRoot(container);
await act(() => {
root.render(<App response={response} />);
});
expect(container.innerHTML).toBe('<p>Async: Module</p>');
});

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 <p>{use(response)}</p>;
}

function App({response}) {
return (
<ErrorBoundary
fallback={error => (
<p>
{__DEV__ ? error.message + ' + ' : null}
{error.digest}
</p>
)}>
<Suspense fallback={<h1>Loading...</h1>}>
<Print response={response} />
</Suspense>
</ErrorBoundary>
);
}

const AsyncModuleRef = await clientExportsESM(AsyncModule, {
forceClientModuleProxy: true,
});

const {writable, readable} = getTestStream();
const {pipe} = await serverAct(() =>
ReactServerDOMServer.renderToPipeableStream(
<AsyncModuleRef />,
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(<App response={response} />);
});

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__
? `<p>${errorMessage} + a dev digest</p>`
: `<p>digest(${errorMessage})</p>`,
);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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++;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,15 @@ export function resolveClientReferenceMetadata<T>(
);
}
}
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];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export type ImportManifestEntry = {
// chunks is an array of filenames
chunks: Array<string>,
name: string,
async?: boolean,
};

// This is the parsed shape of the wire format which is why it is
Expand Down
103 changes: 103 additions & 0 deletions packages/react-server-dom-webpack/src/__tests__/ReactFlightDOM-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ global.TextDecoder = require('util').TextDecoder;
let act;
let use;
let clientExports;
let clientExportsESM;
let clientModuleError;
let webpackMap;
let Stream;
Expand Down Expand Up @@ -68,6 +69,7 @@ describe('ReactFlightDOM', () => {
}
const WebpackMock = require('./utils/WebpackMock');
clientExports = WebpackMock.clientExports;
clientExportsESM = WebpackMock.clientExportsESM;
clientModuleError = WebpackMock.clientModuleError;
webpackMap = WebpackMock.webpackMap;

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

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 <p>{use(response)}</p>;
}

function App({response}) {
return (
<Suspense fallback={<h1>Loading...</h1>}>
<Print response={response} />
</Suspense>
);
}

const AsyncModuleRef = await clientExportsESM(AsyncModule);
const AsyncModuleRef2 = await clientExportsESM(AsyncModule2);

const {writable, readable} = getTestStream();
const {pipe} = await serverAct(() =>
ReactServerDOMServer.renderToPipeableStream(
<AsyncModuleRef text={AsyncModuleRef2.exportName} />,
webpackMap,
),
);
pipe(writable);
const response = ReactServerDOMClient.createFromReadableStream(readable);

const container = document.createElement('div');
const root = ReactDOMClient.createRoot(container);
await act(() => {
root.render(<App response={response} />);
});
expect(container.innerHTML).toBe('<p>Async: Module</p>');
});

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 <p>{use(response)}</p>;
}

function App({response}) {
return (
<ErrorBoundary
fallback={error => (
<p>
{__DEV__ ? error.message + ' + ' : null}
{error.digest}
</p>
)}>
<Suspense fallback={<h1>Loading...</h1>}>
<Print response={response} />
</Suspense>
</ErrorBoundary>
);
}

const AsyncModuleRef = await clientExportsESM(AsyncModule, {
forceClientModuleProxy: true,
});

const {writable, readable} = getTestStream();
const {pipe} = await serverAct(() =>
ReactServerDOMServer.renderToPipeableStream(
<AsyncModuleRef />,
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(<App response={response} />);
});

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__
? `<p>${errorMessage} + a dev digest</p>`
: `<p>digest(${errorMessage})</p>`,
);
});

it('should be able to import a name called "then"', async () => {
const thenExports = {
then: function then() {
Expand Down
Loading

0 comments on commit 5deb782

Please sign in to comment.