Perf: Simplify static metadata handling (#92630)

### What?

Avoid running the generic `fillMetadataSegment(..., true)` path for
static metadata routes.

This PR adds a dedicated static metadata URL resolver, uses it from the
existing TypeScript static call sites, and precomputes static metadata
URLs in Turbopack loader tree codegen instead of waiting on
`props.params` at runtime.

### Why?

Static metadata files like `favicon.ico`, `icon.png`, and other static
metadata images do not need request-time dynamic path interpolation.

The old path reused logic that was designed to also support dynamic
metadata routes, which meant extra route-regex work even when the final
URL was already statically known apart from the existing `-` placeholder
and route-group / parallel-route suffix semantics.

### How?

- Added a static-only metadata URL helper in `get-metadata-route.ts`
that:
  - normalizes app path segments,
  - replaces dynamic and catch-all segments with `-`,
- preserves the existing metadata filename suffix logic for route groups
and parallel routes.
- Kept `fillMetadataSegment()` for dynamic metadata routes, but
short-circuited the `isStatic` path to the new helper.
- Switched TypeScript static metadata call sites to use the static
helper directly:
  - build tree display path generation,
  - dev bundler static metadata mapping,
  - webpack static metadata image loader output.
- Updated Turbopack static metadata codegen to emit the final static URL
string instead of calling `fillMetadataSegment(..., true)` with `await
props.params`.
- Added focused coverage for:
  - root favicon,
  - static metadata under dynamic parents,
  - route-group / parallel-route suffix behavior,
  - unchanged dynamic metadata interpolation.

Closes NEXT-
Fixes #

<!-- NEXT_JS_LLM_PR -->
This commit is contained in:
Tim Neutkens
2026-04-17 10:26:19 +02:00
committed by GitHub
parent 1de67d8b34
commit 7873aeaa51
9 changed files with 238 additions and 64 deletions
+16 -19
View File
@@ -4,7 +4,7 @@ use std::{
};
use anyhow::Result;
use turbo_rcstr::{RcStr, rcstr};
use turbo_rcstr::RcStr;
use turbo_tasks::{FxIndexMap, ResolvedVc, Vc};
use turbo_tasks_fs::FileSystemPath;
use turbopack::{ModuleAssetContext, transition::Transition};
@@ -19,7 +19,9 @@ use crate::{
base_loader_tree::{AppDirModuleType, BaseLoaderTreeBuilder},
next_app::{
AppPage,
metadata::{get_content_type, image::dynamic_image_metadata_source},
metadata::{
fill_static_metadata_segment, get_content_type, image::dynamic_image_metadata_source,
},
},
next_image::module::{BlurPlaceholderMode, StructuredImageModuleType},
};
@@ -229,13 +231,6 @@ impl AppPageLoaderTreeBuilder {
let identifier = magic_identifier::mangle(&format!("{name} #{i}"));
let inner_module_id = format!("METADATA_{i}");
let helper_import = rcstr!(
"import { fillMetadataSegment } from 'next/dist/lib/metadata/get-metadata-route'"
);
if !self.base.imports.contains(&helper_import) {
self.base.imports.push(helper_import);
}
// This should use the same importing mechanism as create_module_tuple_code, so that the
// relative order of items is retained (which isn't the case when mixing ESM imports and
@@ -290,7 +285,7 @@ impl AppPageLoaderTreeBuilder {
};
let s = " ";
writeln!(self.loader_tree_code, "{s}(async (props) => {{")?;
writeln!(self.loader_tree_code, "{s}(async () => {{")?;
writeln!(
self.loader_tree_code,
"{s} const mod = interopDefault(await {identifier}());"
@@ -302,18 +297,20 @@ impl AppPageLoaderTreeBuilder {
)?;
}
writeln!(self.loader_tree_code, "{s} return [{{")?;
let pathname_prefix = if let Some(base_path) = &self.base_path {
format!("{base_path}/{app_page}")
} else {
app_page.to_string()
let pathname_prefix = match &self.base_path {
Some(base_path) if !base_path.is_empty() => {
format!("{base_path}{app_page}")
}
_ => app_page.to_string(),
};
let metadata_route = &*get_metadata_route_name(item.clone().into()).await?;
let metadata_route = fill_static_metadata_segment(
&pathname_prefix,
&get_metadata_route_name(item.clone().into()).await?,
);
writeln!(
self.loader_tree_code,
"{s} url: fillMetadataSegment({}, await props.params, {}, true) + \
`?${{mod.src.split(\"/\").splice(-1)[0]}}`,",
StringifyJs(&pathname_prefix),
StringifyJs(metadata_route),
"{s} url: {} + `?${{mod.src.split(\"/\").splice(-1)[0]}}`,",
StringifyJs(&metadata_route),
)?;
let numeric_sizes = name == "twitter" || name == "openGraph";
+89 -2
View File
@@ -7,7 +7,7 @@ use rustc_hash::FxHashMap;
use turbo_rcstr::RcStr;
use turbo_tasks_fs::FileSystemPath;
use crate::next_app::{AppPage, PageSegment, PageType};
use crate::next_app::{AppPage, AppPath, PageSegment, PageType};
pub mod image;
pub mod route;
@@ -144,6 +144,16 @@ pub fn match_global_metadata_file<'a>(
})
}
/// Regular expression pattern used to match route parameters.
/// Matches both single parameters and parameter groups.
/// Examples:
/// - `[[...slug]]` matches parameter group with key 'slug', repeat: true, optional: true
/// - `[...slug]` matches parameter group with key 'slug', repeat: true, optional: false
/// - `[[foo]]` matches parameter with key 'foo', repeat: false, optional: true
/// - `[bar]` matches parameter with key 'bar', repeat: false, optional: false
static PARAMETER_PATTERN: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^([^\[]*)\[((?:\[[^\]]*\])|[^\]]+)\](.*)$").unwrap());
fn split_directory(path: &str) -> (Option<&str>, &str) {
if let Some((dir, basename)) = path.rsplit_once('/') {
if dir.is_empty() {
@@ -177,6 +187,42 @@ fn file_stem(path: &str) -> &str {
split_extension(path).0
}
fn join_path(dir: &str, basename: &str) -> String {
if dir.is_empty() || dir == "/" {
format!("/{basename}")
} else {
format!("{}/{}", dir.trim_end_matches('/'), basename)
}
}
fn normalize_static_metadata_route_segment(segment: &str) -> String {
let mut normalized_segment = segment.to_string();
while let Some(captures) = PARAMETER_PATTERN.captures(&normalized_segment) {
let prefix = captures.get(1).map(|m| m.as_str()).unwrap_or_default();
let suffix = captures.get(3).map(|m| m.as_str()).unwrap_or_default();
normalized_segment = format!("{prefix}-{suffix}");
}
normalized_segment
}
fn get_static_metadata_route(segment: &str) -> String {
let pathname = AppPath::from(AppPage::parse(segment).unwrap_or_default()).to_string();
let mut route = String::new();
for segment in pathname.split('/').filter(|segment| !segment.is_empty()) {
route.push('/');
route.push_str(&normalize_static_metadata_route_segment(segment));
}
if route.is_empty() {
"/".to_string()
} else {
route
}
}
/// When you only pass the file extension as `[]`, it will only match the static
/// convention files e.g. `/robots.txt`, `/sitemap.xml`, `/favicon.ico`,
/// `/manifest.json`.
@@ -319,6 +365,23 @@ fn get_metadata_route_suffix(page: &str) -> Option<String> {
}
}
pub fn fill_static_metadata_segment(segment: &str, last_segment: &str) -> String {
let route = get_static_metadata_route(segment);
let (name, ext) = split_extension(last_segment);
let page_path = join_path(segment, name);
let route_suffix = get_metadata_route_suffix(&page_path)
.map(|suffix| format!("-{suffix}"))
.unwrap_or_default();
let filename = format!(
"{}{}{}",
name,
route_suffix,
ext.map(|ext| format!(".{ext}")).unwrap_or_default()
);
join_path(&route, &filename)
}
/// Map metadata page key to the corresponding route
///
/// static file page key: /app/robots.txt -> /robots.txt -> /robots.txt/route
@@ -368,7 +431,7 @@ pub fn normalize_metadata_route(mut page: AppPage) -> Result<AppPage> {
#[cfg(test)]
mod test {
use super::{djb2_hash, format_radix, normalize_metadata_route};
use super::{djb2_hash, fill_static_metadata_segment, format_radix, normalize_metadata_route};
use crate::next_app::AppPage;
#[test]
@@ -402,4 +465,28 @@ mod test {
let hash = format_radix(djb2_hash("/lookup/[domain]/(dns)"), 36);
assert!(hash.len() < 6);
}
#[test]
fn test_fill_static_metadata_segment() {
assert_eq!(
fill_static_metadata_segment("/", "favicon.ico"),
"/favicon.ico"
);
assert_eq!(
fill_static_metadata_segment("/blog/[slug]", "favicon.ico"),
"/blog/-/favicon.ico"
);
assert_eq!(
fill_static_metadata_segment("/client/(meme)/more-route", "twitter-image.png"),
"/client/more-route/twitter-image-769mad.png"
);
assert_eq!(
fill_static_metadata_segment("/(group)/group", "icon.png"),
"/group/icon-131tc6.png"
);
assert_eq!(
fill_static_metadata_segment("/parallel/@parallel", "icon.png"),
"/parallel/icon-kzjltp.png"
);
}
}
+3 -3
View File
@@ -81,7 +81,7 @@ import type {
import type { FunctionsConfigManifest, ManifestRoute } from './index'
import { getNamedRouteRegex } from '../shared/lib/router/utils/route-regex'
import { parseNormalizedAppRoute } from '../shared/lib/router/routes/app'
import { fillMetadataSegment } from '../lib/metadata/get-metadata-route'
import { fillStaticMetadataSegment } from '../lib/metadata/get-metadata-route'
import { STATIC_METADATA_IMAGES } from '../lib/metadata/is-metadata-route'
// Build a set of static metadata image filenames for quick lookup
@@ -111,10 +111,10 @@ function getTreeViewDisplayPath(pagePath: string): string {
return pagePath
}
// Transform using fillMetadataSegment with isStatic=true
// Transform using the static metadata resolver so dynamic segments use "-"
const segment = pagePath.slice(0, lastSlash)
const lastSegment = filename
return fillMetadataSegment(segment, {}, lastSegment, true)
return fillStaticMetadataSegment(segment, lastSegment)
}
export type ROUTER_TYPE = 'pages' | 'app'
@@ -13,6 +13,7 @@ import loaderUtils from 'next/dist/compiled/loader-utils3'
import { getImageSize } from '../../../server/image-optimizer'
import { imageExtMimeTypeMap } from '../../../lib/mime-type'
import { WEBPACK_RESOURCE_QUERIES } from '../../../lib/constants'
import { fillStaticMetadataSegment } from '../../../lib/metadata/get-metadata-route'
import { normalizePathSep } from '../../../shared/lib/page-path/normalize-path-sep'
import type { PageExtensions } from '../../page-extensions-type'
import { getLoaderModuleNamedExports } from './utils'
@@ -168,18 +169,15 @@ async function nextMetadataImageLoader(
}
}
return `\
import { fillMetadataSegment } from 'next/dist/lib/metadata/get-metadata-route'
const imageUrl = fillStaticMetadataSegment(pathnamePrefix, pageSegment)
export default async (props) => {
return `\
export default function () {
const imageData = ${JSON.stringify(imageData)}
const imageUrl = fillMetadataSegment(${JSON.stringify(
pathnamePrefix
)}, await props.params, ${JSON.stringify(pageSegment)}, true)
return [{
...imageData,
url: imageUrl + ${JSON.stringify(hashQuery)},
url: ${JSON.stringify(imageUrl + hashQuery)},
}]
}`
}
@@ -0,0 +1,56 @@
import {
fillMetadataSegment,
fillStaticMetadataSegment,
normalizeMetadataRoute,
} from './get-metadata-route'
describe('fillStaticMetadataSegment', () => {
it('should preserve a statically known root favicon path', () => {
expect(fillStaticMetadataSegment('/', 'favicon.ico')).toBe('/favicon.ico')
})
it('should replace dynamic segments with placeholder segments', () => {
expect(fillStaticMetadataSegment('/blog/[slug]', 'favicon.ico')).toBe(
'/blog/-/favicon.ico'
)
expect(fillStaticMetadataSegment('/blog/[...slug]', 'icon.png')).toBe(
'/blog/-/icon.png'
)
})
it('should preserve grouped metadata suffixes', () => {
const staticPath = fillStaticMetadataSegment(
'/(post)/@feed/blog',
'twitter-image.png'
)
const normalizedRoute = normalizeMetadataRoute(
'/(post)/@feed/blog/twitter-image'
)
const suffix = normalizedRoute.match(/twitter-image(-[0-9a-z]{6})\/route$/)
expect(suffix).not.toBeNull()
expect(staticPath).toBe(`/blog/twitter-image${suffix?.[1]}.png`)
})
})
describe('fillMetadataSegment', () => {
it('should continue to interpolate dynamic metadata routes from params', () => {
expect(
fillMetadataSegment(
'/blog/[slug]',
{ slug: 'post-1' },
'opengraph-image',
false
)
).toBe('/blog/post-1/opengraph-image')
expect(
fillMetadataSegment(
'/blog/[...slug]',
{ slug: ['post-1', 'nested'] },
'opengraph-image',
false
)
).toBe('/blog/post-1/nested/opengraph-image')
})
})
@@ -2,6 +2,7 @@ import { isMetadataPage } from './is-metadata-route'
import path from '../../shared/lib/isomorphic/path'
import { interpolateDynamicPath } from '../../server/server-utils'
import { getNamedRouteRegex } from '../../shared/lib/router/utils/route-regex'
import { PARAMETER_PATTERN } from '../../shared/lib/router/utils/get-dynamic-param'
import { djb2Hash } from '../../shared/lib/hash'
import { normalizeAppPath } from '../../shared/lib/router/utils/app-paths'
import { normalizePathSep } from '../../shared/lib/page-path/normalize-path-sep'
@@ -48,6 +49,55 @@ function getMetadataRouteSuffix(page: string) {
return suffix
}
function getMetadataRouteFilename(segment: string, lastSegment: string) {
const { name, ext } = path.parse(lastSegment)
const pagePath = path.posix.join(segment, name)
const suffix = getMetadataRouteSuffix(pagePath)
const routeSuffix = suffix ? `-${suffix}` : ''
return `${name}${routeSuffix}${ext}`
}
function normalizeStaticMetadataRouteSegment(segment: string) {
let normalizedSegment = segment
let match = normalizedSegment.match(PARAMETER_PATTERN)
while (match) {
normalizedSegment = `${match[1]}-${match[3]}`
match = normalizedSegment.match(PARAMETER_PATTERN)
}
return normalizedSegment
}
function getStaticMetadataRoute(segment: string) {
const pathname = normalizeAppPath(segment)
return normalizePathSep(
path.join(
'/',
...pathname
.split('/')
.filter(Boolean)
.map((pathnameSegment) =>
normalizeStaticMetadataRouteSegment(pathnameSegment)
)
)
)
}
export function fillStaticMetadataSegment(
segment: string,
lastSegment: string
) {
return normalizePathSep(
path.join(
getStaticMetadataRoute(segment),
getMetadataRouteFilename(segment, lastSegment)
)
)
}
/**
* Fill the dynamic segment in the metadata route
*
@@ -66,31 +116,19 @@ export function fillMetadataSegment(
lastSegment: string,
isStatic: boolean
) {
if (isStatic) {
return fillStaticMetadataSegment(segment, lastSegment)
}
const pathname = normalizeAppPath(segment)
const routeRegex = getNamedRouteRegex(pathname, {
prefixRouteKeys: false,
})
const route = interpolateDynamicPath(pathname, params, routeRegex)
// For static metadata files, fill all dynamic segments with "-" placeholder
const routeParams = isStatic
? Object.keys(routeRegex.groups).reduce(
(acc, key) => {
const { repeat } = routeRegex.groups[key]
// Use array for catch-all segments, string for regular segments
acc[key] = repeat ? ['-'] : '-'
return acc
},
{} as Record<string, string | string[]>
)
: params
const route = interpolateDynamicPath(pathname, routeParams, routeRegex)
const { name, ext } = path.parse(lastSegment)
const pagePath = path.posix.join(segment, name)
const suffix = getMetadataRouteSuffix(pagePath)
const routeSuffix = suffix ? `-${suffix}` : ''
return normalizePathSep(path.join(route, `${name}${routeSuffix}${ext}`))
return normalizePathSep(
path.join(route, getMetadataRouteFilename(segment, lastSegment))
)
}
/**
@@ -65,7 +65,7 @@ import {
isStaticMetadataFile,
} from '../../../lib/metadata/is-metadata-route'
import {
fillMetadataSegment,
fillStaticMetadataSegment,
normalizeMetadataPageToRoute,
} from '../../../lib/metadata/get-metadata-route'
import { JsConfigPathsPlugin } from '../../../build/webpack/plugins/jsconfig-paths-plugin'
@@ -695,11 +695,9 @@ async function startWatcher(
if (appDir && isStaticMetadataFile(fileName.replace(appDir, ''))) {
const segment = path.posix.dirname(pageName)
const lastSegment = path.posix.basename(pageName)
const normalizedPath = fillMetadataSegment(
const normalizedPath = fillStaticMetadataSegment(
segment,
{},
lastSegment,
true
lastSegment
)
staticMetadataFiles.set(normalizedPath, fileName)
} else {
@@ -95,10 +95,10 @@ describe('metadata-files-static-output-group-route', () => {
twitterImageRes,
sitemapRes,
] = await Promise.all([
next.fetch(`/group/apple-icon-131tc6.png`),
next.fetch(`/group/icon-131tc6.png`),
next.fetch(`/group/opengraph-image-131tc6.png`),
next.fetch(`/group/twitter-image-131tc6.png`),
next.fetch('/group/apple-icon-131tc6.png'),
next.fetch('/group/icon-131tc6.png'),
next.fetch('/group/opengraph-image-131tc6.png'),
next.fetch('/group/twitter-image-131tc6.png'),
next.fetch('/group/sitemap.xml'),
])
@@ -95,10 +95,10 @@ describe('metadata-files-static-output-parallel-route', () => {
twitterImageRes,
sitemapRes,
] = await Promise.all([
next.fetch(`/parallel/apple-icon-kzjltp.png`),
next.fetch(`/parallel/icon-kzjltp.png`),
next.fetch(`/parallel/opengraph-image-kzjltp.png`),
next.fetch(`/parallel/twitter-image-kzjltp.png`),
next.fetch('/parallel/apple-icon-kzjltp.png'),
next.fetch('/parallel/icon-kzjltp.png'),
next.fetch('/parallel/opengraph-image-kzjltp.png'),
next.fetch('/parallel/twitter-image-kzjltp.png'),
next.fetch(`/parallel/sitemap.xml`),
])