Skip to content

Commit bc3e597

Browse files
authored
Fix export statement validations for app router pages (#75278)
Fixes all issues related to the current export statement validation for app router pages, as highlighted in #75277.
1 parent eadb566 commit bc3e597

File tree

6 files changed

+112
-109
lines changed

6 files changed

+112
-109
lines changed

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/next-custom-transforms/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ easy-error = "1.0.0"
1919
either = "1"
2020
fxhash = "0.2.1"
2121
hex = "0.4.3"
22+
indexmap = { workspace = true }
2223
indoc = { workspace = true }
2324
once_cell = { workspace = true }
2425
pathdiff = { workspace = true }

crates/next-custom-transforms/src/transforms/react_server_components.rs

Lines changed: 73 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
use std::{collections::HashMap, path::PathBuf, rc::Rc, sync::Arc};
22

3+
use indexmap::IndexMap;
34
use once_cell::sync::Lazy;
45
use regex::Regex;
56
use serde::Deserialize;
@@ -75,7 +76,7 @@ enum RSCErrorKind {
7576
NextRscErrReactApi((String, Span)),
7677
NextRscErrErrorFileServerComponent(Span),
7778
NextRscErrClientMetadataExport((String, Span)),
78-
NextRscErrConflictMetadataExport(Span),
79+
NextRscErrConflictMetadataExport((Span, Span)),
7980
NextRscErrInvalidApi((String, Span)),
8081
NextRscErrDeprecatedApi((String, String, Span)),
8182
NextSsrDynamicFalseNotAllowed(Span),
@@ -84,6 +85,7 @@ enum RSCErrorKind {
8485

8586
enum InvalidExportKind {
8687
General,
88+
Metadata,
8789
DynamicIoSegment,
8890
}
8991

@@ -233,18 +235,18 @@ impl<C: Comments> ReactServerComponents<C> {
233235
/// Consolidated place to parse, generate error messages for the RSC parsing
234236
/// errors.
235237
fn report_error(app_dir: &Option<PathBuf>, filepath: &str, error_kind: RSCErrorKind) {
236-
let (msg, span) = match error_kind {
238+
let (msg, spans) = match error_kind {
237239
RSCErrorKind::RedundantDirectives(span) => (
238240
"It's not possible to have both `use client` and `use server` directives in the \
239241
same file."
240242
.to_string(),
241-
span,
243+
vec![span],
242244
),
243245
RSCErrorKind::NextRscErrClientDirective(span) => (
244246
"The \"use client\" directive must be placed before other expressions. Move it to \
245247
the top of the file to resolve this issue."
246248
.to_string(),
247-
span,
249+
vec![span],
248250
),
249251
RSCErrorKind::NextRscErrServerImport((source, span)) => {
250252
let msg = match source.as_str() {
@@ -255,7 +257,7 @@ fn report_error(app_dir: &Option<PathBuf>, filepath: &str, error_kind: RSCErrorK
255257
_ => 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"#)
256258
};
257259

258-
(msg, span)
260+
(msg, vec![span])
259261
}
260262
RSCErrorKind::NextRscErrClientImport((source, span)) => {
261263
let is_app_dir = app_dir
@@ -274,7 +276,7 @@ fn report_error(app_dir: &Option<PathBuf>, filepath: &str, error_kind: RSCErrorK
274276
} else {
275277
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")
276278
};
277-
(msg, span)
279+
(msg, vec![span])
278280
}
279281
RSCErrorKind::NextRscErrReactApi((source, span)) => {
280282
let msg = if source == "Component" {
@@ -283,46 +285,46 @@ fn report_error(app_dir: &Option<PathBuf>, filepath: &str, error_kind: RSCErrorK
283285
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")
284286
};
285287

286-
(msg,span)
288+
(msg, vec![span])
287289
},
288290
RSCErrorKind::NextRscErrErrorFileServerComponent(span) => {
289291
(
290292
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"),
291-
span
293+
vec![span]
292294
)
293295
},
294296
RSCErrorKind::NextRscErrClientMetadataExport((source, span)) => {
295-
(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)
297+
(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])
296298
},
297-
RSCErrorKind::NextRscErrConflictMetadataExport(span) => (
299+
RSCErrorKind::NextRscErrConflictMetadataExport((span1, span2)) => (
298300
"\"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(),
299-
span
301+
vec![span1, span2]
300302
),
301303
//NEXT_RSC_ERR_INVALID_API
302304
RSCErrorKind::NextRscErrInvalidApi((source, span)) => (
303-
format!("\"{source}\" is not supported in app/. Read more: https://nextjs.org/docs/app/building-your-application/data-fetching\n\n"), span
305+
format!("\"{source}\" is not supported in app/. Read more: https://nextjs.org/docs/app/building-your-application/data-fetching\n\n"), vec![span]
304306
),
305307
RSCErrorKind::NextRscErrDeprecatedApi((source, item, span)) => match (&*source, &*item) {
306308
("next/server", "ImageResponse") => (
307309
"ImageResponse moved from \"next/server\" to \"next/og\" since Next.js 14, please \
308310
import from \"next/og\" instead"
309311
.to_string(),
310-
span,
312+
vec![span],
311313
),
312-
_ => (format!("\"{source}\" is deprecated."), span),
314+
_ => (format!("\"{source}\" is deprecated."), vec![span]),
313315
},
314316
RSCErrorKind::NextSsrDynamicFalseNotAllowed(span) => (
315317
"`ssr: false` is not allowed with `next/dynamic` in Server Components. Please move it into a client component."
316318
.to_string(),
317-
span,
319+
vec![span],
318320
),
319321
RSCErrorKind::NextRscErrIncompatibleDynamicIoSegment(span, segment) => (
320-
format!("\"{}\" is not compatible with `nextConfig.experimental.dynamicIO`. Please remove it.", segment),
321-
span,
322+
format!("Route segment config \"{}\" is not compatible with `nextConfig.experimental.dynamicIO`. Please remove it.", segment),
323+
vec![span],
322324
),
323325
};
324326

325-
HANDLER.with(|handler| handler.struct_span_err(span, msg.as_str()).emit())
327+
HANDLER.with(|handler| handler.struct_span_err(spans, msg.as_str()).emit())
326328
}
327329

328330
/// Collects top level directives and imports
@@ -752,30 +754,31 @@ impl ReactServerComponentValidator {
752754
let is_layout_or_page = RE.is_match(&self.filepath);
753755

754756
if is_layout_or_page {
755-
let mut span = DUMMY_SP;
756-
let mut invalid_export_name = String::new();
757-
let mut invalid_exports: HashMap<String, InvalidExportKind> = HashMap::new();
758-
759-
let mut invalid_exports_matcher = |export_name: &str| -> bool {
760-
match export_name {
761-
"getServerSideProps" | "getStaticProps" | "generateMetadata" | "metadata" => {
762-
invalid_exports.insert(export_name.to_string(), InvalidExportKind::General);
763-
true
757+
let mut possibly_invalid_exports: IndexMap<String, (InvalidExportKind, Span)> =
758+
IndexMap::new();
759+
760+
let mut collect_possibly_invalid_exports =
761+
|export_name: &str, span: &Span| match export_name {
762+
"getServerSideProps" | "getStaticProps" => {
763+
possibly_invalid_exports
764+
.insert(export_name.to_string(), (InvalidExportKind::General, *span));
765+
}
766+
"generateMetadata" | "metadata" => {
767+
possibly_invalid_exports.insert(
768+
export_name.to_string(),
769+
(InvalidExportKind::Metadata, *span),
770+
);
764771
}
765772
"dynamicParams" | "dynamic" | "fetchCache" | "runtime" | "revalidate" => {
766773
if self.dynamic_io_enabled {
767-
invalid_exports.insert(
774+
possibly_invalid_exports.insert(
768775
export_name.to_string(),
769-
InvalidExportKind::DynamicIoSegment,
776+
(InvalidExportKind::DynamicIoSegment, *span),
770777
);
771-
true
772-
} else {
773-
false
774778
}
775779
}
776-
_ => false,
777-
}
778-
};
780+
_ => (),
781+
};
779782

780783
for export in &module.body {
781784
match export {
@@ -784,35 +787,23 @@ impl ReactServerComponentValidator {
784787
if let ExportSpecifier::Named(named) = specifier {
785788
match &named.orig {
786789
ModuleExportName::Ident(i) => {
787-
if invalid_exports_matcher(&i.sym) {
788-
span = named.span;
789-
invalid_export_name = i.sym.to_string();
790-
}
790+
collect_possibly_invalid_exports(&i.sym, &named.span);
791791
}
792792
ModuleExportName::Str(s) => {
793-
if invalid_exports_matcher(&s.value) {
794-
span = named.span;
795-
invalid_export_name = s.value.to_string();
796-
}
793+
collect_possibly_invalid_exports(&s.value, &named.span);
797794
}
798795
}
799796
}
800797
}
801798
}
802799
ModuleItem::ModuleDecl(ModuleDecl::ExportDecl(export)) => match &export.decl {
803800
Decl::Fn(f) => {
804-
if invalid_exports_matcher(&f.ident.sym) {
805-
span = f.ident.span;
806-
invalid_export_name = f.ident.sym.to_string();
807-
}
801+
collect_possibly_invalid_exports(&f.ident.sym, &f.ident.span);
808802
}
809803
Decl::Var(v) => {
810804
for decl in &v.decls {
811805
if let Pat::Ident(i) = &decl.name {
812-
if invalid_exports_matcher(&i.sym) {
813-
span = i.span;
814-
invalid_export_name = i.sym.to_string();
815-
}
806+
collect_possibly_invalid_exports(&i.sym, &i.span);
816807
}
817808
}
818809
}
@@ -822,59 +813,56 @@ impl ReactServerComponentValidator {
822813
}
823814
}
824815

825-
// Assert invalid metadata and generateMetadata exports.
826-
let has_gm_export = invalid_exports.contains_key("generateMetadata");
827-
let has_metadata_export = invalid_exports.contains_key("metadata");
828-
829-
for (export_name, kind) in &invalid_exports {
816+
for (export_name, (kind, span)) in &possibly_invalid_exports {
830817
match kind {
831818
InvalidExportKind::DynamicIoSegment => {
832819
report_error(
833820
&self.app_dir,
834821
&self.filepath,
835822
RSCErrorKind::NextRscErrIncompatibleDynamicIoSegment(
836-
span,
823+
*span,
837824
export_name.clone(),
838825
),
839826
);
840827
}
841-
InvalidExportKind::General => {
828+
InvalidExportKind::Metadata => {
842829
// Client entry can't export `generateMetadata` or `metadata`.
843-
if is_client_entry {
844-
if has_gm_export || has_metadata_export {
845-
report_error(
846-
&self.app_dir,
847-
&self.filepath,
848-
RSCErrorKind::NextRscErrClientMetadataExport((
849-
invalid_export_name.clone(),
850-
span,
851-
)),
852-
);
853-
}
854-
} else {
855-
// Server entry can't export `generateMetadata` and `metadata` together.
856-
if has_gm_export && has_metadata_export {
857-
report_error(
858-
&self.app_dir,
859-
&self.filepath,
860-
RSCErrorKind::NextRscErrConflictMetadataExport(span),
861-
);
862-
}
863-
}
864-
// Assert `getServerSideProps` and `getStaticProps` exports.
865-
if invalid_export_name == "getServerSideProps"
866-
|| invalid_export_name == "getStaticProps"
830+
if is_client_entry
831+
&& (export_name == "generateMetadata" || export_name == "metadata")
867832
{
868833
report_error(
869834
&self.app_dir,
870835
&self.filepath,
871-
RSCErrorKind::NextRscErrInvalidApi((
872-
invalid_export_name.clone(),
873-
span,
836+
RSCErrorKind::NextRscErrClientMetadataExport((
837+
export_name.clone(),
838+
*span,
874839
)),
875840
);
876841
}
842+
// Server entry can't export `generateMetadata` and `metadata` together,
843+
// which is handled separately below.
877844
}
845+
InvalidExportKind::General => {
846+
report_error(
847+
&self.app_dir,
848+
&self.filepath,
849+
RSCErrorKind::NextRscErrInvalidApi((export_name.clone(), *span)),
850+
);
851+
}
852+
}
853+
}
854+
855+
// Server entry can't export `generateMetadata` and `metadata` together.
856+
if !is_client_entry {
857+
let export1 = possibly_invalid_exports.get("generateMetadata");
858+
let export2 = possibly_invalid_exports.get("metadata");
859+
860+
if let (Some((_, span1)), Some((_, span2))) = (export1, export2) {
861+
report_error(
862+
&self.app_dir,
863+
&self.filepath,
864+
RSCErrorKind::NextRscErrConflictMetadataExport((*span1, *span2)),
865+
);
878866
}
879867
}
880868
}

crates/next-custom-transforms/tests/errors/react-server-components/client-graph/multiple/output.stderr

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,19 @@
1-
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://
1+
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/
2+
| docs/app/api-reference/directives/use-client
3+
|
4+
|
5+
,-[input.js:1:1]
6+
1 | export const metadata = {}
7+
: ^^^^^^^^
8+
`----
9+
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://
210
| nextjs.org/docs/app/api-reference/directives/use-client
311
|
412
|
5-
,-[input.js:5:1]
6-
4 |
7-
5 | export function getServerSideProps() {}
8-
: ^^^^^^^^^^^^^^^^^^
13+
,-[input.js:3:1]
14+
2 |
15+
3 | export function generateMetadata() {}
16+
: ^^^^^^^^^^^^^^^^
917
`----
1018
x "getServerSideProps" is not supported in app/. Read more: https://nextjs.org/docs/app/building-your-application/data-fetching
1119
|

crates/next-custom-transforms/tests/errors/react-server-components/server-graph/dynamic-io/output.stderr

Lines changed: 21 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,31 @@
1-
x "dynamicParams" is not compatible with `nextConfig.experimental.dynamicIO`. Please remove it.
2-
,-[input.js:5:1]
3-
4 | export const fetchCache = 'force-no-store'
4-
5 | export const revalidate = 1
5-
: ^^^^^^^^^^
1+
x Route segment config "runtime" is not compatible with `nextConfig.experimental.dynamicIO`. Please remove it.
2+
,-[input.js:1:1]
3+
1 | export const runtime = 'edge'
4+
: ^^^^^^^
5+
2 | export const dynamic = 'force-dynamic'
66
`----
7-
x "dynamic" is not compatible with `nextConfig.experimental.dynamicIO`. Please remove it.
8-
,-[input.js:5:1]
9-
4 | export const fetchCache = 'force-no-store'
10-
5 | export const revalidate = 1
11-
: ^^^^^^^^^^
7+
x Route segment config "dynamic" is not compatible with `nextConfig.experimental.dynamicIO`. Please remove it.
8+
,-[input.js:2:1]
9+
1 | export const runtime = 'edge'
10+
2 | export const dynamic = 'force-dynamic'
11+
: ^^^^^^^
12+
3 | export const dynamicParams = false
1213
`----
13-
x "fetchCache" is not compatible with `nextConfig.experimental.dynamicIO`. Please remove it.
14-
,-[input.js:5:1]
14+
x Route segment config "dynamicParams" is not compatible with `nextConfig.experimental.dynamicIO`. Please remove it.
15+
,-[input.js:3:1]
16+
2 | export const dynamic = 'force-dynamic'
17+
3 | export const dynamicParams = false
18+
: ^^^^^^^^^^^^^
1519
4 | export const fetchCache = 'force-no-store'
16-
5 | export const revalidate = 1
17-
: ^^^^^^^^^^
1820
`----
19-
x "runtime" is not compatible with `nextConfig.experimental.dynamicIO`. Please remove it.
20-
,-[input.js:5:1]
21+
x Route segment config "fetchCache" is not compatible with `nextConfig.experimental.dynamicIO`. Please remove it.
22+
,-[input.js:4:1]
23+
3 | export const dynamicParams = false
2124
4 | export const fetchCache = 'force-no-store'
22-
5 | export const revalidate = 1
2325
: ^^^^^^^^^^
26+
5 | export const revalidate = 1
2427
`----
25-
x "revalidate" is not compatible with `nextConfig.experimental.dynamicIO`. Please remove it.
28+
x Route segment config "revalidate" is not compatible with `nextConfig.experimental.dynamicIO`. Please remove it.
2629
,-[input.js:5:1]
2730
4 | export const fetchCache = 'force-no-store'
2831
5 | export const revalidate = 1

0 commit comments

Comments
 (0)