Files
next.js/test/e2e/app-dir/metadata-static-file/metadata-static-file-group-route.test.ts
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

137 lines
3.3 KiB
TypeScript

import { nextTestSetup } from 'e2e-utils'
import {
getCommonMetadataHeadTags,
readFixtureBuffer,
readFixtureText,
} from './utils'
describe('metadata-files-static-output-group-route', () => {
const { next, skipped } = nextTestSetup({
files: __dirname,
})
if (skipped) {
return
}
it('should have correct link tags for group page', async () => {
const browser = await next.browser('/group')
expect(await getCommonMetadataHeadTags(browser)).toMatchInlineSnapshot(`
{
"links": [
{
"href": "/favicon.ico",
"rel": "icon",
"type": "image/x-icon",
},
{
"href": "/group/apple-icon-131tc6.png",
"rel": "apple-touch-icon",
"type": "image/png",
},
{
"href": "/group/icon-131tc6.png",
"rel": "icon",
"type": "image/png",
},
{
"href": "/manifest.json",
"rel": "manifest",
},
],
"metas": [
{
"name": "twitter:card",
},
{
"name": "twitter:image",
},
{
"name": "twitter:image:height",
},
{
"name": "twitter:image:type",
},
{
"name": "twitter:image:width",
},
{
"name": "viewport",
},
{
"property": "og:image",
},
{
"property": "og:image:height",
},
{
"property": "og:image:type",
},
{
"property": "og:image:width",
},
],
}
`)
})
it('should serve static files when requested to its route for group page', async () => {
const [
appleIconRes,
iconRes,
opengraphImageRes,
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/sitemap.xml'),
])
// Compare response content with actual files
const [
actualAppleIcon,
actualIcon,
actualOpengraphImage,
actualTwitterImage,
actualSitemap,
] = await Promise.all([
readFixtureBuffer('app/(group)/group/apple-icon.png'),
readFixtureBuffer('app/(group)/group/icon.png'),
readFixtureBuffer('app/(group)/group/opengraph-image.png'),
readFixtureBuffer('app/(group)/group/twitter-image.png'),
readFixtureText('app/(group)/group/sitemap.xml'),
])
expect({
appleIcon: Buffer.compare(
Buffer.from(await appleIconRes.arrayBuffer()),
actualAppleIcon
),
icon: Buffer.compare(
Buffer.from(await iconRes.arrayBuffer()),
actualIcon
),
opengraphImage: Buffer.compare(
Buffer.from(await opengraphImageRes.arrayBuffer()),
actualOpengraphImage
),
twitterImage: Buffer.compare(
Buffer.from(await twitterImageRes.arrayBuffer()),
actualTwitterImage
),
sitemap: await sitemapRes.text(),
}).toEqual({
// Buffer comparison returns 0 for equal
appleIcon: 0,
icon: 0,
opengraphImage: 0,
twitterImage: 0,
sitemap: actualSitemap,
})
})
})