diff --git a/docs/01-app/02-guides/migrating-to-cache-components.mdx b/docs/01-app/02-guides/migrating-to-cache-components.mdx index b1a94b3babfe..e58420b025cd 100644 --- a/docs/01-app/02-guides/migrating-to-cache-components.mdx +++ b/docs/01-app/02-guides/migrating-to-cache-components.mdx @@ -601,6 +601,8 @@ Paths you don't return are still served. Next.js prerenders a static shell for t Params not returned by `generateStaticParams` are rendered on request. If you used `dynamicParams: false` to reject them, call [`notFound()`](/docs/app/api-reference/functions/not-found) in the page when the param doesn't resolve to real data. +If this check runs after the Cache Components [static shell](/docs/app/glossary#static-shell) starts streaming, the response keeps its `200` status and Next.js adds a `noindex` meta tag. If you require an HTTP `404`, validate the path before the response streams, such as in [`proxy`](/docs/app/api-reference/file-conventions/proxy). See [Status Codes](/docs/app/api-reference/file-conventions/loading#status-codes). + ### Await `params` inside `` To produce the static shell, pass the `params` promise into a [``](/docs/app/api-reference/file-conventions/loading) boundary instead of awaiting it at the top of the component, so unknown params can still prerender. diff --git a/test/e2e/app-dir/partial-fallback-shell-upgrade/fixtures/partial-prefetching-disabled/app/blocking/[[...slug]]/page.tsx b/test/e2e/app-dir/partial-fallback-shell-upgrade/fixtures/partial-prefetching-disabled/app/blocking/[[...slug]]/page.tsx new file mode 100644 index 000000000000..4516b48d3a63 --- /dev/null +++ b/test/e2e/app-dir/partial-fallback-shell-upgrade/fixtures/partial-prefetching-disabled/app/blocking/[[...slug]]/page.tsx @@ -0,0 +1,28 @@ +import { notFound } from 'next/navigation' + +const prerenderedSlugs = ['known'] +const runtimeSlugs = ['runtime'] + +export const instant = false + +export function generateStaticParams() { + return prerenderedSlugs.map((slug) => ({ slug: [slug] })) +} + +export default async function Page({ + params, +}: { + params: Promise<{ slug?: string[] }> +}) { + const { slug } = await params + const pathname = slug?.join('/') ?? '' + + if ( + !prerenderedSlugs.includes(pathname) && + !runtimeSlugs.includes(pathname) + ) { + notFound() + } + + return

{pathname}

+} diff --git a/test/e2e/app-dir/partial-fallback-shell-upgrade/partial-fallback-shell-upgrade.test.ts b/test/e2e/app-dir/partial-fallback-shell-upgrade/partial-fallback-shell-upgrade.test.ts index 988800d8ab91..4c2b77f4eebf 100644 --- a/test/e2e/app-dir/partial-fallback-shell-upgrade/partial-fallback-shell-upgrade.test.ts +++ b/test/e2e/app-dir/partial-fallback-shell-upgrade/partial-fallback-shell-upgrade.test.ts @@ -240,4 +240,25 @@ describe('partial-fallback-shell-upgrade - partialPrefetching disabled', () => { 'generic shell should remain shared without partialPrefetching' ) }) + + it('keeps blocking not-found responses stable after the first request', async () => { + for (let attempt = 0; attempt < 2; attempt++) { + const response = await next.fetch('/blocking/missing') + const html = await response.text() + + expect(response.status).toBe(404) + expect(html).toContain(' { + for (let attempt = 0; attempt < 2; attempt++) { + const response = await next.fetch('/blocking/runtime') + + expect(response.status).toBe(200) + expect(await response.text()).toContain( + '

runtime

' + ) + } + }) })