diff --git a/packages/next/src/server/app-render/app-render.tsx b/packages/next/src/server/app-render/app-render.tsx index 5413c1e4ec34..b208ae683680 100644 --- a/packages/next/src/server/app-render/app-render.tsx +++ b/packages/next/src/server/app-render/app-render.tsx @@ -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, @@ -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, @@ -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 @@ -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( @@ -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( @@ -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( @@ -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( diff --git a/test/e2e/app-dir/cache-components-errors/cache-components-errors.http-access-fallback-prerender.test.ts b/test/e2e/app-dir/cache-components-errors/cache-components-errors.http-access-fallback-prerender.test.ts index e837d6f8a954..1172a73ba3ce 100644 --- a/test/e2e/app-dir/cache-components-errors/cache-components-errors.http-access-fallback-prerender.test.ts +++ b/test/e2e/app-dir/cache-components-errors/cache-components-errors.http-access-fallback-prerender.test.ts @@ -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', () => { @@ -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]' diff --git a/test/e2e/app-dir/cache-components-errors/fixtures/http-access-fallback-prerender/app/not-found-dynamic-head/[slug]/not-found.tsx b/test/e2e/app-dir/cache-components-errors/fixtures/http-access-fallback-prerender/app/not-found-dynamic-head/[slug]/not-found.tsx new file mode 100644 index 000000000000..a39125bb05df --- /dev/null +++ b/test/e2e/app-dir/cache-components-errors/fixtures/http-access-fallback-prerender/app/not-found-dynamic-head/[slug]/not-found.tsx @@ -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

not found with dynamic head

+} diff --git a/test/e2e/app-dir/cache-components-errors/fixtures/http-access-fallback-prerender/app/not-found-dynamic-head/[slug]/page.tsx b/test/e2e/app-dir/cache-components-errors/fixtures/http-access-fallback-prerender/app/not-found-dynamic-head/[slug]/page.tsx new file mode 100644 index 000000000000..19eb2cc00e4a --- /dev/null +++ b/test/e2e/app-dir/cache-components-errors/fixtures/http-access-fallback-prerender/app/not-found-dynamic-head/[slug]/page.tsx @@ -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() +} diff --git a/test/e2e/app-dir/parallel-routes-not-found/parallel-routes-not-found.test.ts b/test/e2e/app-dir/parallel-routes-not-found/parallel-routes-not-found.test.ts index 1df0fab01451..0a0b84965f59 100644 --- a/test/e2e/app-dir/parallel-routes-not-found/parallel-routes-not-found.test.ts +++ b/test/e2e/app-dir/parallel-routes-not-found/parallel-routes-not-found.test.ts @@ -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) }) it('should render the title once for the non-existed route', async () => {