diff --git a/Cargo.lock b/Cargo.lock index 357e461b6066c..62102bc1cddbd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4531,6 +4531,7 @@ dependencies = [ "either", "fxhash", "hex", + "indexmap 2.5.0", "indoc", "lazy_static", "modularize_imports", diff --git a/crates/next-custom-transforms/Cargo.toml b/crates/next-custom-transforms/Cargo.toml index c4c2f11e1dab5..d9760a9ec1529 100644 --- a/crates/next-custom-transforms/Cargo.toml +++ b/crates/next-custom-transforms/Cargo.toml @@ -19,6 +19,7 @@ easy-error = "1.0.0" either = "1" fxhash = "0.2.1" hex = "0.4.3" +indexmap = { workspace = true } indoc = { workspace = true } once_cell = { workspace = true } pathdiff = { workspace = true } diff --git a/crates/next-custom-transforms/src/transforms/react_server_components.rs b/crates/next-custom-transforms/src/transforms/react_server_components.rs index 5e4437bb1db03..5d9969d88cf5a 100644 --- a/crates/next-custom-transforms/src/transforms/react_server_components.rs +++ b/crates/next-custom-transforms/src/transforms/react_server_components.rs @@ -1,5 +1,6 @@ use std::{collections::HashMap, path::PathBuf, rc::Rc, sync::Arc}; +use indexmap::IndexMap; use once_cell::sync::Lazy; use regex::Regex; use serde::Deserialize; @@ -75,7 +76,7 @@ enum RSCErrorKind { NextRscErrReactApi((String, Span)), NextRscErrErrorFileServerComponent(Span), NextRscErrClientMetadataExport((String, Span)), - NextRscErrConflictMetadataExport(Span), + NextRscErrConflictMetadataExport((Span, Span)), NextRscErrInvalidApi((String, Span)), NextRscErrDeprecatedApi((String, String, Span)), NextSsrDynamicFalseNotAllowed(Span), @@ -84,6 +85,7 @@ enum RSCErrorKind { enum InvalidExportKind { General, + Metadata, DynamicIoSegment, } @@ -233,18 +235,18 @@ impl ReactServerComponents { /// Consolidated place to parse, generate error messages for the RSC parsing /// errors. fn report_error(app_dir: &Option, filepath: &str, error_kind: RSCErrorKind) { - let (msg, span) = match error_kind { + let (msg, spans) = match error_kind { RSCErrorKind::RedundantDirectives(span) => ( "It's not possible to have both `use client` and `use server` directives in the \ same file." .to_string(), - span, + vec![span], ), RSCErrorKind::NextRscErrClientDirective(span) => ( "The \"use client\" directive must be placed before other expressions. Move it to \ the top of the file to resolve this issue." .to_string(), - span, + vec![span], ), RSCErrorKind::NextRscErrServerImport((source, span)) => { let msg = match source.as_str() { @@ -255,7 +257,7 @@ fn report_error(app_dir: &Option, filepath: &str, error_kind: RSCErrorK _ => format!(r#"You're importing a component that imports {source}. It only works in a Client Component but none of its parents are marked with "use client", so they're Server Components by default.\nLearn more: https://nextjs.org/docs/app/building-your-application/rendering\n\n"#) }; - (msg, span) + (msg, vec![span]) } RSCErrorKind::NextRscErrClientImport((source, span)) => { let is_app_dir = app_dir @@ -274,7 +276,7 @@ fn report_error(app_dir: &Option, filepath: &str, error_kind: RSCErrorK } else { format!("You're importing a component that needs \"{source}\". That only works in a Server Component but one of its parents is marked with \"use client\", so it's a Client Component.\nLearn more: https://nextjs.org/docs/app/building-your-application/rendering\n\n") }; - (msg, span) + (msg, vec![span]) } RSCErrorKind::NextRscErrReactApi((source, span)) => { let msg = if source == "Component" { @@ -283,46 +285,46 @@ fn report_error(app_dir: &Option, filepath: &str, error_kind: RSCErrorK format!("You're importing a component that needs `{source}`. This React hook only works in a client component. To fix, mark the file (or its parent) with the `\"use client\"` directive.\n\n Learn more: https://nextjs.org/docs/app/api-reference/directives/use-client\n\n") }; - (msg,span) + (msg, vec![span]) }, RSCErrorKind::NextRscErrErrorFileServerComponent(span) => { ( format!("{filepath} must be a Client Component. Add the \"use client\" directive the top of the file to resolve this issue.\nLearn more: https://nextjs.org/docs/app/api-reference/directives/use-client\n\n"), - span + vec![span] ) }, RSCErrorKind::NextRscErrClientMetadataExport((source, span)) => { - (format!("You are attempting to export \"{source}\" from a component marked with \"use client\", which is disallowed. Either remove the export, or the \"use client\" directive. Read more: https://nextjs.org/docs/app/api-reference/directives/use-client\n\n"), span) + (format!("You are attempting to export \"{source}\" from a component marked with \"use client\", which is disallowed. Either remove the export, or the \"use client\" directive. Read more: https://nextjs.org/docs/app/api-reference/directives/use-client\n\n"), vec![span]) }, - RSCErrorKind::NextRscErrConflictMetadataExport(span) => ( + RSCErrorKind::NextRscErrConflictMetadataExport((span1, span2)) => ( "\"metadata\" and \"generateMetadata\" cannot be exported at the same time, please keep one of them. Read more: https://nextjs.org/docs/app/api-reference/file-conventions/metadata\n\n".to_string(), - span + vec![span1, span2] ), //NEXT_RSC_ERR_INVALID_API RSCErrorKind::NextRscErrInvalidApi((source, span)) => ( - format!("\"{source}\" is not supported in app/. Read more: https://nextjs.org/docs/app/building-your-application/data-fetching\n\n"), span + format!("\"{source}\" is not supported in app/. Read more: https://nextjs.org/docs/app/building-your-application/data-fetching\n\n"), vec![span] ), RSCErrorKind::NextRscErrDeprecatedApi((source, item, span)) => match (&*source, &*item) { ("next/server", "ImageResponse") => ( "ImageResponse moved from \"next/server\" to \"next/og\" since Next.js 14, please \ import from \"next/og\" instead" .to_string(), - span, + vec![span], ), - _ => (format!("\"{source}\" is deprecated."), span), + _ => (format!("\"{source}\" is deprecated."), vec![span]), }, RSCErrorKind::NextSsrDynamicFalseNotAllowed(span) => ( "`ssr: false` is not allowed with `next/dynamic` in Server Components. Please move it into a client component." .to_string(), - span, + vec![span], ), RSCErrorKind::NextRscErrIncompatibleDynamicIoSegment(span, segment) => ( - format!("\"{}\" is not compatible with `nextConfig.experimental.dynamicIO`. Please remove it.", segment), - span, + format!("Route segment config \"{}\" is not compatible with `nextConfig.experimental.dynamicIO`. Please remove it.", segment), + vec![span], ), }; - HANDLER.with(|handler| handler.struct_span_err(span, msg.as_str()).emit()) + HANDLER.with(|handler| handler.struct_span_err(spans, msg.as_str()).emit()) } /// Collects top level directives and imports @@ -752,30 +754,31 @@ impl ReactServerComponentValidator { let is_layout_or_page = RE.is_match(&self.filepath); if is_layout_or_page { - let mut span = DUMMY_SP; - let mut invalid_export_name = String::new(); - let mut invalid_exports: HashMap = HashMap::new(); - - let mut invalid_exports_matcher = |export_name: &str| -> bool { - match export_name { - "getServerSideProps" | "getStaticProps" | "generateMetadata" | "metadata" => { - invalid_exports.insert(export_name.to_string(), InvalidExportKind::General); - true + let mut possibly_invalid_exports: IndexMap = + IndexMap::new(); + + let mut collect_possibly_invalid_exports = + |export_name: &str, span: &Span| match export_name { + "getServerSideProps" | "getStaticProps" => { + possibly_invalid_exports + .insert(export_name.to_string(), (InvalidExportKind::General, *span)); + } + "generateMetadata" | "metadata" => { + possibly_invalid_exports.insert( + export_name.to_string(), + (InvalidExportKind::Metadata, *span), + ); } "dynamicParams" | "dynamic" | "fetchCache" | "runtime" | "revalidate" => { if self.dynamic_io_enabled { - invalid_exports.insert( + possibly_invalid_exports.insert( export_name.to_string(), - InvalidExportKind::DynamicIoSegment, + (InvalidExportKind::DynamicIoSegment, *span), ); - true - } else { - false } } - _ => false, - } - }; + _ => (), + }; for export in &module.body { match export { @@ -784,16 +787,10 @@ impl ReactServerComponentValidator { if let ExportSpecifier::Named(named) = specifier { match &named.orig { ModuleExportName::Ident(i) => { - if invalid_exports_matcher(&i.sym) { - span = named.span; - invalid_export_name = i.sym.to_string(); - } + collect_possibly_invalid_exports(&i.sym, &named.span); } ModuleExportName::Str(s) => { - if invalid_exports_matcher(&s.value) { - span = named.span; - invalid_export_name = s.value.to_string(); - } + collect_possibly_invalid_exports(&s.value, &named.span); } } } @@ -801,18 +798,12 @@ impl ReactServerComponentValidator { } ModuleItem::ModuleDecl(ModuleDecl::ExportDecl(export)) => match &export.decl { Decl::Fn(f) => { - if invalid_exports_matcher(&f.ident.sym) { - span = f.ident.span; - invalid_export_name = f.ident.sym.to_string(); - } + collect_possibly_invalid_exports(&f.ident.sym, &f.ident.span); } Decl::Var(v) => { for decl in &v.decls { if let Pat::Ident(i) = &decl.name { - if invalid_exports_matcher(&i.sym) { - span = i.span; - invalid_export_name = i.sym.to_string(); - } + collect_possibly_invalid_exports(&i.sym, &i.span); } } } @@ -822,59 +813,56 @@ impl ReactServerComponentValidator { } } - // Assert invalid metadata and generateMetadata exports. - let has_gm_export = invalid_exports.contains_key("generateMetadata"); - let has_metadata_export = invalid_exports.contains_key("metadata"); - - for (export_name, kind) in &invalid_exports { + for (export_name, (kind, span)) in &possibly_invalid_exports { match kind { InvalidExportKind::DynamicIoSegment => { report_error( &self.app_dir, &self.filepath, RSCErrorKind::NextRscErrIncompatibleDynamicIoSegment( - span, + *span, export_name.clone(), ), ); } - InvalidExportKind::General => { + InvalidExportKind::Metadata => { // Client entry can't export `generateMetadata` or `metadata`. - if is_client_entry { - if has_gm_export || has_metadata_export { - report_error( - &self.app_dir, - &self.filepath, - RSCErrorKind::NextRscErrClientMetadataExport(( - invalid_export_name.clone(), - span, - )), - ); - } - } else { - // Server entry can't export `generateMetadata` and `metadata` together. - if has_gm_export && has_metadata_export { - report_error( - &self.app_dir, - &self.filepath, - RSCErrorKind::NextRscErrConflictMetadataExport(span), - ); - } - } - // Assert `getServerSideProps` and `getStaticProps` exports. - if invalid_export_name == "getServerSideProps" - || invalid_export_name == "getStaticProps" + if is_client_entry + && (export_name == "generateMetadata" || export_name == "metadata") { report_error( &self.app_dir, &self.filepath, - RSCErrorKind::NextRscErrInvalidApi(( - invalid_export_name.clone(), - span, + RSCErrorKind::NextRscErrClientMetadataExport(( + export_name.clone(), + *span, )), ); } + // Server entry can't export `generateMetadata` and `metadata` together, + // which is handled separately below. } + InvalidExportKind::General => { + report_error( + &self.app_dir, + &self.filepath, + RSCErrorKind::NextRscErrInvalidApi((export_name.clone(), *span)), + ); + } + } + } + + // Server entry can't export `generateMetadata` and `metadata` together. + if !is_client_entry { + let export1 = possibly_invalid_exports.get("generateMetadata"); + let export2 = possibly_invalid_exports.get("metadata"); + + if let (Some((_, span1)), Some((_, span2))) = (export1, export2) { + report_error( + &self.app_dir, + &self.filepath, + RSCErrorKind::NextRscErrConflictMetadataExport((*span1, *span2)), + ); } } } diff --git a/crates/next-custom-transforms/tests/errors/react-server-components/client-graph/multiple/output.stderr b/crates/next-custom-transforms/tests/errors/react-server-components/client-graph/multiple/output.stderr index 4071d9fd5d0b8..5c28c0da62586 100644 --- a/crates/next-custom-transforms/tests/errors/react-server-components/client-graph/multiple/output.stderr +++ b/crates/next-custom-transforms/tests/errors/react-server-components/client-graph/multiple/output.stderr @@ -1,11 +1,19 @@ - x You are attempting to export "getServerSideProps" from a component marked with "use client", which is disallowed. Either remove the export, or the "use client" directive. Read more: https:// + x You are attempting to export "metadata" from a component marked with "use client", which is disallowed. Either remove the export, or the "use client" directive. Read more: https://nextjs.org/ + | docs/app/api-reference/directives/use-client + | + | + ,-[input.js:1:1] + 1 | export const metadata = {} + : ^^^^^^^^ + `---- + x You are attempting to export "generateMetadata" from a component marked with "use client", which is disallowed. Either remove the export, or the "use client" directive. Read more: https:// | nextjs.org/docs/app/api-reference/directives/use-client | | - ,-[input.js:5:1] - 4 | - 5 | export function getServerSideProps() {} - : ^^^^^^^^^^^^^^^^^^ + ,-[input.js:3:1] + 2 | + 3 | export function generateMetadata() {} + : ^^^^^^^^^^^^^^^^ `---- x "getServerSideProps" is not supported in app/. Read more: https://nextjs.org/docs/app/building-your-application/data-fetching | diff --git a/crates/next-custom-transforms/tests/errors/react-server-components/server-graph/dynamic-io/output.stderr b/crates/next-custom-transforms/tests/errors/react-server-components/server-graph/dynamic-io/output.stderr index 8c7461630a6f5..da3889fcf552d 100644 --- a/crates/next-custom-transforms/tests/errors/react-server-components/server-graph/dynamic-io/output.stderr +++ b/crates/next-custom-transforms/tests/errors/react-server-components/server-graph/dynamic-io/output.stderr @@ -1,28 +1,31 @@ - x "dynamicParams" is not compatible with `nextConfig.experimental.dynamicIO`. Please remove it. - ,-[input.js:5:1] - 4 | export const fetchCache = 'force-no-store' - 5 | export const revalidate = 1 - : ^^^^^^^^^^ + x Route segment config "runtime" is not compatible with `nextConfig.experimental.dynamicIO`. Please remove it. + ,-[input.js:1:1] + 1 | export const runtime = 'edge' + : ^^^^^^^ + 2 | export const dynamic = 'force-dynamic' `---- - x "dynamic" is not compatible with `nextConfig.experimental.dynamicIO`. Please remove it. - ,-[input.js:5:1] - 4 | export const fetchCache = 'force-no-store' - 5 | export const revalidate = 1 - : ^^^^^^^^^^ + x Route segment config "dynamic" is not compatible with `nextConfig.experimental.dynamicIO`. Please remove it. + ,-[input.js:2:1] + 1 | export const runtime = 'edge' + 2 | export const dynamic = 'force-dynamic' + : ^^^^^^^ + 3 | export const dynamicParams = false `---- - x "fetchCache" is not compatible with `nextConfig.experimental.dynamicIO`. Please remove it. - ,-[input.js:5:1] + x Route segment config "dynamicParams" is not compatible with `nextConfig.experimental.dynamicIO`. Please remove it. + ,-[input.js:3:1] + 2 | export const dynamic = 'force-dynamic' + 3 | export const dynamicParams = false + : ^^^^^^^^^^^^^ 4 | export const fetchCache = 'force-no-store' - 5 | export const revalidate = 1 - : ^^^^^^^^^^ `---- - x "runtime" is not compatible with `nextConfig.experimental.dynamicIO`. Please remove it. - ,-[input.js:5:1] + x Route segment config "fetchCache" is not compatible with `nextConfig.experimental.dynamicIO`. Please remove it. + ,-[input.js:4:1] + 3 | export const dynamicParams = false 4 | export const fetchCache = 'force-no-store' - 5 | export const revalidate = 1 : ^^^^^^^^^^ + 5 | export const revalidate = 1 `---- - x "revalidate" is not compatible with `nextConfig.experimental.dynamicIO`. Please remove it. + x Route segment config "revalidate" is not compatible with `nextConfig.experimental.dynamicIO`. Please remove it. ,-[input.js:5:1] 4 | export const fetchCache = 'force-no-store' 5 | export const revalidate = 1 diff --git a/crates/next-custom-transforms/tests/errors/react-server-components/server-graph/metadata-conflict/output.stderr b/crates/next-custom-transforms/tests/errors/react-server-components/server-graph/metadata-conflict/output.stderr index a2731ea543f57..d7149bbf74cd7 100644 --- a/crates/next-custom-transforms/tests/errors/react-server-components/server-graph/metadata-conflict/output.stderr +++ b/crates/next-custom-transforms/tests/errors/react-server-components/server-graph/metadata-conflict/output.stderr @@ -1,7 +1,9 @@ x "metadata" and "generateMetadata" cannot be exported at the same time, please keep one of them. Read more: https://nextjs.org/docs/app/api-reference/file-conventions/metadata | | - ,-[input.js:3:1] + ,-[input.js:1:1] + 1 | export const metadata = {} + : ^^^^^^^^ 2 | 3 | export function generateMetadata() {} : ^^^^^^^^^^^^^^^^