Files
Tim NeutkensandGitHub 765b39c410 Prerender static metadata under dynamic segments to canonical pathname (#93873)
### What?

Fixes an invariant failure (`failed to find source route
/dynamic/[id]/apple-icon.png for prerender
/dynamic/[id]/apple-icon.png`) raised by the build adapter when an app
contains static metadata files (`apple-icon.png`, `icon.png`,
`opengraph-image.png`, `twitter-image.png`, `sitemap.xml`) under a
dynamic segment.

### Why?

Static metadata files under a dynamic segment were treated as full
dynamic prerenders: they ended up in `prerenderManifest.dynamicRoutes`
with `fallbackRouteParams`, but no `.body` file was exported and they
never received an `appOutputMap` entry. The adapter's `dynamicRoutes`
loop then asked `getParentOutput` for a source route that didn't exist
and threw.

These files are content-stable across params — the route handler returns
the same bytes for every URL that matches the dynamic pattern — so they
should prerender once under a canonical pathname rather than be treated
like per-param dynamic routes.

### How?

- New `getStaticMetadataPrerenderPathname` helper that maps
`/dynamic/[id]/apple-icon.png` to `/dynamic/-/apple-icon.png`. This is
the canonical pathname already used by `fillStaticMetadataSegment` for
HTML link emission.
- In `build/utils.ts`, route `APP_ROUTE` entries that are static
metadata files through a new `buildStaticMetadataStaticPaths` (one
prerender path, no fallback params) instead of the regular
`buildAppStaticPaths`.
- In `build/index.ts`:
- Rewrite each prerendered static metadata route to its `-`-placeholder
pathname before splitting into known/unknown buckets, so it lands in
`prerenderManifest.routes` with a `.body` file.
- Skip static metadata entries in the `dynamicPrerenderedRoutes` loop so
they don't get a stray `PRERENDER` manifest entry.
- Defensive sweep before write: drop any leftover bracketed metadata
keys from `prerenderManifest.dynamicRoutes`.
- In the adapter, look up `prerenderManifest.routes` under the canonical
placeholder pathname (with locale prefix when applicable) and skip
static metadata in the `dynamicRoutes` loop.
- Unit tests in `get-metadata-route.test.ts` lock in the placeholder
behavior for the tricky cases: catchall, optional catchall, multi-level
dynamic, literals between dynamics, dynamic + catchall mix.

Reproduction:
`test/e2e/app-dir/metadata-static-file/metadata-static-file-dynamic-route.test.ts`
configured with `adapterPath` previously crashed at
`handleBuildComplete`; all three cases in that file now pass.

Runtime stays the same: arbitrary param URLs continue to be served by
the dynamic route handler shipped with the app route; only the canonical
`-` URL is statically served.

<!-- NEXT_JS_LLM_PR -->
2026-05-22 19:49:04 +02:00

94 lines
2.8 KiB
TypeScript

// @ts-nocheck - On isolated test, this will be a type error.
import { promises as fs } from 'fs'
import path from 'path'
import type { Playwright } from 'e2e-utils'
// Fixture files live in this directory (`test/e2e/app-dir/metadata-static-file`),
// so we read them from the repo on the test runner. Using `next.readFile` would
// hit the deployed filesystem, which is not available in deploy test mode.
export function readFixtureBuffer(relativePath: string) {
return fs.readFile(path.join(__dirname, relativePath))
}
export function readFixtureText(relativePath: string) {
return fs.readFile(path.join(__dirname, relativePath), 'utf8')
}
async function getMetadataLinks(browser: Playwright) {
const links = await browser.locator('link').evaluateAll((elements: any[]) => {
return elements
.filter((el) => {
if (el.href.includes('/_next/static')) {
return false
}
return [
'/favicon.ico',
'/manifest.json',
'/manifest.webmanifest',
// Below may have suffixes like /icon1.png, /icon2.png, etc.
// Or has suffixes like /icon-xxxxxx.png, /icon-image-yyyyyy.jpg, etc.
'/icon',
'/apple-icon',
'/opengraph-image',
'/twitter-image',
].some((file) =>
new URL(el.href, window.location.origin).pathname.includes(file)
)
})
.map((el) => ({
href: new URL(el.href, window.location.origin).pathname,
rel: el.rel,
...(el.type && { type: el.type }),
}))
.sort((a, b) => a.href.localeCompare(b.href))
})
return links
}
async function getMetadataMetas(browser: Playwright) {
const metas = await browser.locator('meta').evaluateAll((elements: any[]) => {
return elements
.filter((meta) => {
if (!meta.name && !meta.hasAttribute('property')) {
return false
}
const attr = meta.name || meta.getAttribute('property') || ''
return [
'og:',
'twitter:',
'viewport',
'description',
'keywords',
'robots',
].some(
(prefix) => attr.startsWith(prefix) || attr === prefix.slice(0, -1)
)
})
.map((el) => ({
...(el.name && { name: el.name }),
...(el.hasAttribute('property') && {
property: el.getAttribute('property'),
}),
}))
.sort((a, b) => {
if (a.name && !b.name) return -1
if (!a.name && b.name) return 1
return (a.name || a.property || '').localeCompare(
b.name || b.property || ''
)
})
})
return metas
}
export async function getCommonMetadataHeadTags(browser: Playwright) {
const [links, metas] = await Promise.all([
getMetadataLinks(browser),
getMetadataMetas(browser),
])
return { links, metas }
}