Repository: vercel/next.js
Pull request: fix: support multiple icon formats with same base name (icon.png + icon.svg)

## What?

Fixes a crash in Turbopack and incorrect behavior in Webpack when users have multiple icon files with the same base name but different extensions (e.g., `icon.png` and `icon.svg`) in their app directory.

## Why?

This is a valid use case for browser fallback support - modern browsers can use SVG icons while older browsers (Safari <26) need to fall back to PNG. Previously:

- **Turbopack**: Crashed during `next build` with "Dependency tracking is disabled so invalidation is not allowed"
- **Webpack**: Silently ignored all but one format

## How?

**Turbopack**: Changed virtual source filename from `{stem}--route-entry.js` to `{filename}--route-entry.js` (e.g., `icon.png--route-entry.js` and `icon.svg--route-entry.js`) to avoid file conflicts that triggered invalidation.

**Webpack**: Changed `MetadataResolver` to return all matching files instead of just the first one.

Both icon formats are now properly rendered:
```html
<link rel="icon" type="image/png" sizes="114x114" href="/icon.png">
<link rel="icon" type="image/svg+xml" sizes="any" href="/icon.svg">
```

Fixes #85496
Fixes NEXT-4816


Changed files:
filename: crates/next-core/src/next_app/metadata/route.rs
patch: @@ -201,9 +201,13 @@ async fn static_route_source(mode: NextMode, path: FileSystemPath) -> Result<Vc<
         original_file_content_b64 = StringifyJs(&original_file_content_b64),
     };
 
+    // Use full filename (stem + extension) to avoid conflicts when multiple icon
+    // formats exist (e.g., icon.png and icon.svg)
+    let filename = path.file_name();
+
     let file = File::from(code);
     let source = VirtualSource::new(
-        path.parent().join(&format!("{stem}--route-entry.js"))?,
+        path.parent().join(&format!("{filename}--route-entry.js"))?,
         AssetContent::file(FileContent::Content(file).cell()),
     );
 

filename: packages/next/src/build/webpack/loaders/metadata/discover.ts
patch: @@ -36,9 +36,7 @@ async function enumMetadataFiles(
   )
   for (const name of possibleFileNames) {
     const resolved = await metadataResolver(dir, name, extensions)
-    if (resolved) {
-      collectedFiles.push(resolved)
-    }
+    collectedFiles.push(...resolved)
   }
 
   return collectedFiles

filename: packages/next/src/build/webpack/loaders/next-app-loader/index.ts
patch: @@ -110,7 +110,7 @@ export type MetadataResolver = (
   dir: string,
   filename: string,
   extensions: readonly string[]
-) => Promise<string | undefined>
+) => Promise<string[]>
 
 export type AppDirModules = {
   readonly [moduleKey in ValueOf<typeof FILE_TYPES>]?: ModuleTuple
@@ -962,22 +962,18 @@ const nextAppLoader: AppLoader = async function nextAppLoader() {
     const dirname = absolutePath.slice(0, filenameIndex)
     const filename = absolutePath.slice(filenameIndex + 1)
 
-    let result: string | undefined
-
-    for (const ext of extensions) {
-      const absolutePathWithExtension = `${absolutePath}${ext}`
-      if (
-        !result &&
-        (await fileExistsInDirectory(dirname, `${filename}${ext}`))
-      ) {
-        result = absolutePathWithExtension
-      }
-      // Call `addMissingDependency` for all files even if they didn't match,
-      // because they might be added or removed during development.
-      this.addMissingDependency(absolutePathWithExtension)
-    }
+    const checks = await Promise.all(
+      extensions.map(async (ext) => {
+        const absolutePathWithExtension = `${absolutePath}${ext}`
+        const exists = await fileExistsInDirectory(dirname, `${filename}${ext}`)
+        // Call `addMissingDependency` for all files even if they didn't match,
+        // because they might be added or removed during development.
+        this.addMissingDependency(absolutePathWithExtension)
+        return exists ? absolutePathWithExtension : undefined
+      })
+    )
 
-    return result
+    return checks.find((result) => result)
   }
 
   const metadataResolver: MetadataResolver = async (
@@ -987,21 +983,20 @@ const nextAppLoader: AppLoader = async function nextAppLoader() {
   ) => {
     const absoluteDir = createAbsolutePath(appDir, dirname)
 
-    let result: string | undefined
-
-    for (const ext of exts) {
-      // Compared to `resolver` above the exts do not have the `.` included already, so it's added here.
-      const filenameWithExt = `${filename}.${ext}`
-      const absolutePathWithExtension = `${absoluteDir}${path.sep}${filenameWithExt}`
-      if (!result && (await fileExistsInDirectory(dirname, filenameWithExt))) {
-        result = absolutePathWithExtension
-      }
-      // Call `addMissingDependency` for all files even if they didn't match,
-      // because they might be added or removed during development.
-      this.addMissingDependency(absolutePathWithExtension)
-    }
+    const checks = await Promise.all(
+      exts.map(async (ext) => {
+        // Compared to `resolver` above the exts do not have the `.` included already, so it's added here.
+        const filenameWithExt = `${filename}.${ext}`
+        const absolutePathWithExtension = `${absoluteDir}${path.sep}${filenameWithExt}`
+        const exists = await fileExistsInDirectory(dirname, filenameWithExt)
+        // Call `addMissingDependency` for all files even if they didn't match,
+        // because they might be added or removed during development.
+        this.addMissingDependency(absolutePathWithExtension)
+        return exists ? absolutePathWithExtension : undefined
+      })
+    )
 
-    return result
+    return checks.filter((result) => result !== undefined)
   }
 
   if (isAppRouteRoute(name)) {

filename: test/e2e/app-dir/metadata-icons-parallel-routes/metadata-icons-parallel-routes.test.ts
patch: @@ -12,6 +12,31 @@ describe('app-dir - metadata-icons-parallel-routes', () => {
     expect($('link[rel="apple-touch-icon"]').length).toBe(1)
   })
 
+  it('should render both icon.png and icon.svg when both are present', async () => {
+    const $ = await next.render$('/')
+
+    // Should have both PNG and SVG icons for browser fallback support
+    const pngIcon = $('link[rel="icon"][type="image/png"]')
+    const svgIcon = $('link[rel="icon"][type="image/svg+xml"]')
+
+    expect(pngIcon.length).toBe(1)
+    expect(svgIcon.length).toBe(1)
+
+    // Verify the URLs are distinct
+    expect(pngIcon.attr('href')).toMatch(/icon\.png/)
+    expect(svgIcon.attr('href')).toMatch(/icon\.svg/)
+  })
+
+  it('should serve both icon formats', async () => {
+    const pngRes = await next.fetch('/icon.png')
+    expect(pngRes.status).toBe(200)
+    expect(pngRes.headers.get('content-type')).toContain('image/png')
+
+    const svgRes = await next.fetch('/icon.svg')
+    expect(svgRes.status).toBe(200)
+    expect(svgRes.headers.get('content-type')).toContain('image/svg+xml')
+  })
+
   it('should override parent icon when both static icon presented', async () => {
     const $ = await next.render$('/nested')
     expect($('link[type="image/x-icon"]').length).toBe(1)