mirror of
https://github.com/hanzoai/next.js.git
synced 2026-09-24 17:00:51 +00:00
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:
@@ -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";
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`),
|
||||
])
|
||||
|
||||
|
||||
Reference in New Issue
Block a user