Fix client boundary defined in a module (#46171)

This PR fixes the bug where a client boundary (`"use client"`) is defined in a module (`"type": "module"`). Currently Next.js throws this error:

```
error - Error: Cannot find module 'private-next-rsc-mod-ref-proxy'
```

...that will be resolved with this PR.

The only limitation after this fix is, you can't have `export *` under a client boundary in a module. Added a error message for that.

NEXT-595

## Bug

- [ ] Related issues linked using `fixes #number`
- [x] Integration tests added
- [ ] Errors have a helpful link attached, see [`contributing.md`](https://github.com/vercel/next.js/blob/canary/contributing.md)

## Feature

- [ ] Implements an existing feature request or RFC. Make sure the feature request has been accepted for implementation before opening a PR.
- [ ] Related issues linked using `fixes #number`
- [ ] [e2e](https://github.com/vercel/next.js/blob/canary/contributing/core/testing.md#writing-tests-for-nextjs) tests added
- [ ] Documentation added
- [ ] Telemetry added. In case of a feature if it's used or not.
- [ ] Errors have a helpful link attached, see [`contributing.md`](https://github.com/vercel/next.js/blob/canary/contributing.md)

## Documentation / Examples

- [ ] Make sure the linting passes by running `pnpm build && pnpm lint`
- [ ] The "examples guidelines" are followed from [our contributing doc](https://github.com/vercel/next.js/blob/canary/contributing/examples/adding-examples.md)
This commit is contained in:
Shu Ding 2023-02-21 16:20:45 +01:00 committed by GitHub
parent 9e86fd1d58
commit 0ab8dcb09d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 172 additions and 14 deletions

View file

@ -43,6 +43,7 @@ struct ReactServerComponents<C: Comments> {
filepath: String,
app_dir: Option<PathBuf>,
comments: C,
export_names: Vec<String>,
invalid_server_imports: Vec<JsWord>,
invalid_client_imports: Vec<JsWord>,
invalid_server_react_apis: Vec<JsWord>,
@ -78,7 +79,7 @@ impl<C: Comments> ReactServerComponents<C> {
// Collects top level directives and imports, then removes specific ones
// from the AST.
fn collect_top_level_directives_and_imports(
&self,
&mut self,
module: &mut Module,
) -> (bool, Vec<ModuleImports>) {
let mut imports: Vec<ModuleImports> = vec![];
@ -170,6 +171,52 @@ impl<C: Comments> ReactServerComponents<C> {
finished_directives = true;
}
// Collect all export names.
ModuleItem::ModuleDecl(ModuleDecl::ExportNamed(e)) => {
for specifier in &e.specifiers {
self.export_names.push(match specifier {
ExportSpecifier::Default(_) => "default".to_string(),
ExportSpecifier::Namespace(_) => "*".to_string(),
ExportSpecifier::Named(named) => match &named.exported {
Some(exported) => match &exported {
ModuleExportName::Ident(i) => i.sym.to_string(),
ModuleExportName::Str(s) => s.value.to_string(),
},
_ => match &named.orig {
ModuleExportName::Ident(i) => i.sym.to_string(),
ModuleExportName::Str(s) => s.value.to_string(),
},
},
})
}
}
ModuleItem::ModuleDecl(ModuleDecl::ExportDecl(ExportDecl { decl, .. })) => {
match decl {
Decl::Class(ClassDecl { ident, .. }) => {
self.export_names.push(ident.sym.to_string());
}
Decl::Fn(FnDecl { ident, .. }) => {
self.export_names.push(ident.sym.to_string());
}
Decl::Var(var) => {
for decl in &var.decls {
if let Pat::Ident(ident) = &decl.name {
self.export_names.push(ident.id.sym.to_string());
}
}
}
_ => {}
}
}
ModuleItem::ModuleDecl(ModuleDecl::ExportDefaultDecl(ExportDefaultDecl {
decl: _,
..
})) => {
self.export_names.push("default".to_string());
}
ModuleItem::ModuleDecl(ModuleDecl::ExportAll(_)) => {
self.export_names.push("*".to_string());
}
_ => {
finished_directives = true;
}
@ -245,7 +292,11 @@ impl<C: Comments> ReactServerComponents<C> {
Comment {
span: DUMMY_SP,
kind: CommentKind::Block,
text: " __next_internal_client_entry_do_not_use__ ".into(),
text: format!(
" __next_internal_client_entry_do_not_use__ {} ",
self.export_names.join(",")
)
.into(),
},
);
}
@ -471,6 +522,7 @@ pub fn server_components<C: Comments>(
comments,
filepath: filename.to_string(),
app_dir,
export_names: vec![],
invalid_server_imports: vec![
JsWord::from("client-only"),
JsWord::from("react-dom/client"),

View file

@ -25,3 +25,10 @@ import "fs"
export default function () {
return null;
}
export const a = 1
const b = 1
export { b }
export { c } from 'c'
export * from 'd'
export { e as f } from 'e'

View file

@ -1,3 +1,3 @@
// This is a comment.
/* __next_internal_client_entry_do_not_use__ */ const { createProxy } = require("private-next-rsc-mod-ref-proxy");
/* __next_internal_client_entry_do_not_use__ default,a,b,c,*,f */ const { createProxy } = require("private-next-rsc-mod-ref-proxy");
module.exports = createProxy("/some-project/src/some-file.js");

View file

@ -17,6 +17,7 @@ import { tryToParsePath } from '../../lib/try-to-parse-path'
import { isAPIRoute } from '../../lib/is-api-route'
import { isEdgeRuntime } from '../../lib/is-edge-runtime'
import { RSC_MODULE_TYPES } from '../../shared/lib/constants'
import type { RSCMeta } from '../webpack/loaders/get-module-build-info'
export interface MiddlewareConfig {
matchers: MiddlewareMatcher[]
@ -39,21 +40,18 @@ export interface PageStaticInfo {
middleware?: Partial<MiddlewareConfig>
}
const CLIENT_MODULE_LABEL = '/* __next_internal_client_entry_do_not_use__ */'
const CLIENT_MODULE_LABEL =
/\/\* __next_internal_client_entry_do_not_use__ ([^ ]*) \*\//
const ACTION_MODULE_LABEL =
/\/\* __next_internal_action_entry_do_not_use__ ([^ ]+) \*\//
export type RSCModuleType = 'server' | 'client'
export function getRSCModuleInformation(source: string): {
type: RSCModuleType
actions?: string[]
} {
const type = source.includes(CLIENT_MODULE_LABEL)
? RSC_MODULE_TYPES.client
: RSC_MODULE_TYPES.server
export function getRSCModuleInformation(source: string): RSCMeta {
const clientRefs = source.match(CLIENT_MODULE_LABEL)?.[1]?.split(',')
const actions = source.match(ACTION_MODULE_LABEL)?.[1]?.split(',')
return { type, actions }
const type = clientRefs ? RSC_MODULE_TYPES.client : RSC_MODULE_TYPES.server
return { type, actions, clientRefs }
}
/**

View file

@ -25,7 +25,9 @@ export function getModuleBuildInfo(webpackModule: webpack.Module) {
}
export interface RSCMeta {
type?: RSCModuleType
type: RSCModuleType
actions?: string[]
clientRefs?: string[]
requests?: string[] // client requests in flight client entry
}

View file

@ -22,6 +22,46 @@ export default async function transformSource(
const buildInfo = getModuleBuildInfo(this._module)
buildInfo.rsc = getRSCModuleInformation(source)
const isESM = this._module?.parser?.sourceType === 'module'
// A client boundary.
if (isESM && buildInfo.rsc?.type === RSC_MODULE_TYPES.client) {
const clientRefs = buildInfo.rsc.clientRefs!
if (clientRefs.includes('*')) {
return callback(
new Error(
`It's currently unsupport to use "export *" in a client boundary. Please use named exports instead.`
)
)
}
// For ESM, we can't simply export it as a proxy via `module.exports`.
// Use multiple named exports instead.
const proxyFilepath = source.match(/createProxy\((.+)\)/)?.[1]
if (!proxyFilepath) {
return callback(
new Error(
`Failed to find the proxy file path in the client boundary. This is a bug in Next.js.`
)
)
}
let esmSource = `
import { createProxy } from "private-next-rsc-mod-ref-proxy"
const proxy = createProxy(${proxyFilepath})
`
let cnt = 0
for (const ref of clientRefs) {
if (ref === 'default') {
esmSource += `\nexport default proxy.default`
} else {
esmSource += `\nconst e${cnt} = proxy["${ref}"]\nexport { e${cnt++} as ${ref} }`
}
}
return callback(null, esmSource, sourceMap)
}
if (buildInfo.rsc?.type !== RSC_MODULE_TYPES.client) {
if (noopHeadPath === this.resourcePath) {
warnOnce(

View file

@ -177,5 +177,34 @@ createNextDescribe(
expect(v1).toBe(v2)
})
})
it('should export client module references in esm', async () => {
const html = await next.render('/esm-client-ref')
expect(html).toContain('hello')
})
if ((global as any).isNextDev) {
it('should error for wildcard exports of client module references in esm', async () => {
const page = 'app/esm-client-ref/page.js'
const pageSource = await next.readFile(page)
try {
await next.patchFile(
page,
pageSource.replace(
"'client-esm-module'",
"'client-esm-module-wildcard'"
)
)
await next.render('/esm-client-ref')
} finally {
await next.patchFile(page, pageSource)
}
expect(next.cliOutput).toInclude(
`It's currently unsupport to use "export *" in a client boundary. Please use named exports instead.`
)
})
}
}
)

View file

@ -0,0 +1,9 @@
import { Hello } from 'client-esm-module'
export default function Page() {
return (
<h1>
<Hello />
</h1>
)
}

View file

@ -0,0 +1,3 @@
'use client'
export * from './lib'

View file

@ -0,0 +1,3 @@
export function Hello() {
return 'hello'
}

View file

@ -0,0 +1,5 @@
{
"name": "client-esm-module-wildcard",
"type": "module",
"exports": "./index.js"
}

View file

@ -0,0 +1,5 @@
'use client'
export function Hello() {
return 'hello'
}

View file

@ -0,0 +1,5 @@
{
"name": "client-esm-module",
"type": "module",
"exports": "./index.js"
}