Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
56 changes: 36 additions & 20 deletions packages/next/src/server/app-render/app-render.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2244,7 +2244,8 @@ async function getErrorRSCPayload(
tree: LoaderTree,
ctx: AppRenderContext,
ssrError: unknown,
errorType: MetadataErrorType | 'redirect' | undefined
errorType: MetadataErrorType | 'redirect' | undefined,
shouldRenderMetadataAndViewport: boolean
) {
const {
getDynamicParamFromSegment,
Expand All @@ -2254,19 +2255,25 @@ async function getErrorRSCPayload(
workStore,
} = ctx

const serveStreamingMetadata = !!ctx.renderOpts.serveStreamingMetadata
const metadataIsRuntimePrefetchable =
await anySegmentHasRuntimePrefetchEnabled(tree)
const { Viewport, Metadata } = createMetadataComponents({
tree,
parsedQuery: query,
pathname: url.pathname,
metadataContext: createMetadataContext(ctx.renderOpts),
errorType,
interpolatedParams: ctx.interpolatedParams,
serveStreamingMetadata: serveStreamingMetadata,
isRuntimePrefetchable: metadataIsRuntimePrefetchable,
})
let Viewport: ComponentType | null = null
let Metadata: ComponentType | null = null
if (shouldRenderMetadataAndViewport) {
const serveStreamingMetadata = !!ctx.renderOpts.serveStreamingMetadata
const metadataIsRuntimePrefetchable =
await anySegmentHasRuntimePrefetchEnabled(tree)
const metadataComponents = createMetadataComponents({
tree,
parsedQuery: query,
pathname: url.pathname,
metadataContext: createMetadataContext(ctx.renderOpts),
errorType,
interpolatedParams: ctx.interpolatedParams,
serveStreamingMetadata: serveStreamingMetadata,
isRuntimePrefetchable: metadataIsRuntimePrefetchable,
})
Viewport = metadataComponents.Viewport
Metadata = metadataComponents.Metadata
}

const initialHead = createElement(
Fragment,
Expand All @@ -2279,13 +2286,13 @@ async function getErrorRSCPayload(
statusCode: ctx.res.statusCode,
isPossibleServerAction: ctx.isPossibleServerAction,
}),
createElement(Viewport, null),
Viewport ? createElement(Viewport, null) : null,
process.env.__NEXT_DEV_SERVER &&
createElement('meta', {
name: 'next-error',
content: 'not-found',
}),
createElement(Metadata, null)
Metadata ? createElement(Metadata, null) : null
)

const errorHints = ctx.renderOpts.prefetchHints?.[ctx.pagePath] ?? null
Expand Down Expand Up @@ -4393,7 +4400,9 @@ async function renderToStream(
tree,
ctx,
reactServerErrorsByDigest.has((err as any).digest) ? null : err,
errorType
errorType,
// Normal error rendering should include the error payload head.
true
)

errorServerStream = workUnitAsyncStorage.run(
Expand Down Expand Up @@ -4490,7 +4499,9 @@ async function renderToStream(
tree,
ctx,
reactServerErrorsByDigest.has((err as any).digest) ? null : err,
errorType
errorType,
// Normal error rendering should include the error payload head.
true
)

errorServerStream = workUnitAsyncStorage.run(
Expand Down Expand Up @@ -8587,7 +8598,10 @@ async function prerenderToStream(
tree,
ctx,
reactServerErrorsByDigest.has((err as any).digest) ? undefined : err,
errorType
errorType,
// The recovery shell only bootstraps the original Flight data. Avoid
// blocking that shell on error-page metadata or viewport.
false
)

const errorServerResult = await createReactServerPrerenderResult(
Expand Down Expand Up @@ -8879,7 +8893,9 @@ async function prerenderToStream(
tree,
ctx,
reactServerErrorsByDigest.has((err as any).digest) ? undefined : err,
errorType
errorType,
// Legacy prerender recovery should include the error payload head.
true
)

const errorServerStream = workUnitAsyncStorage.run(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { isNextDev, nextTestSetup } from 'e2e-utils'
import { retry } from 'next-test-utils'
import { getPrerenderOutput } from './utils'

describe('Cache Components HTTP Access Fallback Prerender', () => {
Expand Down Expand Up @@ -166,6 +167,56 @@ describe('Cache Components HTTP Access Fallback Prerender', () => {
}
})

describe('notFound() with dynamic metadata and viewport', () => {
const pagePath = '/not-found-dynamic-head/[slug]'

if (!isNextDev) {
it('should allow fallback recovery when the route opts out of static shell validation', async () => {
await prerender(pagePath)

const output = getPrerenderOutput(
next.cliOutput.slice(cliOutputLength),
{ isMinified: !isDebugPrerender }
)

expect(output).toMatchInlineSnapshot(`""`)
await expectPartiallyStaticErrorArtifacts(
'not-found-dynamic-head/not-found'
)
const prerenderedHtml = await next.readFile(
'.next/server/app/not-found-dynamic-head/not-found.html'
)
expect(prerenderedHtml).not.toContain('not-found metadata marker')
expect(prerenderedHtml).not.toContain('metadata from not-found.tsx')
expect(prerenderedHtml).not.toContain('#123456')

await next.start({ skipBuild: true })
const browser = await next.browser(
'/not-found-dynamic-head/not-found'
)

await retry(async () => {
const head = await browser.eval(() => {
return {
title: document.title,
description: document
.querySelector('meta[name="description"]')
?.getAttribute('content'),
themeColors: Array.from(
document.querySelectorAll('meta[name="theme-color"]')
).map((meta) => meta.getAttribute('content')),
}
})

expect(head.title).toBe('not-found metadata marker')
expect(head.description).toBe('metadata from not-found.tsx')
expect(head.themeColors).toContain('#123456')
expect(head.themeColors).not.toContain('black')
})
})
}
})

describe('notFound() with static RSC data', () => {
const pagePath = '/not-found-static-flight/[slug]'

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
export async function generateMetadata() {
await new Promise((resolve) => setTimeout(resolve, 0))
return {
title: 'not-found metadata marker',
description: 'metadata from not-found.tsx',
}
}

export async function generateViewport() {
await new Promise((resolve) => setTimeout(resolve, 0))
return { themeColor: '#123456' }
}

export default function NotFound() {
return <p>not found with dynamic head</p>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { notFound } from 'next/navigation'

export const unstable_instant = false

export function generateStaticParams() {
return [{ slug: 'not-found' }]
}

export const metadata = {
title: 'main page metadata marker',
}

export async function generateViewport() {
await new Promise((resolve) => setTimeout(resolve, 0))
return { themeColor: 'black' }
}

export default function Page() {
notFound()
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,7 @@ describe('parallel-routes-and-interception', () => {
// we also check that the #children-slot id is not present
expect(await browser.hasElementByCssSelector('#children-slot')).toBe(false)
const $ = await next.render$('/')
expect($('title').length).toBe(1)
expect($('title').text()).toBe('layout title')
expect($('title').length).toBe(0)
Copy link
Copy Markdown
Contributor

@vercel vercel Bot May 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test assertion expect($('title').length).toBe(0) was changed unconditionally, but the <title> tag is only absent in cache-components mode — regular prod mode still renders it, causing the test to fail outside cache-components CI.

Fix on Vercel

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@gnoff I though you'd do the same check as here. That should work for both with and without cacheComponents, right?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point — using a browser.eval() + retry() approach like the new cache-components test would work in both modes since the title will be present client-side regardless (either from SSR or from hydration recovery). Either approach fixes the issue; I'll defer to @gnoff on which feels more appropriate here.

})

it('should render the title once for the non-existed route', async () => {
Expand Down
Loading