Repository: vercel/next.js
Pull request: Resolve local variable references in error code SWC plugin

The error code plugin now resolves local variable references in error constructors to their string literal initializers. Previously, `const msg = 'Error text'; throw new Error(msg)` would produce `"%s"` as the error message, preventing it from being tracked in `errors.json`. A pre-pass `BindingCollector` now collects variable declarations before the transformation pass, enabling error code assignment for these patterns. This adds 50 new error codes to `errors.json`.

Variable references that still can't be resolved include imported constants (cross-module), function return values, object property access, and function parameters. These continue to produce `"%s"` as before.

Also fixes `rcstr!()` to `.into()`. The `rcstr!` macro was introduced by a bulk Turbopack refactor (#80552), but was never available as a dependency of this standalone crate. `turbo-rcstr` can't be added as a dependency because it uses workspace-level dependency references that aren't resolvable from this standalone crate's `Cargo.lock`.

Changed files:
filename: crates/next-error-code-swc-plugin/src/lib.rs
patch: @@ -8,6 +8,23 @@ use swc_core::{
 
 pub struct TransformVisitor {
     errors: FxHashMap<String, String>,
+    resolved_bindings: FxHashMap<Id, String>,
+}
+
+struct BindingCollector {
+    bindings: FxHashMap<Id, String>,
+}
+
+impl Visit for BindingCollector {
+    fn visit_var_declarator(&mut self, n: &VarDeclarator) {
+        if let Pat::Ident(binding_ident) = &n.name {
+            if let Some(init) = &n.init {
+                let resolved = stringify_new_error_arg(init, &self.bindings);
+                self.bindings.insert(binding_ident.to_id(), resolved);
+            }
+        }
+        n.visit_children_with(self);
+    }
 }
 
 #[derive(serde::Serialize)]
@@ -59,7 +76,7 @@ fn is_error_class_name(name: &str) -> bool {
 }
 
 // Get the string representation of the first argument of `new Error(...)`
-fn stringify_new_error_arg(expr: &Expr) -> String {
+fn stringify_new_error_arg(expr: &Expr, bindings: &FxHashMap<Id, String>) -> String {
     match expr {
         Expr::Lit(lit) => match lit {
             Lit::Str(str_lit) => str_lit.value.to_string(),
@@ -73,28 +90,39 @@ fn stringify_new_error_arg(expr: &Expr) -> String {
             for (_i, quasi) in tpl.quasis.iter().enumerate() {
                 result.push_str(&quasi.raw);
                 if let Some(expr) = expr_iter.next() {
-                    result.push_str(&stringify_new_error_arg(expr));
+                    result.push_str(&stringify_new_error_arg(expr, bindings));
                 }
             }
             result
         }
 
         Expr::Bin(bin_expr) => {
-            // Assume binary expression is always add for two strings
             format!(
                 "{}{}",
-                stringify_new_error_arg(&bin_expr.left),
-                stringify_new_error_arg(&bin_expr.right)
+                stringify_new_error_arg(&bin_expr.left, bindings),
+                stringify_new_error_arg(&bin_expr.right, bindings)
             )
         }
 
+        Expr::Ident(ident) => bindings
+            .get(&ident.to_id())
+            .cloned()
+            .unwrap_or_else(|| "%s".to_string()),
+
         _ => "%s".to_string(),
     }
 }
 
-impl TransformVisitor {}
-
 impl VisitMut for TransformVisitor {
+    fn visit_mut_program(&mut self, program: &mut Program) {
+        let mut collector = BindingCollector {
+            bindings: FxHashMap::default(),
+        };
+        program.visit_with(&mut collector);
+        self.resolved_bindings = collector.bindings;
+        program.visit_mut_children_with(self);
+    }
+
     fn visit_mut_expr(&mut self, expr: &mut Expr) {
         let mut error_message: Option<String> = None;
 
@@ -110,7 +138,10 @@ impl VisitMut for TransformVisitor {
                     if let Some(args) = &new_expr.args {
                         if let Some(first_arg) = args.first() {
                             new_error_expr = Some(new_expr.clone());
-                            error_message = Some(stringify_new_error_arg(&first_arg.expr));
+                            error_message = Some(stringify_new_error_arg(
+                                &first_arg.expr,
+                                &self.resolved_bindings,
+                            ));
                         }
                     }
                 }
@@ -120,7 +151,10 @@ impl VisitMut for TransformVisitor {
                 Callee::Expr(expr) => match &**expr {
                     Expr::Ident(ident) if is_error_class_name(ident.sym.as_str()) => {
                         if let Some(first_arg) = call_expr.args.first() {
-                            error_message = Some(stringify_new_error_arg(&first_arg.expr));
+                            error_message = Some(stringify_new_error_arg(
+                                &first_arg.expr,
+                                &self.resolved_bindings,
+                            ));
 
                             // For `Error(...)`, we convert it to `new Error(...)` to make the
                             // following code simpler
@@ -185,11 +219,11 @@ impl VisitMut for TransformVisitor {
                 callee: Callee::Expr(Box::new(Expr::Member(MemberExpr {
                     span: new_error_expr.span,
                     obj: Box::new(Expr::Ident(Ident::new(
-                        rcstr!("Object"),
+                        "Object".into(),
                         new_error_expr.span,
                         Default::default(),
                     ))),
-                    prop: MemberProp::Ident(rcstr!("defineProperty")),
+                    prop: MemberProp::Ident("defineProperty".into()),
                 }))), // Object.defineProperty(
                 args: vec![
                     ExprOrSpread {
@@ -200,7 +234,7 @@ impl VisitMut for TransformVisitor {
                         spread: None,
                         expr: Box::new(Expr::Lit(Lit::Str(Str {
                             span: new_error_expr.span,
-                            value: rcstr!("__NEXT_ERROR_CODE"),
+                            value: "__NEXT_ERROR_CODE".into(),
                             raw: None,
                         }))), // "__NEXT_ERROR_CODE"
                     },
@@ -210,22 +244,22 @@ impl VisitMut for TransformVisitor {
                             span: new_error_expr.span,
                             props: vec![
                                 PropOrSpread::Prop(Box::new(Prop::KeyValue(KeyValueProp {
-                                    key: PropName::Ident(rcstr!("value")),
+                                    key: PropName::Ident("value".into()),
                                     value: Box::new(Expr::Lit(Lit::Str(Str {
                                         span: new_error_expr.span,
                                         value: code.into(),
                                         raw: None,
                                     }))),
                                 }))),
                                 PropOrSpread::Prop(Box::new(Prop::KeyValue(KeyValueProp {
-                                    key: PropName::Ident(rcstr!("enumerable")),
+                                    key: PropName::Ident("enumerable".into()),
                                     value: Box::new(Expr::Lit(Lit::Bool(Bool {
                                         span: new_error_expr.span,
                                         value: false,
                                     }))),
                                 }))),
                                 PropOrSpread::Prop(Box::new(Prop::KeyValue(KeyValueProp {
-                                    key: PropName::Ident(rcstr!("configurable")),
+                                    key: PropName::Ident("configurable".into()),
                                     value: Box::new(Expr::Lit(Lit::Bool(Bool {
                                         span: new_error_expr.span,
                                         value: true,
@@ -252,7 +286,10 @@ pub fn process_transform(
     let errors: FxHashMap<String, String> = serde_json::from_str(&errors_json)
         .unwrap_or_else(|e| panic!("failed to parse errors.json: {}", e));
 
-    let mut visitor = TransformVisitor { errors };
+    let mut visitor = TransformVisitor {
+        errors,
+        resolved_bindings: FxHashMap::default(),
+    };
 
     visitor.visit_mut_program(&mut program);
     program
@@ -270,7 +307,12 @@ test_inline!(
                 "5".to_string(),
                 "Pattern should define hostname but found\n%s".to_string()
             ),
+            (
+                "6".to_string(),
+                "This is an extracted error message.".to_string()
+            ),
         ]),
+        resolved_bindings: FxHashMap::default(),
     }),
     realistic_api_handler,
     // Input codes
@@ -302,6 +344,12 @@ function test3() {
 function test4() {
     throw new Error();
     throw new Error("Pattern should define hostname but found\n" + JSON.stringify(pattern));
+}
+
+const extractedErrorMessage = 'This is an extracted error message.';
+
+function test5() {
+    throw new Error(extractedErrorMessage);
 }"#,
     // Output codes after transformed with plugin
     r#"
@@ -349,5 +397,13 @@ function test4() {
         configurable: true
     });
 }
+const extractedErrorMessage = 'This is an extracted error message.';
+function test5() {
+    throw Object.defineProperty(new Error(extractedErrorMessage), "__NEXT_ERROR_CODE", {
+        value: "E6",
+        enumerable: false,
+        configurable: true
+    });
+}
 "#
 );

filename: packages/next/errors.json
patch: @@ -995,5 +995,55 @@
   "994": "InitialRSCPayload does not match the expected shape during instant validation.",
   "995": "Missing segment data: %s",
   "996": "Unexpected state while traversing route tree: %s",
-  "997": "%s: \\`unstable_instant\\` with mode 'runtime' is not supported in root layouts."
+  "997": "%s: \\`unstable_instant\\` with mode 'runtime' is not supported in root layouts.",
+  "998": "The \"exportPathMap\" configuration cannot be used with the \"app\" directory. Please use generateStaticParams() instead.",
+  "999": "[lightningcss-loader]: experimental.useLightningcss does not work with postcss plugins. Please remove 'useLightningcss: true' from your configuration.",
+  "1000": "A \"use cache\" with zero \\`revalidate\\` is nested inside another \"use cache\" that has no explicit \\`cacheLife\\`, which is not allowed during prerendering. Add \\`cacheLife()\\` to the outer \\`\"use cache\"\\` to choose whether it should be prerendered (with non-zero \\`revalidate\\`) or remain dynamic (with zero \\`revalidate\\`). Read more: https://nextjs.org/docs/messages/nested-use-cache-no-explicit-cachelife",
+  "1001": "\"use cache: private\" must not be used within \"use cache\". It can only be nested inside of another \"use cache: private\".",
+  "1002": "%s;401",
+  "1003": "Failed to set Next.js data cache for %s%s, items over 2MB can not be cached (%s bytes)",
+  "1004": "Invalid value returned from getStaticPaths in %s. Received %s Expected: { paths: [], fallback: boolean }\\nSee here for more info: https://nextjs.org/docs/messages/invalid-getstaticpaths-value",
+  "1005": "revalidate: 0 configured %s",
+  "1006": "Route %s used \\`import('next/root-params').%s()\\` outside of a Server Component. This is not allowed.",
+  "1007": "Can't resolve %s%s%s/page",
+  "1008": "\"use cache: private\" cannot be used outside of a request context.",
+  "1009": "A \"use cache\" with short \\`expire\\` (under 5 minutes) is nested inside another \"use cache\" that has no explicit \\`cacheLife\\`, which is not allowed during prerendering. Add \\`cacheLife()\\` to the outer \\`\"use cache\"\\` to choose whether it should be prerendered (with longer \\`expire\\`) or remain dynamic (with short \\`expire\\`). Read more: https://nextjs.org/docs/messages/nested-use-cache-no-explicit-cachelife",
+  "1010": "Conflicting app and page file%s found, please remove the conflicting files to continue:\\n",
+  "1011": "The Edge Function \"pagesVAR_DEFINITION_PAGE\" must export a \\`default\\` function",
+  "1012": "Route %s used \\`import('next/root-params').%s()\\` inside \\`\"use cache\"\\` or \\`unstable_cache\\`. Support for this API inside cache scopes is planned for a future version of Next.js.",
+  "1013": "Next.js can't recognize the exported \\`config\\` field in %s:\n%s%s.\nRead More - https://nextjs.org/docs/messages/invalid-page-config",
+  "1014": "\\`import('next/root-params').%s()\\` was used inside a Server Action. This is not supported. Functions from 'next/root-params' can only be called in the context of a route.",
+  "1015": "Page %s provided runtime 'edge', the edge runtime for rendering is currently experimental. Use runtime 'experimental-edge' instead.",
+  "1016": "\"use cache: private\" must not be used within \\`unstable_cache()\\`.",
+  "1017": "`headers` must not be used within a client component. Next.js should be preventing `headers` from being included in client components statically, but did not in this case.",
+  "1018": "webpack build failed: found page without a React Component as default export in pages/%s%s%s\\n\\nSee https://nextjs.org/docs/messages/page-without-valid-component for more info.",
+  "1019": "%s;403",
+  "1020": "\"use cache: private\" must not be used within a client component. Next.js should be preventing \"use cache: private\" from being allowed in client components statically, but did not in this case.",
+  "1021": "Internal Error: do not use legacy react-dom/server APIs. If you encountered this error, please open an issue on the Next.js repo.",
+  "1022": "invalid hex: \\`\\`",
+  "1023": "%s\n%s",
+  "1024": "%s doesn't have a root layout. ",
+  "1025": "\"%s.getInitialProps()\" should resolve to an object. But found \"%s\" instead.",
+  "1026": "Unknown \"loader\" found in \"next.config.js\". Expected: %s. Received: %sdefault",
+  "1027": "No router instance found. you should only use \"next/router\" inside the client side of your app. https://nextjs.org/docs/messages/no-router-instance",
+  "1028": "You should not access 'res' after getServerSideProps resolves.\\nRead more: https://nextjs.org/docs/messages/gssp-no-mutating-res",
+  "1029": "The \"target\" property is no longer supported in next.config.js.\\nSee more info here https://nextjs.org/docs/messages/deprecated-target-config",
+  "1030": "Duration is too long to express as float64: %s%s",
+  "1031": "Route segment config is not allowed in Proxy file at \"%s\". Proxy always runs on Node.js runtime. Learn more: https://nextjs.org/docs/messages/middleware-to-proxy",
+  "1032": "Missing workStore in \\`import('next/root-params').%s()\\`",
+  "1033": "[lightningcss-loader]: options.implementation.transformCss must be an 'lightningcss' transform function. Received %s",
+  "1034": "The \\`fallback\\` key must be returned from getStaticPaths in %s.\\nExpected: { paths: [], fallback: boolean }\\nSee here for more info: https://nextjs.org/docs/messages/invalid-getstaticpaths-value",
+  "1035": "\"%s.getInitialProps()\" is defined as an instance method - visit https://nextjs.org/docs/messages/get-initial-props-as-an-instance-method for more information.",
+  "1036": "Malformed PostCSS Configuration",
+  "1037": "`cookies` must not be used within a Client Component. Next.js should be preventing `cookies` from being included in Client Components statically, but did not in this case.",
+  "1038": "\"use cache\" must not be used within a client component. Next.js should be preventing \"use cache\" from being allowed in client components statically, but did not in this case.",
+  "1039": "\"%s.getInitialProps()\" should resolve to an object with a \"html\" prop set with a valid html string",
+  "1040": "Process image \"%s\" failed: %s",
+  "1041": "%s;404",
+  "1042": "Conflicting app and page file%s found, please remove the conflicting files to continue:",
+  "1043": "Route %s used \\`import('next/root-params').%s()\\` inside a Route Handler. Support for this API in Route Handlers is planned for a future version of Next.js.",
+  "1044": "No router instance found.\nYou should only use \"next/router\" on the client side of your app.\n",
+  "1045": "LRUCache: calculateSize returned %s%s, but size must be > 0. Items with size 0 would never be evicted, causing unbounded cache growth.",
+  "1046": "`draftMode` must not be used within a Client Component. Next.js should be preventing `draftMode` from being included in Client Components statically, but did not in this case.",
+  "1047": "Extra keys returned from getStaticPaths in %s (%s) Expected: { paths: [], fallback: boolean }\\nSee here for more info: https://nextjs.org/docs/messages/invalid-getstaticpaths-value"
 }