Repository: vercel/next.js
Pull request: cleanup(turbopack) Show users an error message when Webpack loaders have a glob in only the extension

This PR adds validation for webpack loader rules in Next.js configuration to prevent a common issue where users define wildcard extensions without corresponding wildcard patterns in the `as` option. This situation causes all modules to be transformed into the same name, resulting in duplicate module errors.

The changes:
- Add a new `InvalidLoaderRuleError` issue type that provides clear error messages when this misconfiguration is detected
- Check if an extension contains a wildcard (`*`) but the `rename_as` option does not
- Display helpful error messages with links to documentation when this issue is found
- Update the `webpack_rules` function to accept the project path for error reporting

This validation helps users avoid confusing duplicate module errors by providing clear guidance on how to properly configure webpack loaders in Next.js.

![image.png](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/IhNrutw2qOTgYcgE6MlR/6bae20b6-34b0-4643-9a5c-f580ba32c550.png)



Closes PACK-4988

Changed files:
filename: crates/next-core/src/next_config.rs
patch: @@ -9,11 +9,15 @@ use turbo_tasks::{
     trace::TraceRawVcs,
 };
 use turbo_tasks_env::EnvMap;
+use turbo_tasks_fs::FileSystemPath;
 use turbopack::module_options::{
     ConditionItem, ConditionPath, LoaderRuleItem, OptionWebpackRules,
     module_options_context::{MdxTransformOptions, OptionWebpackConditions},
 };
-use turbopack_core::resolve::ResolveAliasMap;
+use turbopack_core::{
+    issue::{Issue, IssueExt, IssueStage, OptionStyledString, StyledString},
+    resolve::ResolveAliasMap,
+};
 use turbopack_ecmascript::{OptionTreeShaking, TreeShakingMode};
 use turbopack_ecmascript_plugins::transform::{
     emotion::EmotionTransformConfig, relay::RelayConfig,
@@ -1112,6 +1116,62 @@ pub struct OptionServerActions(Option<ServerActions>);
 #[turbo_tasks::value(transparent)]
 pub struct OptionJsonValue(pub Option<serde_json::Value>);
 
+#[turbo_tasks::value(shared)]
+struct InvalidLoaderRuleError {
+    ext: RcStr,
+    rename_as: Option<RcStr>,
+    config_file_path: FileSystemPath,
+}
+
+#[turbo_tasks::value_impl]
+impl Issue for InvalidLoaderRuleError {
+    #[turbo_tasks::function]
+    async fn file_path(self: turbo_tasks::Vc<Self>) -> Result<Vc<FileSystemPath>> {
+        Ok(self.await?.config_file_path.clone().cell())
+    }
+
+    #[turbo_tasks::function]
+    fn stage(self: turbo_tasks::Vc<Self>) -> Vc<IssueStage> {
+        IssueStage::Config.cell()
+    }
+
+    #[turbo_tasks::function]
+    async fn title(self: turbo_tasks::Vc<Self>) -> Result<Vc<StyledString>> {
+        Ok(StyledString::Text(
+            format!(
+                "Invalid loader rule for extension: {}",
+                self.await?.ext.as_str()
+            )
+            .into(),
+        )
+        .cell())
+    }
+
+    #[turbo_tasks::function]
+    async fn description(self: turbo_tasks::Vc<Self>) -> Result<Vc<OptionStyledString>> {
+        Ok(Vc::cell(Some(StyledString::Stack(vec![
+            StyledString::Text(
+                format!(
+                    "The extension {} contains a wildcard, but the `as` option does not: {}",
+                    self.await?.ext.as_str(),
+                    self.await?
+                        .rename_as
+                        .as_ref()
+                        .map(|r| r.as_str())
+                        .unwrap_or("")
+                )
+                .into(),
+            ),
+            StyledString::Text(
+                "Check out the documentation here for more information:".into(),
+            ),
+            StyledString::Text(
+                "https://nextjs.org/docs/app/api-reference/config/next-config-js/turbopack#configuring-webpack-loaders".into(),
+            ),
+        ]).resolved_cell())))
+    }
+}
+
 #[turbo_tasks::value_impl]
 impl NextConfig {
     #[turbo_tasks::function]
@@ -1203,12 +1263,16 @@ impl NextConfig {
     }
 
     #[turbo_tasks::function]
-    pub fn webpack_rules(&self, active_conditions: Vec<RcStr>) -> Vc<OptionWebpackRules> {
+    pub async fn webpack_rules(
+        &self,
+        active_conditions: Vec<RcStr>,
+        project_path: FileSystemPath,
+    ) -> Result<Vc<OptionWebpackRules>> {
         let Some(turbo_rules) = self.turbopack.as_ref().and_then(|t| t.rules.as_ref()) else {
-            return Vc::cell(None);
+            return Ok(Vc::cell(None));
         };
         if turbo_rules.is_empty() {
-            return Vc::cell(None);
+            return Ok(Vc::cell(None));
         }
         let active_conditions = active_conditions.into_iter().collect::<FxHashSet<_>>();
         let mut rules = FxIndexMap::default();
@@ -1271,6 +1335,22 @@ impl NextConfig {
                     if let FindRuleResult::Found(RuleConfigItemOptions { loaders, rename_as }) =
                         find_rule(rule, &active_conditions)
                     {
+                        // If the extension contains a wildcard, and the rename_as does not,
+                        // emit an issue to prevent users from encountering duplicate module names.
+                        if ext.contains("*") && rename_as.as_ref().is_some_and(|r| !r.contains("*"))
+                        {
+                            let config_file_path =
+                                project_path.join(&format!("./{}", self.config_file_name))?;
+
+                            InvalidLoaderRuleError {
+                                ext: ext.clone(),
+                                config_file_path,
+                                rename_as: rename_as.clone(),
+                            }
+                            .resolved_cell()
+                            .emit();
+                        }
+
                         rules.insert(
                             ext.clone(),
                             LoaderRuleItem {
@@ -1282,7 +1362,7 @@ impl NextConfig {
                 }
             }
         }
-        Vc::cell(Some(ResolvedVc::cell(rules)))
+        Ok(Vc::cell(Some(ResolvedVc::cell(rules))))
     }
 
     #[turbo_tasks::function]

filename: crates/next-core/src/next_shared/webpack_rules/mod.rs
patch: @@ -17,7 +17,9 @@ pub async fn webpack_loader_options(
     foreign: bool,
     condition_strs: Vec<RcStr>,
 ) -> Result<Option<ResolvedVc<WebpackLoadersOptions>>> {
-    let rules = *next_config.webpack_rules(condition_strs).await?;
+    let rules = *next_config
+        .webpack_rules(condition_strs, project_path.clone())
+        .await?;
     let rules = *maybe_add_sass_loader(next_config.sass_config(), rules.map(|v| *v)).await?;
     let rules = if foreign {
         rules