Skip to content

Commit

Permalink
Handle async module for client components (#39953)
Browse files Browse the repository at this point in the history
### Problem

esm modules imports from client components will be compiled to `m = import('module-name')` when webpack bundles them for server components flight rendering. In this case, they will all become async modules since dyanmic imports will return a promise which react flight cannot handle it then results into module resolving error on server flight rendering.

### Solution

* React flight renderer supports handling async modules in facebook/react#25138
* On next.js side leverage the module proxy change for each client reference, to make sure it always resolve the correct client module

The idea is wrapping each module with a module proxy, and if the module is async and accessed as thenable, it will return a new module reference with `async` label to tell react to handle it as async modules:

exported client reference `*` --> not async module (non thenable) --> original module reference `''`
exported client reference `*` --> it's async module (thenable) --> wrapped module reference `'*'` with `async` label

### Note

Since we need to check if user having incorrect gSSP/gSP specifying in layout client componet, so we still need to parse it and assign those info to the proxy (Does client module containing `ssr`, `ssg` exports). Otherwise the proxy will return the cached module reference
  • Loading branch information
huozhi authored Aug 29, 2022
1 parent 1b61d1f commit b4f74ee
Show file tree
Hide file tree
Showing 19 changed files with 12,350 additions and 4,676 deletions.
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -177,8 +177,8 @@
"react-17": "npm:react@17.0.2",
"react-dom": "18.2.0",
"react-dom-17": "npm:react-dom@17.0.2",
"react-dom-exp": "npm:react-dom@0.0.0-experimental-6ef466c68-20220816",
"react-exp": "npm:react@0.0.0-experimental-6ef466c68-20220816",
"react-dom-exp": "npm:react-dom@0.0.0-experimental-0de3ddf56-20220825",
"react-exp": "npm:react@0.0.0-experimental-0de3ddf56-20220825",
"react-ssr-prepass": "1.0.8",
"react-virtualized": "9.22.3",
"relay-compiler": "13.0.2",
Expand Down
2 changes: 1 addition & 1 deletion packages/next/build/analysis/get-page-static-info.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export interface PageStaticInfo {
* - Modules with `export function getStaticProps | getServerSideProps`
* - Modules with `export { getStaticProps | getServerSideProps } <from ...>`
*/
function checkExports(swcAST: any) {
export function checkExports(swcAST: any): { ssr: boolean; ssg: boolean } {
if (Array.isArray(swcAST?.body)) {
try {
for (const node of swcAST.body) {
Expand Down
177 changes: 0 additions & 177 deletions packages/next/build/webpack/loaders/next-flight-client-loader.ts

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

import { checkExports } from '../../../analysis/get-page-static-info'
import { parse } from '../../../swc'

export default async function transformSource(
this: any,
source: string
): Promise<string> {
const { resourcePath } = this

const transformedSource = source
if (typeof transformedSource !== 'string') {
throw new Error('Expected source to have been transformed to a string.')
}

const swcAST = await parse(transformedSource, {
filename: resourcePath,
isModule: 'unknown',
})
const { ssg, ssr } = checkExports(swcAST)

const output = `
const { createProxy } = require("next/dist/build/webpack/loaders/next-flight-client-loader/module-proxy")\n
module.exports = createProxy(${JSON.stringify(
resourcePath
)}, { ssr: ${ssr}, ssg: ${ssg} })
`
return output
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

// Modified from https://github.com/facebook/react/blob/main/packages/react-server-dom-webpack/src/ReactFlightWebpackNodeRegister.js

const MODULE_REFERENCE = Symbol.for('react.module.reference')
const PROMISE_PROTOTYPE = Promise.prototype

const proxyHandlers: ProxyHandler<object> = {
get: function (target: any, name: string, _receiver: any) {
switch (name) {
// These names are read by the Flight runtime if you end up using the exports object.
case '$$typeof':
// These names are a little too common. We should probably have a way to
// have the Flight runtime extract the inner target instead.
return target.$$typeof
case 'filepath':
return target.filepath
case 'name':
return target.name
case 'async':
return target.async
// We need to special case this because createElement reads it if we pass this
// reference.
case 'defaultProps':
return undefined
case '__esModule':
// Something is conditionally checking which export to use. We'll pretend to be
// an ESM compat module but then we'll check again on the client.
target.default = {
$$typeof: MODULE_REFERENCE,
filepath: target.filepath,
// This a placeholder value that tells the client to conditionally use the
// whole object or just the default export.
name: '',
async: target.async,

ssr: target.ssr,
ssg: target.ssg,
}
return true
case 'then':
if (!target.async) {
// If this module is expected to return a Promise (such as an AsyncModule) then
// we should resolve that with a client reference that unwraps the Promise on
// the client.
const then = function then(
resolve: (res: any) => void,
_reject: (err: any) => void
) {
const moduleReference: Record<string, any> = {
$$typeof: MODULE_REFERENCE,
filepath: target.filepath,
name: '*', // Represents the whole object instead of a particular import.
async: true,

ssr: target.ssr,
ssg: target.ssg,
}
return Promise.resolve(
resolve(new Proxy(moduleReference, proxyHandlers))
)
}
// If this is not used as a Promise but is treated as a reference to a `.then`
// export then we should treat it as a reference to that name.
then.$$typeof = MODULE_REFERENCE
then.filepath = target.filepath
// then.name is conveniently already "then" which is the export name we need.
// This will break if it's minified though.
return then
}
break

case 'ssg':
return target.ssg
case 'ssr':
return target.ssr
default:
break
}
let cachedReference = target[name]
if (!cachedReference) {
cachedReference = target[name] = {
$$typeof: MODULE_REFERENCE,
filepath: target.filepath,
name: name,
async: target.async,
}
}
return cachedReference
},
getPrototypeOf(_target: object) {
// Pretend to be a Promise in case anyone asks.
return PROMISE_PROTOTYPE
},
set: function () {
throw new Error('Cannot assign to a client module from a server module.')
},
}

export function createProxy(
moduleId: string,
{ ssr, ssg }: { ssr: boolean; ssg: boolean }
) {
const moduleReference = {
$$typeof: MODULE_REFERENCE,
filepath: moduleId,
name: '*', // Represents the whole object instead of a particular import.
async: false,

ssr,
ssg,
}
return new Proxy(moduleReference, proxyHandlers)
}
Loading

0 comments on commit b4f74ee

Please sign in to comment.