Skip to main content

next_custom_transforms/transforms/
empty_gsp.rs

1use regex::Regex;
2use swc_core::{
3    common::{Span, Spanned},
4    ecma::{
5        ast::*,
6        visit::{Visit, VisitMut, VisitWith, visit_mut_pass},
7    },
8    quote,
9};
10
11// When Cache Components is enabled, an empty generateStaticParams is an error,
12// detected at runtime in buildAppStaticPaths. The throw there is framework
13// code, so it has no user frame to point at. For an app page (or
14// layout/default) that exports generateStaticParams, this transform emits a
15// factory whose error is anchored at the user's code, which the runtime throws
16// when it sees an empty result. The anchor is the most specific of: the `return
17// []` literal when statically detectable, the declaration, or the export
18// statement.
19#[derive(Debug)]
20pub struct EmptyGenerateStaticParams {
21    page_or_layout: Regex,
22}
23
24impl EmptyGenerateStaticParams {
25    pub fn new<I, S>(page_extensions: I) -> Self
26    where
27        I: IntoIterator<Item = S>,
28        S: AsRef<str>,
29    {
30        // `route` handlers can also export generateStaticParams, but an empty
31        // result is only an error for app pages (PPR), so they are excluded.
32        let mut result = String::from(r"[\\/](page|layout|default)\.");
33        let mut iter = page_extensions.into_iter();
34        if let Some(first) = iter.next() {
35            result.push('(');
36            result.push_str(&regex::escape(first.as_ref()));
37            for ext in iter {
38                result.push('|');
39                result.push_str(&regex::escape(ext.as_ref()));
40            }
41            result.push(')');
42        } else {
43            result.push_str("(ts|js)x?");
44        }
45        result.push('$');
46        Self {
47            page_or_layout: Regex::new(&result).unwrap(),
48        }
49    }
50
51    pub fn get_pass(&self, filepath: String) -> impl Pass + use<> {
52        visit_mut_pass(EmptyGenerateStaticParamsPass {
53            filepath,
54            page_or_layout: self.page_or_layout.clone(),
55        })
56    }
57}
58
59struct EmptyGenerateStaticParamsPass {
60    filepath: String,
61    page_or_layout: Regex,
62}
63
64// Counts a function's own return statements (not descending into nested
65// functions, whose returns aren't this function's) and remembers the span of an
66// empty array literal return.
67#[derive(Default)]
68struct ReturnCollector {
69    count: usize,
70    empty_array_span: Option<Span>,
71}
72
73impl Visit for ReturnCollector {
74    fn visit_return_stmt(&mut self, return_statement: &ReturnStmt) {
75        self.count += 1;
76        if let Some(argument) = &return_statement.arg
77            && let Expr::Array(array) = &**argument
78            && array.elems.is_empty()
79        {
80            self.empty_array_span = Some(array.span());
81        }
82    }
83
84    fn visit_arrow_expr(&mut self, _: &ArrowExpr) {}
85    fn visit_function(&mut self, _: &Function) {}
86}
87
88// Anchors at the `return []` literal only when it is the body's sole return, so
89// an empty result can only have come from it. With other returns present we
90// can't tell which produced the empty result, so this returns None and the
91// caller falls back to the declaration.
92fn find_error_anchor_in_body(body: &BlockStmt) -> Option<Span> {
93    let mut returns = ReturnCollector::default();
94    body.visit_with(&mut returns);
95    if returns.count == 1 {
96        return returns.empty_array_span;
97    }
98    None
99}
100
101// Finds the empty array literal anchor within an initializer expression, when
102// it is unambiguously the only return. Returns None for a computed result; the
103// caller falls back to the initializer itself.
104fn find_error_anchor_in_initializer(initializer: &Expr) -> Option<Span> {
105    match initializer {
106        Expr::Arrow(arrow) => match &*arrow.body {
107            BlockStmtOrExpr::BlockStmt(body) => find_error_anchor_in_body(body),
108            BlockStmtOrExpr::Expr(expr) => match &**expr {
109                Expr::Array(array) if array.elems.is_empty() => Some(array.span()),
110                _ => None,
111            },
112        },
113        Expr::Fn(function_expression) => function_expression
114            .function
115            .body
116            .as_ref()
117            .and_then(find_error_anchor_in_body),
118        _ => None,
119    }
120}
121
122// Finds the anchor for a declaration, if it declares `name` as a function or
123// variable. Prefers the empty array literal and falls back to the declaration
124// itself.
125fn find_error_anchor_in_declaration(declaration: &Decl, name: &str) -> Option<Span> {
126    match declaration {
127        Decl::Fn(function) if function.ident.sym == *name => Some(
128            function
129                .function
130                .body
131                .as_ref()
132                .and_then(find_error_anchor_in_body)
133                .unwrap_or_else(|| function.function.span()),
134        ),
135        Decl::Var(variable) => {
136            for declarator in &variable.decls {
137                if let Pat::Ident(ident) = &declarator.name
138                    && ident.id.sym == *name
139                    && let Some(initializer) = &declarator.init
140                {
141                    return Some(
142                        find_error_anchor_in_initializer(initializer)
143                            .unwrap_or_else(|| initializer.span()),
144                    );
145                }
146            }
147            None
148        }
149        _ => None,
150    }
151}
152
153// Finds the local identifier that an export specifier exposes as
154// `generateStaticParams` (`export { generateStaticParams }` or `export { x as
155// generateStaticParams }`).
156fn find_generate_static_params_export(specifier: &ExportSpecifier) -> Option<&Ident> {
157    match specifier {
158        ExportSpecifier::Named(ExportNamedSpecifier {
159            exported: Some(ModuleExportName::Ident(exported)),
160            orig: ModuleExportName::Ident(local),
161            ..
162        }) if exported.sym == "generateStaticParams" => Some(local),
163        ExportSpecifier::Named(ExportNamedSpecifier {
164            exported: None,
165            orig: ModuleExportName::Ident(local),
166            ..
167        }) if local.sym == "generateStaticParams" => Some(local),
168        _ => None,
169    }
170}
171
172// Finds the anchor for a local declaration named `name`, whether exported or
173// not.
174fn find_error_anchor_for_local_name(items: &[ModuleItem], name: &str) -> Option<Span> {
175    for item in items {
176        let declaration = match item {
177            ModuleItem::Stmt(Stmt::Decl(declaration)) => declaration,
178            ModuleItem::ModuleDecl(ModuleDecl::ExportDecl(export)) => &export.decl,
179            _ => continue,
180        };
181        if let Some(span) = find_error_anchor_in_declaration(declaration, name) {
182            return Some(span);
183        }
184    }
185    None
186}
187
188// Finds the span to anchor the error at, considering only exported
189// `generateStaticParams`. Handles `export function`/`export const`, `export { x
190// as generateStaticParams }`, and `export { ... } from '...'`.
191fn find_error_anchor(items: &[ModuleItem]) -> Option<Span> {
192    for item in items {
193        match item {
194            ModuleItem::ModuleDecl(ModuleDecl::ExportDecl(export)) => {
195                if let Some(span) =
196                    find_error_anchor_in_declaration(&export.decl, "generateStaticParams")
197                {
198                    return Some(span);
199                }
200            }
201            ModuleItem::ModuleDecl(ModuleDecl::ExportNamed(named)) => {
202                for specifier in &named.specifiers {
203                    if let Some(local) = find_generate_static_params_export(specifier) {
204                        // A re-export (`... from '...'`) declares the name in
205                        // another module, so anchor at the export statement.
206                        // Otherwise resolve the local declaration.
207                        if named.src.is_some() {
208                            return Some(specifier.span());
209                        }
210                        return Some(
211                            find_error_anchor_for_local_name(items, &local.sym)
212                                .unwrap_or_else(|| specifier.span()),
213                        );
214                    }
215                }
216            }
217            _ => {}
218        }
219    }
220    None
221}
222
223impl VisitMut for EmptyGenerateStaticParamsPass {
224    fn visit_mut_module_items(&mut self, items: &mut Vec<ModuleItem>) {
225        if !self.page_or_layout.is_match(&self.filepath) {
226            return;
227        }
228
229        let Some(anchor) = find_error_anchor(items) else {
230            return;
231        };
232
233        // `new Error(...)` spanned at the anchor so the stack maps back to the
234        // user's code.
235        let mut error = quote!(
236            "new Error('When using Cache Components, all `generateStaticParams` \
237                functions must return at least one result. This is to ensure \
238                that we can perform build-time validation that there is no \
239                other dynamic accesses that would cause a runtime error.\\n\\n\
240                Learn more: \
241                https://nextjs.org/docs/messages/empty-generate-static-params')"
242                as Expr
243        );
244        if let Expr::New(new_expr) = &mut error {
245            new_expr.span = anchor;
246        }
247
248        // The factory is named `generateStaticParams` so the frame reads as the
249        // user's function, and its span is the anchor for the same reason.
250        let mut factory = quote!(
251            "function generateStaticParams() {
252                return $error
253            }" as Expr,
254            error: Expr = error,
255        );
256        if let Expr::Fn(f) = &mut factory {
257            f.function.span = anchor;
258        }
259
260        items.push(quote!(
261            "export const __next_create_empty_gsp_error = $factory" as ModuleItem,
262            factory: Expr = factory,
263        ));
264    }
265}