Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/next/errors.json
Original file line number Diff line number Diff line change
Expand Up @@ -1244,5 +1244,6 @@
"1243": "This \"use cache\" has a dynamic cache life that was propagated to its parent.",
"1244": "A \"use cache\" with short \\`expire\\` (under 5 minutes) is nested inside another \"use cache\" that has no explicit \\`cacheLife\\`, which is not allowed during prerendering. Add \\`cacheLife()\\` to the outer \"use cache\" to choose whether it should be prerendered (with longer \\`expire\\`) or remain dynamic (with short \\`expire\\`). Read more: https://nextjs.org/docs/messages/nested-use-cache-no-explicit-cachelife",
"1245": "A \"use cache\" with zero \\`revalidate\\` is nested inside another \"use cache\" that has no explicit \\`cacheLife\\`, which is not allowed during prerendering. Add \\`cacheLife()\\` to the outer \"use cache\" to choose whether it should be prerendered (with non-zero \\`revalidate\\`) or remain dynamic (with zero \\`revalidate\\`). Read more: https://nextjs.org/docs/messages/nested-use-cache-no-explicit-cachelife",
"1246": "Could not validate instant UI because an expected segment was not rendered."
"1246": "Could not validate instant UI because an expected segment was not rendered.",
"1247": "Invariant: expected static metadata route to have a prerender pathname (%s)"
}
17 changes: 10 additions & 7 deletions packages/next/src/build/adapter/build-complete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import {
} from '../../lib/constants'

import { normalizeLocalePath } from '../../shared/lib/i18n/normalize-locale-path'
import { getStaticMetadataPrerenderPathname } from '../../lib/metadata/get-metadata-route'
import { isStaticMetadataFile } from '../../lib/metadata/is-metadata-route'
import { addPathPrefix } from '../../shared/lib/router/utils/add-path-prefix'
import { getRedirectStatus, modifyRouteRegex } from '../../lib/redirect-status'
Expand Down Expand Up @@ -984,19 +985,17 @@ export async function handleBuildComplete({
// Dynamic metadata routes (e.g. robots/sitemap using connection())
// should remain app routes in adapter outputs.
const isStaticMetadataRoute = isStaticMetadataFile(normalizedPage)
const staticMetadataPrerenderPathname =
getStaticMetadataPrerenderPathname(normalizedPage) ?? normalizedPage
const isPrerenderedMetadataRoute =
prerenderManifest.routes[normalizedPage] ||
prerenderManifest.dynamicRoutes[normalizedPage] ||
prerenderManifest.routes[staticMetadataPrerenderPathname] ||
config.i18n?.locales?.some((locale) => {
const localePathname = path.posix.join(
'/',
locale,
normalizedPage.slice(1)
)
return (
prerenderManifest.routes[localePathname] ||
prerenderManifest.dynamicRoutes[localePathname]
staticMetadataPrerenderPathname.slice(1)
)
return prerenderManifest.routes[localePathname]
})

if (isStaticMetadataRoute && isPrerenderedMetadataRoute) {
Expand Down Expand Up @@ -1518,6 +1517,10 @@ export async function handleBuildComplete({
}

for (const dynamicRoute in prerenderManifest.dynamicRoutes) {
if (isStaticMetadataFile(dynamicRoute)) {
continue
}

const {
fallback,
fallbackExpire,
Expand Down
60 changes: 56 additions & 4 deletions packages/next/src/build/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,8 @@ import { webpackBuild } from './webpack-build'
import { NextBuildContext } from './build-context'
import { normalizePathSep } from '../shared/lib/page-path/normalize-path-sep'
import { isAppRouteRoute } from '../lib/is-app-route-route'
import { getStaticMetadataPrerenderPathname } from '../lib/metadata/get-metadata-route'
import { isStaticMetadataFile } from '../lib/metadata/is-metadata-route'
import { createClientRouterFilter } from '../lib/create-client-router-filter'
import { startTypeChecking } from './type-check'
import { generateInterceptionRoutesRewrites } from '../lib/generate-interception-routes-rewrites'
Expand Down Expand Up @@ -3183,13 +3185,39 @@ export default async function build(
const unsortedUnknownPrerenderRoutes: PrerenderedRoute[] = []
const unsortedKnownPrerenderRoutes: PrerenderedRoute[] = []
for (const prerenderedRoute of prerenderedRoutes) {
let route = prerenderedRoute
// Static metadata files under dynamic segments (e.g.
// `/[id]/apple-icon.png`) produce the same bytes regardless of
// params, so they prerender once to a canonical pathname with
// dynamic segments replaced by `-` (e.g. `/-/apple-icon.png`).
// Rewriting here ensures they land in `prerenderManifest.routes`
// as known static entries rather than in `dynamicRoutes` with
// fallback params, which they don't actually need.
const staticMetadataPrerenderPathname =
getStaticMetadataPrerenderPathname(prerenderedRoute.pathname)

if (
prerenderedRoute.fallbackRouteParams &&
prerenderedRoute.fallbackRouteParams.length > 0
staticMetadataPrerenderPathname &&
staticMetadataPrerenderPathname !== prerenderedRoute.pathname
) {
unsortedUnknownPrerenderRoutes.push(prerenderedRoute)
route = {
params: prerenderedRoute.params,
pathname: staticMetadataPrerenderPathname,
encodedPathname: staticMetadataPrerenderPathname,
fallbackRouteParams: undefined,
fallbackMode: prerenderedRoute.fallbackMode,
fallbackRootParams: undefined,
throwOnEmptyStaticShell: undefined,
}
}

if (
route.fallbackRouteParams &&
route.fallbackRouteParams.length > 0
) {
unsortedUnknownPrerenderRoutes.push(route)
} else {
unsortedKnownPrerenderRoutes.push(prerenderedRoute)
unsortedKnownPrerenderRoutes.push(route)
}
}

Expand Down Expand Up @@ -3363,6 +3391,16 @@ export default async function build(
}

for (const route of dynamicPrerenderedRoutes) {
// Static metadata files are rewritten above into the known
// static bucket under their `-`-placeholder pathname, so any
// entry that slips through here (e.g. an unexpected fallback
// shape) must not generate a dynamic PRERENDER manifest entry
// — the route handler shipped with the dynamic route still
// serves these at runtime.
if (isStaticMetadataFile(route.pathname)) {
continue
}

const normalizedRoute = normalizePagePath(route.pathname)
const parentPageInfo = pageInfos.get(page) as PageInfo

Expand Down Expand Up @@ -4069,6 +4107,20 @@ export default async function build(
NextBuildContext.allowedRevalidateHeaderKeys =
config.experimental.allowedRevalidateHeaderKeys

// Defensive sweep: static metadata files should never end up in
// `dynamicRoutes` because they are rewritten to the `-`-placeholder
// pathname and added to `routes` above. If anything upstream
// (e.g. a code path that calls `addDynamicRoute` directly) still
// registers a bracketed entry like `/[id]/apple-icon.png` here, the
// adapter would later look for a parent app output that doesn't
// exist and throw an invariant error. Strip those entries before the
// manifest is written.
for (const route of Object.keys(prerenderManifest.dynamicRoutes)) {
if (isStaticMetadataFile(route)) {
delete prerenderManifest.dynamicRoutes[route]
}
}

await writePrerenderManifest(distDir, prerenderManifest)
await writeManifest(
path.join(distDir, SERVER_DIRECTORY, PREFETCH_HINTS),
Expand Down
117 changes: 71 additions & 46 deletions packages/next/src/build/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,40 +81,52 @@ 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 { 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
const staticMetadataImageFilenames = new Set<string>(
Object.values(STATIC_METADATA_IMAGES).map((meta) => meta.filename)
)
import { getStaticMetadataPrerenderPathname } from '../lib/metadata/get-metadata-route'
import { isStaticMetadataFile } from '../lib/metadata/is-metadata-route'
import { normalizeAppPath } from '../shared/lib/router/utils/app-paths'

/**
* Get the display path for build output. For static metadata files under
* dynamic routes, this normalizes the path to use "-" placeholder.
* e.g., /dynamic/[id]/icon.png -> /dynamic/-/icon.png
*/
function getTreeViewDisplayPath(pagePath: string): string {
// Check if the path contains dynamic segments
if (!isDynamicRoute(pagePath)) {
return pagePath
}
const prerenderPathname = getStaticMetadataPrerenderPathname(
pagePath.startsWith('/') ? pagePath : `/${pagePath}`
)
return prerenderPathname ?? pagePath
}

// Check if the filename is a static metadata image
const lastSlash = pagePath.lastIndexOf('/')
const filename = pagePath.slice(lastSlash + 1)
const dotIndex = filename.lastIndexOf('.')
const baseName = dotIndex > 0 ? filename.slice(0, dotIndex) : filename
function buildStaticMetadataStaticPaths(page: string): {
fallbackMode: FallbackMode | undefined
prerenderedRoutes: PrerenderedRoute[]
} {
let pathname = normalizeAppPath(page)
if (pathname.endsWith('/route')) {
pathname = pathname.slice(0, -'/route'.length)
}

// Check against known static metadata image filenames (e.g., icon, apple-icon, opengraph-image)
if (!staticMetadataImageFilenames.has(baseName)) {
return pagePath
const prerenderPathname = getStaticMetadataPrerenderPathname(pathname)
if (!prerenderPathname) {
throw new Error(
`Invariant: expected static metadata route to have a prerender pathname (${page})`
)
}

// Transform using the static metadata resolver so dynamic segments use "-"
const segment = pagePath.slice(0, lastSlash)
const lastSegment = filename
return fillStaticMetadataSegment(segment, lastSegment)
return {
fallbackMode: undefined,
prerenderedRoutes: [
{
params: {},
pathname: prerenderPathname,
encodedPathname: prerenderPathname,
fallbackRouteParams: undefined,
fallbackMode: undefined,
fallbackRootParams: undefined,
throwOnEmptyStaticShell: undefined,
},
],
}
}

export type ROUTER_TYPE = 'pages' | 'app'
Expand Down Expand Up @@ -871,29 +883,42 @@ export async function isPageStatic({
// build the static paths. The edge runtime doesn't support static
// paths.
if (route.dynamicSegments.length > 0 && !pathIsEdgeRuntime) {
;({ prerenderedRoutes, fallbackMode: prerenderFallbackMode } =
await buildAppStaticPaths({
dir,
page,
route,
cacheComponents,
authInterrupts,
useCacheTimeout,
staticPageGenerationTimeout,
segments,
distDir,
requestHeaders: {},
isrFlushToDisk,
cacheMaxMemorySize,
cacheHandler,
cacheLifeProfiles,
ComponentMod,
nextConfigOutput,
isRoutePPREnabled,
buildId,
deploymentId,
rootParamKeys,
}))
let pathname = normalizeAppPath(page)
if (pathname.endsWith('/route')) {
pathname = pathname.slice(0, -'/route'.length)
}

if (
routeModule.definition.kind === RouteKind.APP_ROUTE &&
isStaticMetadataFile(pathname)
) {
;({ prerenderedRoutes, fallbackMode: prerenderFallbackMode } =
buildStaticMetadataStaticPaths(page))
} else {
;({ prerenderedRoutes, fallbackMode: prerenderFallbackMode } =
await buildAppStaticPaths({
dir,
page,
route,
cacheComponents,
authInterrupts,
useCacheTimeout,
staticPageGenerationTimeout,
segments,
distDir,
requestHeaders: {},
isrFlushToDisk,
cacheMaxMemorySize,
cacheHandler,
cacheLifeProfiles,
ComponentMod,
nextConfigOutput,
isRoutePPREnabled,
buildId,
deploymentId,
rootParamKeys,
}))
}
}
} else {
if (!Comp || !isValidElementType(Comp) || typeof Comp === 'string') {
Expand Down
52 changes: 52 additions & 0 deletions packages/next/src/lib/metadata/get-metadata-route.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
fillMetadataSegment,
fillStaticMetadataSegment,
getStaticMetadataPrerenderPathname,
normalizeMetadataRoute,
} from './get-metadata-route'

Expand Down Expand Up @@ -33,6 +34,57 @@ describe('fillStaticMetadataSegment', () => {
})
})

describe('getStaticMetadataPrerenderPathname', () => {
it('should return null for non-metadata routes', () => {
expect(getStaticMetadataPrerenderPathname('/dynamic/[id]/page')).toBeNull()
})

it('should normalize static metadata under dynamic segments', () => {
expect(
getStaticMetadataPrerenderPathname('/dynamic/[id]/apple-icon.png')
).toBe('/dynamic/-/apple-icon.png')
expect(
getStaticMetadataPrerenderPathname('/dynamic/[id]/sitemap.xml')
).toBe('/dynamic/-/sitemap.xml')
})

it('should preserve static metadata routes without dynamic segments', () => {
expect(getStaticMetadataPrerenderPathname('/static/apple-icon.png')).toBe(
'/static/apple-icon.png'
)
})

it('should collapse catchall segments to a single placeholder', () => {
expect(
getStaticMetadataPrerenderPathname('/[...slug]/apple-icon.png')
).toBe('/-/apple-icon.png')
})

it('should collapse optional catchall segments to a single placeholder', () => {
expect(
getStaticMetadataPrerenderPathname('/[[...slug]]/apple-icon.png')
).toBe('/-/apple-icon.png')
})

it('should replace each dynamic segment independently', () => {
expect(getStaticMetadataPrerenderPathname('/[a]/[b]/apple-icon.png')).toBe(
'/-/-/apple-icon.png'
)
})

it('should preserve literal segments between dynamic ones', () => {
expect(
getStaticMetadataPrerenderPathname('/[lang]/posts/[slug]/apple-icon.png')
).toBe('/-/posts/-/apple-icon.png')
})

it('should normalize mixed dynamic and catchall segments', () => {
expect(
getStaticMetadataPrerenderPathname('/[lang]/[...rest]/apple-icon.png')
).toBe('/-/-/apple-icon.png')
})
})

describe('fillMetadataSegment', () => {
it('should continue to interpolate dynamic metadata routes from params', () => {
expect(
Expand Down
28 changes: 28 additions & 0 deletions packages/next/src/lib/metadata/get-metadata-route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ 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 { isDynamicRoute } from '../../shared/lib/router/utils'
import { normalizePathSep } from '../../shared/lib/page-path/normalize-path-sep'
import { isMetadataRouteFile } from './is-metadata-route'
import {
isGroupSegment,
isParallelRouteSegment,
Expand Down Expand Up @@ -98,6 +100,32 @@ export function fillStaticMetadataSegment(
)
}

/**
* Returns the pathname used when prerendering static metadata files. Dynamic
* segments are replaced with "-" placeholders so the file is exported once.
*/
export function getStaticMetadataPrerenderPathname(
pathname: string
): string | null {
const normalized = pathname.startsWith('/') ? pathname : `/${pathname}`
if (!isMetadataRouteFile(normalized, [], true)) {
return null
}

if (!isDynamicRoute(normalized)) {
return normalized
}

const lastSlash = normalized.lastIndexOf('/')
if (lastSlash === -1) {
return normalized
}

const segment = normalized.slice(0, lastSlash) || '/'
const lastSegment = normalized.slice(lastSlash + 1)
return fillStaticMetadataSegment(segment, lastSegment)
}

/**
* Fill the dynamic segment in the metadata route
*
Expand Down
Loading
Loading