From 448347a325e35a58c28f4ec4631970c22fedf346 Mon Sep 17 00:00:00 2001 From: Janka Uryga Date: Thu, 14 May 2026 16:50:41 +0200 Subject: [PATCH 1/3] [CC] fix: cachedNavigations missing asyncApiPromises in resumes (#93827) When rendering dynamically for `cachedNavigations`, we do a staged render so that we can recover a static prefetch from the stream. However, we weren't delaying params properly because we were missing `asyncApiPromises` and `fallbackParams` from the store, so dynamic params would incorrectly resolve in the static stage. This was masked in the tests, which accidentally had a bunch of `await setTimeout(300)` delays left in. so components that were meant to test when params resolve were always resolving in the dynamic stage, because timeouts are tasky. I'm not confident in the fix for missing `fallbackParams`, it's robot-assisted and i'm not very familiar with those bits, but it seems to work --- packages/next/src/build/templates/app-page.ts | 19 +++++++++++++++++-- .../next/src/server/app-render/app-render.tsx | 12 ++++++++++++ .../app/partially-static/page.tsx | 5 ----- .../app/runtime-prefetchable/page.tsx | 5 ----- .../app/with-fallback-params/[slug]/page.tsx | 3 --- .../app/with-static-params/[slug]/page.tsx | 2 -- 6 files changed, 29 insertions(+), 17 deletions(-) diff --git a/packages/next/src/build/templates/app-page.ts b/packages/next/src/build/templates/app-page.ts index 6fc58f71b53f..c4ea7aa8c9df 100644 --- a/packages/next/src/build/templates/app-page.ts +++ b/packages/next/src/build/templates/app-page.ts @@ -1922,14 +1922,29 @@ export async function handler( const transformer = new TransformStream() body.push(transformer.readable) + // Plumb fallback params via request meta so the RequestStore created + // downstream in app-render.tsx knows which params to defer during the + // resume. We don't pass them as `fallbackRouteParams` because that + // would replace actual param values with opaque placeholders during + // segment resolution; the resolved values are baked into the URL and + // already interpolated into the postponed state. + if (nextConfig.cacheComponents && prerenderInfo?.fallbackRouteParams) { + const fallbackParams = createOpaqueFallbackRouteParams( + prerenderInfo.fallbackRouteParams + ) + if (fallbackParams) { + addRequestMeta(req, 'fallbackParams', fallbackParams) + } + } + // Perform the render again, but this time, provide the postponed state. // We don't await because we want the result to start streaming now, and // we've already chained the transformer's readable to the render result. doRender({ span, postponed: cachedData.postponed, - // This is a resume render, not a fallback render, so we don't need to - // set this. + // This is a resume render, not a fallback render. Fallback params + // (for cacheComponents routes) are plumbed via request meta above. fallbackRouteParams: null, forceStaticRender: false, }) diff --git a/packages/next/src/server/app-render/app-render.tsx b/packages/next/src/server/app-render/app-render.tsx index f4153661c421..e4e7f8c280f5 100644 --- a/packages/next/src/server/app-render/app-render.tsx +++ b/packages/next/src/server/app-render/app-render.tsx @@ -3716,6 +3716,12 @@ async function renderToStream( requestStore.stale = INFINITE_CACHE requestStore.stagedRendering = stageController + requestStore.asyncApiPromises = createAsyncApiPromises( + stageController, + requestStore.cookies, + requestStore.mutableCookies, + requestStore.headers + ) requestStore.varyParamsAccumulator = createResponseVaryParamsAccumulator() @@ -3845,6 +3851,12 @@ async function renderToStream( requestStore.stale = INFINITE_CACHE requestStore.stagedRendering = stageController + requestStore.asyncApiPromises = createAsyncApiPromises( + stageController, + requestStore.cookies, + requestStore.mutableCookies, + requestStore.headers + ) requestStore.varyParamsAccumulator = createResponseVaryParamsAccumulator() diff --git a/test/e2e/app-dir/segment-cache/cached-navigations/app/partially-static/page.tsx b/test/e2e/app-dir/segment-cache/cached-navigations/app/partially-static/page.tsx index 5b3ee74bace7..b081d9a955c3 100644 --- a/test/e2e/app-dir/segment-cache/cached-navigations/app/partially-static/page.tsx +++ b/test/e2e/app-dir/segment-cache/cached-navigations/app/partially-static/page.tsx @@ -1,7 +1,6 @@ import { cacheLife } from 'next/cache' import { cookies, headers } from 'next/headers' import { connection } from 'next/server' -import { setTimeout } from 'timers/promises' import { Suspense } from 'react' export default async function Page({ @@ -48,7 +47,6 @@ async function SearchParamsContent({ searchParams: Promise<{ q?: string }> }) { const { q } = await searchParams - await setTimeout(300) return (

Search params: {q ?? 'none'} ({new Date().toISOString()}) @@ -60,7 +58,6 @@ async function CookiesContent() { const cookieStore = await cookies() const value = cookieStore.get('testCookie')?.value ?? 'none' const date = await getShortLivedCachedDate() - await setTimeout(300) return (

Cookie: {value}, Cached at: {date} @@ -77,7 +74,6 @@ async function getShortLivedCachedDate() { async function HeadersContent() { const headerStore = await headers() const value = headerStore.get('x-test-header') ?? 'none' - await setTimeout(300) return (

Header: {value} ({new Date().toISOString()}) @@ -87,6 +83,5 @@ async function HeadersContent() { async function ConnectionContent() { await connection() - await setTimeout(600) return

Dynamic content ({new Date().toISOString()})

} diff --git a/test/e2e/app-dir/segment-cache/cached-navigations/app/runtime-prefetchable/page.tsx b/test/e2e/app-dir/segment-cache/cached-navigations/app/runtime-prefetchable/page.tsx index ea3f2ca89088..531291f0c4c1 100644 --- a/test/e2e/app-dir/segment-cache/cached-navigations/app/runtime-prefetchable/page.tsx +++ b/test/e2e/app-dir/segment-cache/cached-navigations/app/runtime-prefetchable/page.tsx @@ -1,7 +1,6 @@ import { cacheLife } from 'next/cache' import { cookies, headers } from 'next/headers' import { connection } from 'next/server' -import { setTimeout } from 'timers/promises' import { Suspense } from 'react' export const unstable_instant = { @@ -61,7 +60,6 @@ async function SearchParamsContent({ 'use cache: private' cacheLife({ stale: 30 }) const { q } = await searchParams - await setTimeout(300) return (

Search params: {q ?? 'none'} ({new Date().toISOString()}) @@ -74,7 +72,6 @@ async function CookiesContent() { cacheLife({ stale: 30 }) const cookieStore = await cookies() const value = cookieStore.get('testCookie')?.value ?? 'none' - await setTimeout(300) return (

Cookie: {value} ({new Date().toISOString()}) @@ -87,7 +84,6 @@ async function HeadersContent() { cacheLife({ stale: 30 }) const headerStore = await headers() const value = headerStore.get('x-test-header') ?? 'none' - await setTimeout(300) return (

Header: {value} ({new Date().toISOString()}) @@ -97,6 +93,5 @@ async function HeadersContent() { async function ConnectionContent() { await connection() - await setTimeout(600) return

Dynamic content ({new Date().toISOString()})

} diff --git a/test/e2e/app-dir/segment-cache/cached-navigations/app/with-fallback-params/[slug]/page.tsx b/test/e2e/app-dir/segment-cache/cached-navigations/app/with-fallback-params/[slug]/page.tsx index a48d8d237125..ab5b45599eab 100644 --- a/test/e2e/app-dir/segment-cache/cached-navigations/app/with-fallback-params/[slug]/page.tsx +++ b/test/e2e/app-dir/segment-cache/cached-navigations/app/with-fallback-params/[slug]/page.tsx @@ -1,6 +1,5 @@ import { cacheLife } from 'next/cache' import { connection } from 'next/server' -import { setTimeout } from 'timers/promises' import { Suspense } from 'react' export default function Page({ @@ -40,12 +39,10 @@ async function ParamsContent({ // await is deferred to the runtime stage, so this content won't appear // in the static stage. const { slug } = await params - await setTimeout(300) return

Param: {slug}

} async function ConnectionContent() { await connection() - await setTimeout(600) return

Dynamic content ({new Date().toISOString()})

} diff --git a/test/e2e/app-dir/segment-cache/cached-navigations/app/with-static-params/[slug]/page.tsx b/test/e2e/app-dir/segment-cache/cached-navigations/app/with-static-params/[slug]/page.tsx index f9fd1ae90bf1..6637f982b641 100644 --- a/test/e2e/app-dir/segment-cache/cached-navigations/app/with-static-params/[slug]/page.tsx +++ b/test/e2e/app-dir/segment-cache/cached-navigations/app/with-static-params/[slug]/page.tsx @@ -1,5 +1,4 @@ import { connection } from 'next/server' -import { setTimeout } from 'timers/promises' import { Suspense } from 'react' export async function generateStaticParams() { @@ -34,6 +33,5 @@ async function CachedContent() { async function ConnectionContent() { await connection() - await setTimeout(600) return

Dynamic content ({new Date().toISOString()})

} From 20892dd44e1321c13f755f051e48c3cadd75204b Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Thu, 14 May 2026 17:01:48 +0200 Subject: [PATCH 2/3] Fix server action forwarding loop with middleware rewrites (#93792) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reopens #93710 from a branch on this repo so the required deploy test matrix can run — GitHub Actions doesn't expose secrets to fork PRs, and those deploy jobs are required checks. All commits and credit carry forward from the original PR by @elishagreenwald, which built on earlier attempts by @LiamBoz in #84525 and @claygeo in #92053. When middleware rewrites a Server Action POST to a page that doesn't bundle the action, the receiving worker forwards the request to a worker that does. The forwarded request hits middleware again and gets rewritten the same way, so the new receiving worker forwards again — looping until undici's headers timeout (~300s) or memory pressure brings the server down (#84504). The fix is two related changes on the forwarding code path. The first is an `!actionWasForwarded` guard in `action-handler.ts` so a request carrying `x-action-forwarded: 1` is never forwarded a second time. The second is a new branch in `createForwardedActionResponse` that copies the `x-nextjs-action-not-found` header and 404 status onto the originating response when the forwarded worker can't find the action either; without it the client would see a generic "unexpected response" error instead of the `UnrecognizedActionError` that the rest of the framework expects. The e2e test at `test/e2e/app-dir/action-forward-loop` reproduces the scenario. A `proxy.ts` rewrites POSTs to `/with-action` so GETs still render the page normally; the page renders a `
` with an inline server action wrapped in a React error boundary that branches on `unstable_isUnrecognizedActionError`. The test clicks the button and waits for `#action-not-found-error`. Without the loop guard the test times out at 10s, and without the pass-through the boundary catches a generic error and the assertion fails — so both changes are individually load-bearing. This is a short-term fix. The mid-term direction is to remove server-side action forwarding altogether and have the client dispatch each Server Action to the route that actually bundles it, which makes the entire forwarding code path (and this bug surface) unnecessary. #90549 is the draft exploring that approach. Fixes #84504 Closes #93710 Closes #84525 Closes #92053 --------- Co-Authored-By: Elisha Greenwald Co-Authored-By: LiamBoz Co-Authored-By: kmaclip --- .../src/server/app-render/action-handler.ts | 25 ++++++++++++----- .../action-forward-loop.test.ts | 13 +++++++++ .../action-forward-loop/app/layout.tsx | 8 ++++++ .../app/with-action/error-boundary.tsx | 27 +++++++++++++++++++ .../app/with-action/page.tsx | 18 +++++++++++++ .../app/without-action/page.tsx | 3 +++ .../action-forward-loop/next.config.js | 6 +++++ test/e2e/app-dir/action-forward-loop/proxy.ts | 22 +++++++++++++++ 8 files changed, 116 insertions(+), 6 deletions(-) create mode 100644 test/e2e/app-dir/action-forward-loop/action-forward-loop.test.ts create mode 100644 test/e2e/app-dir/action-forward-loop/app/layout.tsx create mode 100644 test/e2e/app-dir/action-forward-loop/app/with-action/error-boundary.tsx create mode 100644 test/e2e/app-dir/action-forward-loop/app/with-action/page.tsx create mode 100644 test/e2e/app-dir/action-forward-loop/app/without-action/page.tsx create mode 100644 test/e2e/app-dir/action-forward-loop/next.config.js create mode 100644 test/e2e/app-dir/action-forward-loop/proxy.ts diff --git a/packages/next/src/server/app-render/action-handler.ts b/packages/next/src/server/app-render/action-handler.ts index 5048ddba6141..a5d75316b2fb 100644 --- a/packages/next/src/server/app-render/action-handler.ts +++ b/packages/next/src/server/app-render/action-handler.ts @@ -279,9 +279,18 @@ async function createForwardedActionResponse( } return new FlightRenderResult(response.body!) - } else { - // Since we aren't consuming the response body, we cancel it to avoid memory leaks - response.body?.cancel() + } + + // Since we aren't consuming the response body, we cancel it to avoid memory leaks + response.body?.cancel() + + // Pass the action-not-found marker through so the client throws + // UnrecognizedActionError instead of a generic "unexpected response". + if (response.headers.get(NEXT_ACTION_NOT_FOUND_HEADER) === '1') { + res.setHeader(NEXT_ACTION_NOT_FOUND_HEADER, '1') + res.setHeader('content-type', 'text/plain') + res.statusCode = 404 + return RenderResult.fromStatic('Server action not found.', 'text/plain') } } catch (err) { // we couldn't stream the forwarded response, so we'll just return an empty response @@ -706,11 +715,15 @@ export async function handleAction({ const actionWasForwarded = Boolean(req.headers['x-action-forwarded']) - if (actionId) { + // Only attempt to forward if this request has not already been forwarded. + // Otherwise middleware that rewrites the action POST can cause the receiving + // worker to forward again, looping indefinitely. + if (actionId && !actionWasForwarded) { const forwardedWorker = selectWorkerForForwarding(actionId, page) - // If forwardedWorker is truthy, it means there isn't a worker for the action - // in the current handler, so we forward the request to a worker that has the action. + // If forwardedWorker is truthy, it means there isn't a worker for the + // action in the current handler, so we forward the request to a worker that + // has the action. if (forwardedWorker) { return { type: 'done', diff --git a/test/e2e/app-dir/action-forward-loop/action-forward-loop.test.ts b/test/e2e/app-dir/action-forward-loop/action-forward-loop.test.ts new file mode 100644 index 000000000000..7d90d696e38d --- /dev/null +++ b/test/e2e/app-dir/action-forward-loop/action-forward-loop.test.ts @@ -0,0 +1,13 @@ +import { nextTestSetup } from 'e2e-utils' + +describe('action forward loop prevention', () => { + const { next } = nextTestSetup({ + files: __dirname, + }) + + it('renders the action-not-found error when a rewrite sends the action POST to a route that does not bundle it', async () => { + const browser = await next.browser('/with-action') + await browser.elementById('run-action').click() + await browser.waitForElementByCss('#action-not-found-error') + }) +}) diff --git a/test/e2e/app-dir/action-forward-loop/app/layout.tsx b/test/e2e/app-dir/action-forward-loop/app/layout.tsx new file mode 100644 index 000000000000..888614deda3b --- /dev/null +++ b/test/e2e/app-dir/action-forward-loop/app/layout.tsx @@ -0,0 +1,8 @@ +import { ReactNode } from 'react' +export default function Root({ children }: { children: ReactNode }) { + return ( + + {children} + + ) +} diff --git a/test/e2e/app-dir/action-forward-loop/app/with-action/error-boundary.tsx b/test/e2e/app-dir/action-forward-loop/app/with-action/error-boundary.tsx new file mode 100644 index 000000000000..b581b6d1e549 --- /dev/null +++ b/test/e2e/app-dir/action-forward-loop/app/with-action/error-boundary.tsx @@ -0,0 +1,27 @@ +'use client' + +import { Component, type ReactNode } from 'react' +import { unstable_isUnrecognizedActionError } from 'next/navigation' + +interface State { + error: unknown +} + +export class ErrorBoundary extends Component<{ children: ReactNode }, State> { + state: State = { error: null } + + static getDerivedStateFromError(error: unknown): State { + return { error } + } + + render() { + const { error } = this.state + if (error === null) { + return this.props.children + } + if (unstable_isUnrecognizedActionError(error)) { + return

Server action not found

+ } + return

Unexpected error

+ } +} diff --git a/test/e2e/app-dir/action-forward-loop/app/with-action/page.tsx b/test/e2e/app-dir/action-forward-loop/app/with-action/page.tsx new file mode 100644 index 000000000000..02195f786900 --- /dev/null +++ b/test/e2e/app-dir/action-forward-loop/app/with-action/page.tsx @@ -0,0 +1,18 @@ +import { ErrorBoundary } from './error-boundary' + +export default function Page() { + return ( +
+

with-action

+ + { + 'use server' + }} + > + + + +
+ ) +} diff --git a/test/e2e/app-dir/action-forward-loop/app/without-action/page.tsx b/test/e2e/app-dir/action-forward-loop/app/without-action/page.tsx new file mode 100644 index 000000000000..d34252e09298 --- /dev/null +++ b/test/e2e/app-dir/action-forward-loop/app/without-action/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return
without-action
+} diff --git a/test/e2e/app-dir/action-forward-loop/next.config.js b/test/e2e/app-dir/action-forward-loop/next.config.js new file mode 100644 index 000000000000..807126e4cf0b --- /dev/null +++ b/test/e2e/app-dir/action-forward-loop/next.config.js @@ -0,0 +1,6 @@ +/** + * @type {import('next').NextConfig} + */ +const nextConfig = {} + +module.exports = nextConfig diff --git a/test/e2e/app-dir/action-forward-loop/proxy.ts b/test/e2e/app-dir/action-forward-loop/proxy.ts new file mode 100644 index 000000000000..a9f2b21f18ac --- /dev/null +++ b/test/e2e/app-dir/action-forward-loop/proxy.ts @@ -0,0 +1,22 @@ +import { NextResponse } from 'next/server' +import type { NextRequest } from 'next/server' + +// Only rewrite action POSTs so the user can still GET the page to see the +// button. The receiving worker is /without-action, which does not bundle the +// action — so without the loop guard, the worker forwards the action back to +// /with-action, which is rewritten here again, and so on. +export function proxy(request: NextRequest) { + if ( + request.method === 'POST' && + request.nextUrl.pathname === '/with-action' + ) { + const url = request.nextUrl.clone() + url.pathname = '/without-action' + return NextResponse.rewrite(url) + } + return NextResponse.next() +} + +export const config = { + matcher: '/with-action', +} From e2b0d2eadccf2a4ebceb8987b02faa9791f849b7 Mon Sep 17 00:00:00 2001 From: Josh Story Date: Thu, 14 May 2026 08:24:31 -0700 Subject: [PATCH 3/3] Instant Insights: When unable to complete validation provide a filename for the unvalidated boundary (#93770) The error message previously did not say which parallel route slot couldn't be validated. Now it poitns to the nearest segment below the validation boundary. The idea is you go look abive this and figure out if it is intentional that this segment isn't rendered in the current context. If so you can ignored the warning (it should ideally not even be presented as a normal error) but if it is unintentional you investigate why you aren't SSRing this slot or you mark the slot as intentionally blocking so it doesn't get validated. --- errors/unrendered-instant-segment.mdx | 72 ++++++++ packages/next/errors.json | 3 +- .../next/src/server/app-render/app-render.tsx | 14 +- .../server/app-render/dynamic-rendering.ts | 34 +++- .../instant-validation/boundary-tracking.tsx | 13 +- .../instant-validation/instant-validation.tsx | 40 ++++- .../app/suspense-in-root/page.tsx | 6 + .../test-firstmod/inter/inner/layout.tsx | 11 ++ .../static/test-firstmod/inter/inner/page.tsx | 10 ++ .../static/test-firstmod/inter/layout.tsx | 11 ++ .../static/test-firstmod/layout.tsx | 12 ++ .../static/test-firstmod/page.tsx | 6 + .../test-multi-unrendered/@sidebar/page.tsx | 5 + .../static/test-multi-unrendered/layout.tsx | 14 ++ .../static/test-multi-unrendered/page.tsx | 5 + .../instant-validation-parallel-slots.test.ts | 37 +++- .../instant-validation.test.ts | 165 +++++++++++++++++- 17 files changed, 426 insertions(+), 32 deletions(-) create mode 100644 errors/unrendered-instant-segment.mdx create mode 100644 test/e2e/app-dir/instant-validation/app/suspense-in-root/static/test-firstmod/inter/inner/layout.tsx create mode 100644 test/e2e/app-dir/instant-validation/app/suspense-in-root/static/test-firstmod/inter/inner/page.tsx create mode 100644 test/e2e/app-dir/instant-validation/app/suspense-in-root/static/test-firstmod/inter/layout.tsx create mode 100644 test/e2e/app-dir/instant-validation/app/suspense-in-root/static/test-firstmod/layout.tsx create mode 100644 test/e2e/app-dir/instant-validation/app/suspense-in-root/static/test-firstmod/page.tsx create mode 100644 test/e2e/app-dir/instant-validation/app/suspense-in-root/static/test-multi-unrendered/@sidebar/page.tsx create mode 100644 test/e2e/app-dir/instant-validation/app/suspense-in-root/static/test-multi-unrendered/layout.tsx create mode 100644 test/e2e/app-dir/instant-validation/app/suspense-in-root/static/test-multi-unrendered/page.tsx diff --git a/errors/unrendered-instant-segment.mdx b/errors/unrendered-instant-segment.mdx new file mode 100644 index 000000000000..7b00a79074b3 --- /dev/null +++ b/errors/unrendered-instant-segment.mdx @@ -0,0 +1,72 @@ +--- +title: An expected segment was not rendered during instant UI validation +--- + +## Why This Error Occurred + +Next.js attempted to validate that navigation to the affected route is able to be rendered instantly without waiting on a server request or data loading. + +In this instance Next.js was unable to verify that the navigation would be instant because something prevented the necessary page data from rendering during validation. + +## Common Causes + +### A parent layout conditionally omits a parallel slot + +If a layout receives multiple slot props (e.g. `children`, `@modal`, `@sidebar`) but only renders some of them based on a condition, the omitted slot's content never renders. + +```tsx +// app/dashboard/layout.tsx +export default function Layout({ + children, + modal, +}: { + children: React.ReactNode + modal: React.ReactNode +}) { + return ( +
+ {children} + { + // `modal` is only rendered conditionally. + showModal ? modal : null + } +
+ ) +} +``` + +### A client component does not render its children during SSR + +If a client component does not render during SSR, any segments it would have rendered as children cannot be validated for instant UI. + +```tsx +import dynamic from 'next/dynamic' + +// This component and its children will not render during SSR. +const ClientOnly = dynamic(() => import('./my-component'), { ssr: false }) + +export default async function Layout({ children }) { + // The children of this layout won't appear in the SSR HTML + // but can render fine on client navigation + return {children} +} +``` + +## How to Fix It + +### If the missing segment is intentional + +If you expect this segment to sometimes not render (for example, a modal slot that only appears on certain routes), you can opt it out of instant UI validation: + +```tsx +// app/dashboard/@modal/page.tsx +export const unstable_instant = false + +export default function ModalPage() { + // ... +} +``` + +### If the missing segment is unintentional + +Check the parent layouts above the reported segment. Make sure every layout renders its slot props (`children` and any named parallel routes). If a client component wraps the segment, ensure it renders its children during SSR. diff --git a/packages/next/errors.json b/packages/next/errors.json index e40b508787a3..9c6cc47701c2 100644 --- a/packages/next/errors.json +++ b/packages/next/errors.json @@ -1243,5 +1243,6 @@ "1242": "Route \"%s\": Next.js encountered %s without an explicit rendering intent.\\n\\nThis value can change between renders, so it must be either prerendered or computed later.\\n\\nWays to fix this:\\n - Render at request time by adding a dynamic data access (e.g. \\`await connection()\\`) before this call\\n - Prerender and cache the value with \\`\"use cache\"\\`\\n - Render the value on the client with \\`\"use client\"\\`\\n%s\\nLearn more: %s", "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" + "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." } diff --git a/packages/next/src/server/app-render/app-render.tsx b/packages/next/src/server/app-render/app-render.tsx index e4e7f8c280f5..8be9b6ca0949 100644 --- a/packages/next/src/server/app-render/app-render.tsx +++ b/packages/next/src/server/app-render/app-render.tsx @@ -6165,8 +6165,8 @@ async function validateInstantConfigs( if (previousBoundaryState) { // We're doing a followup render to better discriminate error types useRuntimeStageForPartialSegments = true - for (const id of previousBoundaryState.requiredIds) { - boundaryState.requiredIds.add(id) + for (const [id, filePath] of previousBoundaryState.requiredIds) { + boundaryState.requiredIds.set(id, filePath) } } @@ -6422,10 +6422,12 @@ async function validateInstantConfigs( // There was no validation to perform at this level debug?.(` No config at depth ${depth}+${currentGroupDepth}, skipping.`) } else { - // Something prevented this level from fully validating but there were no detected errors - if (impairedValidation === null) { - impairedValidation = result - } + // Something prevented this level from fully validating but there + // were no detected errors. Always overwrite — prefer the + // shallowest deferred fallback. If a high-level layout drops + // children, everything below is unreachable; the shallowest + // unrendered segment is closest to the actual cause. + impairedValidation = result } } } diff --git a/packages/next/src/server/app-render/dynamic-rendering.ts b/packages/next/src/server/app-render/dynamic-rendering.ts index 93a3b649e01c..f0e6aeaa7d88 100644 --- a/packages/next/src/server/app-render/dynamic-rendering.ts +++ b/packages/next/src/server/app-render/dynamic-rendering.ts @@ -1449,10 +1449,36 @@ export function getNavigationDisallowedDynamicReasons( const { thrownErrorsOutsideBoundary } = dynamicValidation const rootInstantStack = dynamicValidation.slotStacks[0] if (thrownErrorsOutsideBoundary.length === 0) { - const message = `Route "${workStore.route}": Could not validate \`unstable_instant\` because the target segment was prevented from rendering for an unknown reason.` - const error = rootInstantStack !== null ? rootInstantStack() : new Error() - error.name = 'Error' - error.message = message + const missingFiles: string[] = [] + for (const [id, filePaths] of boundaryState.requiredIds) { + if (!boundaryState.renderedIds.has(id)) { + for (const filePath of filePaths) { + let normalized = filePath + .replace(/^\[project\][\\/]?/, '') + .replace(process.cwd() + '/', '') + .replace(process.cwd() + '\\', '') + missingFiles.push(normalized) + } + } + } + missingFiles.sort() + let message = `Could not validate instant UI because an expected segment was not rendered.` + if (missingFiles.length > 0) { + const label = + missingFiles.length === 1 + ? 'Unrendered segment' + : 'Unrendered segments' + message += + `\n\n${label}:\n${missingFiles.map((p) => ` ${p}`).join('\n')}` + + `\n\nRoute: ${workStore.route}` + + `\n\nThis can happen when you conditionally render a parallel route, for instance a login page when a user is logged out.` + + `\nThis can happen when a client component opts out of rendering during SSR.` + + `\n\nYou can mark this layout as not requiring instant UI with \`export const unstable_instant = false\` if you want to silence this warning.` + + `\n\nLearn more: https://nextjs.org/docs/messages/unrendered-instant-segment` + } else { + message += `\n\nRoute: ${workStore.route}` + } + const error = new Error(message) return error } else if (thrownErrorsOutsideBoundary.length === 1) { const message = `Route "${workStore.route}": Could not validate \`unstable_instant\` because the target segment was prevented from rendering, likely due to the following error.` diff --git a/packages/next/src/server/app-render/instant-validation/boundary-tracking.tsx b/packages/next/src/server/app-render/instant-validation/boundary-tracking.tsx index 411c4f47fa2c..3ac6f51c5417 100644 --- a/packages/next/src/server/app-render/instant-validation/boundary-tracking.tsx +++ b/packages/next/src/server/app-render/instant-validation/boundary-tracking.tsx @@ -1,11 +1,18 @@ export type ValidationBoundaryTracking = { - requiredIds: Set + /** + * Map from boundary id (the SegmentPath where a validation boundary is + * placed) to the file paths of modules inside that boundary's subtree. + * When the boundary spans multiple parallel slots, each slot contributes + * its own first-found module path so all unrendered segments can be + * reported together. + */ + requiredIds: Map renderedIds: Set } export function createValidationBoundaryTracking(): ValidationBoundaryTracking { return { - requiredIds: new Set(), + requiredIds: new Map(), renderedIds: new Set(), } } @@ -13,7 +20,7 @@ export function createValidationBoundaryTracking(): ValidationBoundaryTracking { export function allRequiredBoundariesRendered( state: ValidationBoundaryTracking ): boolean { - for (const id of state.requiredIds) { + for (const id of state.requiredIds.keys()) { if (!state.renderedIds.has(id)) { return false } diff --git a/packages/next/src/server/app-render/instant-validation/instant-validation.tsx b/packages/next/src/server/app-render/instant-validation/instant-validation.tsx index abc756c32306..c865dd6f93af 100644 --- a/packages/next/src/server/app-render/instant-validation/instant-validation.tsx +++ b/packages/next/src/server/app-render/instant-validation/instant-validation.tsx @@ -782,6 +782,13 @@ type TreeResult = { seedData: CacheNodeSeedData requiresInstantUI: boolean createInstantStack: (() => Error) | null + /** First module file path encountered (DFS) inside this subtree, + * or null if unavailable. The boundary's own segment may not own a + * layout/page module (e.g. a directory whose page lives in a + * __PAGE__ child), so we propagate the first one we find upward. + * Surfaced in the missing-boundary fallback message as a pointer + * to "something inside the subtree that didn't render". */ + firstModFilePath: string | null /** How deep in the tree the config was found. Higher = more specific. * Used to prefer deeper configs over shallower ones when multiple * slots have configs. */ @@ -1063,6 +1070,12 @@ export async function createCombinedPayloadAtDepth( let requiresInstantUI = false let createInstantStack: (() => Error) | null = null let bestConfigDepth = -1 + // Collect the first mod file path from each slot's subtree. + // Don't include the boundary segment's own layout/page — that + // file DID render (it wraps the boundary). What didn't render + // is the content inside the children slots. + const slotModFilePaths: string[] = [] + let firstModFilePath: string | null = null for (const parallelRouteKey in parallelRoutes) { const result = await buildNewTreeSeedData( @@ -1074,6 +1087,12 @@ export async function createCombinedPayloadAtDepth( ) slotResults.set(parallelRouteKey, result) slots[parallelRouteKey] = result.seedData + if (result.firstModFilePath !== null) { + slotModFilePaths.push(result.firstModFilePath) + if (firstModFilePath === null) { + firstModFilePath = result.firstModFilePath + } + } if (result.requiresInstantUI) { requiresInstantUI = true if ( @@ -1091,7 +1110,7 @@ export async function createCombinedPayloadAtDepth( // instant config. Unconfigured slot subtrees are allowed to not // render (e.g. conditionally excluded by a layout). if (requiresInstantUI) { - boundaryState.requiredIds.add(path) + boundaryState.requiredIds.set(path, slotModFilePaths) } wrapSlotsWithMarkers(slots, slotResults) @@ -1100,6 +1119,7 @@ export async function createCombinedPayloadAtDepth( seedData: getCacheNodeSeedDataFromSegment(finalSegmentData, slots), requiresInstantUI, createInstantStack, + firstModFilePath, configDepth: bestConfigDepth, } } @@ -1110,6 +1130,7 @@ export async function createCombinedPayloadAtDepth( let requiresInstantUI = false let createInstantStack: (() => Error) | null = null let bestConfigDepth = -1 + let firstModFilePath: string | null = null for (const parallelRouteKey in parallelRoutes) { const result = await buildSharedTreeSeedData( parallelRoutes[parallelRouteKey], @@ -1120,6 +1141,9 @@ export async function createCombinedPayloadAtDepth( ) slotResults.set(parallelRouteKey, result) slots[parallelRouteKey] = result.seedData + if (firstModFilePath === null) { + firstModFilePath = result.firstModFilePath + } if (result.requiresInstantUI) { requiresInstantUI = true if ( @@ -1139,6 +1163,7 @@ export async function createCombinedPayloadAtDepth( seedData: getCacheNodeSeedDataFromSegment(segmentData, slots), requiresInstantUI, createInstantStack, + firstModFilePath, configDepth: bestConfigDepth, } } @@ -1151,7 +1176,9 @@ export async function createCombinedPayloadAtDepth( segmentDepth: number ): Promise { const { parallelRoutes } = parseLoaderTree(lt) - const { mod: layoutOrPageMod } = await getLayoutOrPageModule(lt) + const { mod: layoutOrPageMod, filePath: layoutOrPageFilePath } = + await getLayoutOrPageModule(lt) + const localModFilePath: string | null = layoutOrPageFilePath ?? null const segment = getSegment(lt) const path: SegmentPath = @@ -1237,6 +1264,7 @@ export async function createCombinedPayloadAtDepth( let childrenRequireInstantUI = false let childCreateInstantStack: (() => Error) | null = null let bestChildConfigDepth = -1 + let childFirstModFilePath: string | null = null for (const parallelRouteKey in parallelRoutes) { const childSegmentDepth = segmentConsumesURLDepth(segment) ? segmentDepth + 1 @@ -1250,6 +1278,9 @@ export async function createCombinedPayloadAtDepth( ) slotResults.set(parallelRouteKey, result) slots[parallelRouteKey] = result.seedData + if (childFirstModFilePath === null) { + childFirstModFilePath = result.firstModFilePath + } if (result.requiresInstantUI) { childrenRequireInstantUI = true if ( @@ -1286,10 +1317,15 @@ export async function createCombinedPayloadAtDepth( configDepth = bestChildConfigDepth } + // First mod we find in DFS order: this segment's own layout/page if + // any, otherwise the first non-null we got from a child. + const firstModFilePath = localModFilePath ?? childFirstModFilePath + return { seedData: getCacheNodeSeedDataFromSegment(segmentData, slots), requiresInstantUI, createInstantStack, + firstModFilePath, configDepth, } } diff --git a/test/e2e/app-dir/instant-validation/app/suspense-in-root/page.tsx b/test/e2e/app-dir/instant-validation/app/suspense-in-root/page.tsx index 7244911112e9..7df8b838e4b2 100644 --- a/test/e2e/app-dir/instant-validation/app/suspense-in-root/page.tsx +++ b/test/e2e/app-dir/instant-validation/app/suspense-in-root/page.tsx @@ -257,6 +257,12 @@ export default async function Page() {
  • +
  • + +
  • +
  • + +
  • Disable Validation

    diff --git a/test/e2e/app-dir/instant-validation/app/suspense-in-root/static/test-firstmod/inter/inner/layout.tsx b/test/e2e/app-dir/instant-validation/app/suspense-in-root/static/test-firstmod/inter/inner/layout.tsx new file mode 100644 index 000000000000..af24ebc20175 --- /dev/null +++ b/test/e2e/app-dir/instant-validation/app/suspense-in-root/static/test-firstmod/inter/inner/layout.tsx @@ -0,0 +1,11 @@ +// No config. Just a passthrough wrapper. This sits *inside* the +// validation boundary's subtree (the boundary lands at `inner/` because +// the configured layout is one level shallower). If we pick the +// "first mod inside the boundary subtree" correctly, this layout's +// path is what we'd expect to surface — distinct from the configured +// `test-firstmod/layout.tsx` above. +import { ReactNode } from 'react' + +export default function InnerLayout({ children }: { children: ReactNode }) { + return
    {children}
    +} diff --git a/test/e2e/app-dir/instant-validation/app/suspense-in-root/static/test-firstmod/inter/inner/page.tsx b/test/e2e/app-dir/instant-validation/app/suspense-in-root/static/test-firstmod/inter/inner/page.tsx new file mode 100644 index 000000000000..a64696d76228 --- /dev/null +++ b/test/e2e/app-dir/instant-validation/app/suspense-in-root/static/test-firstmod/inter/inner/page.tsx @@ -0,0 +1,10 @@ +// Config lives here (inside __PAGE__ child of inner). The boundary +// lands at `inner` because of depth iteration, and inner/layout.tsx +// is the boundary segment's local mod. If firstModFilePath correctly +// prefers the boundary segment's own layout, the error should point +// at inner/layout.tsx — not this file. +export const unstable_instant = { level: 'experimental-error' } + +export default function Page() { + return

    test-firstmod inner page (should not render)

    +} diff --git a/test/e2e/app-dir/instant-validation/app/suspense-in-root/static/test-firstmod/inter/layout.tsx b/test/e2e/app-dir/instant-validation/app/suspense-in-root/static/test-firstmod/inter/layout.tsx new file mode 100644 index 000000000000..af24ebc20175 --- /dev/null +++ b/test/e2e/app-dir/instant-validation/app/suspense-in-root/static/test-firstmod/inter/layout.tsx @@ -0,0 +1,11 @@ +// No config. Just a passthrough wrapper. This sits *inside* the +// validation boundary's subtree (the boundary lands at `inner/` because +// the configured layout is one level shallower). If we pick the +// "first mod inside the boundary subtree" correctly, this layout's +// path is what we'd expect to surface — distinct from the configured +// `test-firstmod/layout.tsx` above. +import { ReactNode } from 'react' + +export default function InnerLayout({ children }: { children: ReactNode }) { + return
    {children}
    +} diff --git a/test/e2e/app-dir/instant-validation/app/suspense-in-root/static/test-firstmod/layout.tsx b/test/e2e/app-dir/instant-validation/app/suspense-in-root/static/test-firstmod/layout.tsx new file mode 100644 index 000000000000..4485100649cc --- /dev/null +++ b/test/e2e/app-dir/instant-validation/app/suspense-in-root/static/test-firstmod/layout.tsx @@ -0,0 +1,12 @@ +// No config. Hides {children} to trigger the missing-boundary fallback. +// The config lives deeper (inner/leaf/page.tsx), but this layout is +// what prevents it from rendering. +import { ReactNode } from 'react' + +export default function Layout({ children }: { children: ReactNode }) { + return ( +
    +

    test-firstmod root layout — children intentionally not rendered

    +
    + ) +} diff --git a/test/e2e/app-dir/instant-validation/app/suspense-in-root/static/test-firstmod/page.tsx b/test/e2e/app-dir/instant-validation/app/suspense-in-root/static/test-firstmod/page.tsx new file mode 100644 index 000000000000..29ac5a8f0d79 --- /dev/null +++ b/test/e2e/app-dir/instant-validation/app/suspense-in-root/static/test-firstmod/page.tsx @@ -0,0 +1,6 @@ +// Required so /test-firstmod is a valid route too. The interesting +// case is /test-firstmod/inner/leaf — see the README... actually, see +// the layout's comment. +export default function Page() { + return

    test-firstmod root

    +} diff --git a/test/e2e/app-dir/instant-validation/app/suspense-in-root/static/test-multi-unrendered/@sidebar/page.tsx b/test/e2e/app-dir/instant-validation/app/suspense-in-root/static/test-multi-unrendered/@sidebar/page.tsx new file mode 100644 index 000000000000..a58b90f44257 --- /dev/null +++ b/test/e2e/app-dir/instant-validation/app/suspense-in-root/static/test-multi-unrendered/@sidebar/page.tsx @@ -0,0 +1,5 @@ +export const unstable_instant = { level: 'experimental-error' } + +export default function SidebarPage() { + return

    sidebar page (should not render)

    +} diff --git a/test/e2e/app-dir/instant-validation/app/suspense-in-root/static/test-multi-unrendered/layout.tsx b/test/e2e/app-dir/instant-validation/app/suspense-in-root/static/test-multi-unrendered/layout.tsx new file mode 100644 index 000000000000..83de21458d5b --- /dev/null +++ b/test/e2e/app-dir/instant-validation/app/suspense-in-root/static/test-multi-unrendered/layout.tsx @@ -0,0 +1,14 @@ +// Drops both {children} and {sidebar}. Both slots have configured +// pages, so both boundaries will be missing — the error should list +// both files. +import { ReactNode } from 'react' + +export default function Layout({ + children, + sidebar, +}: { + children: ReactNode + sidebar: ReactNode +}) { + return
    neither slot rendered
    +} diff --git a/test/e2e/app-dir/instant-validation/app/suspense-in-root/static/test-multi-unrendered/page.tsx b/test/e2e/app-dir/instant-validation/app/suspense-in-root/static/test-multi-unrendered/page.tsx new file mode 100644 index 000000000000..5ddefa38b7d7 --- /dev/null +++ b/test/e2e/app-dir/instant-validation/app/suspense-in-root/static/test-multi-unrendered/page.tsx @@ -0,0 +1,5 @@ +export const unstable_instant = { level: 'experimental-error' } + +export default function Page() { + return

    children page (should not render)

    +} diff --git a/test/e2e/app-dir/instant-validation/instant-validation-parallel-slots.test.ts b/test/e2e/app-dir/instant-validation/instant-validation-parallel-slots.test.ts index bd8e8cb95165..9fb825531120 100644 --- a/test/e2e/app-dir/instant-validation/instant-validation-parallel-slots.test.ts +++ b/test/e2e/app-dir/instant-validation/instant-validation-parallel-slots.test.ts @@ -583,22 +583,43 @@ describe('instant validation - parallel slot configs', () => { const browser = await navigateTo(href) await expect(browser).toDisplayCollapsedRedbox(` { - "description": "Route "/suspense-in-root/parallel/conditional-breadcrumbs/show-only-breadcrumbs/unblocked": Could not validate \`unstable_instant\` because the target segment was prevented from rendering for an unknown reason.", + "code": "E1246", + "description": "Could not validate instant UI because an expected segment was not rendered. + + Unrendered segment: + app/suspense-in-root/parallel/conditional-breadcrumbs/show-only-breadcrumbs/unblocked/page.tsx + + Route: /suspense-in-root/parallel/conditional-breadcrumbs/show-only-breadcrumbs/unblocked + + This can happen when you conditionally render a parallel route, for instance a login page when a user is logged out. + This can happen when a client component opts out of rendering during SSR. + + You can mark this layout as not requiring instant UI with \`export const unstable_instant = false\` if you want to silence this warning. + + Learn more: https://nextjs.org/docs/messages/unrendered-instant-segment", "environmentLabel": "Server", "label": "Console Error", - "source": "app/suspense-in-root/parallel/conditional-breadcrumbs/show-only-breadcrumbs/unblocked/page.tsx (1:33) @ unstable_instant - > 1 | export const unstable_instant = { level: 'experimental-error' } - | ^", - "stack": [ - "unstable_instant app/suspense-in-root/parallel/conditional-breadcrumbs/show-only-breadcrumbs/unblocked/page.tsx (1:33)", - ], + "source": null, + "stack": [], } `) } else { const result = await prerender(href) expect(extractBuildValidationError(result.cliOutput)) .toMatchInlineSnapshot(` - "Error: Route "/suspense-in-root/parallel/conditional-breadcrumbs/show-only-breadcrumbs/unblocked": Could not validate \`unstable_instant\` because the target segment was prevented from rendering for an unknown reason. + "Error: Could not validate instant UI because an expected segment was not rendered. + + Unrendered segment: + app/suspense-in-root/parallel/conditional-breadcrumbs/show-only-breadcrumbs/unblocked/page.tsx + + Route: /suspense-in-root/parallel/conditional-breadcrumbs/show-only-breadcrumbs/unblocked + + This can happen when you conditionally render a parallel route, for instance a login page when a user is logged out. + This can happen when a client component opts out of rendering during SSR. + + You can mark this layout as not requiring instant UI with \`export const unstable_instant = false\` if you want to silence this warning. + + Learn more: https://nextjs.org/docs/messages/unrendered-instant-segment at ignore-listed frames Build-time instant validation failed for route "/suspense-in-root/parallel/conditional-breadcrumbs/show-only-breadcrumbs/unblocked". To get a more detailed stack trace and pinpoint the issue, try one of the following: diff --git a/test/e2e/app-dir/instant-validation/instant-validation.test.ts b/test/e2e/app-dir/instant-validation/instant-validation.test.ts index ab37c9562d47..d155428d591b 100644 --- a/test/e2e/app-dir/instant-validation/instant-validation.test.ts +++ b/test/e2e/app-dir/instant-validation/instant-validation.test.ts @@ -3412,15 +3412,24 @@ describe('instant validation', () => { ) await expect(browser).toDisplayCollapsedRedbox(` { - "description": "Route "/suspense-in-root/static/multi-depth-deferred-fallback/inner": Could not validate \`unstable_instant\` because the target segment was prevented from rendering for an unknown reason.", + "code": "E1246", + "description": "Could not validate instant UI because an expected segment was not rendered. + + Unrendered segment: + app/suspense-in-root/static/multi-depth-deferred-fallback/inner/page.tsx + + Route: /suspense-in-root/static/multi-depth-deferred-fallback/inner + + This can happen when you conditionally render a parallel route, for instance a login page when a user is logged out. + This can happen when a client component opts out of rendering during SSR. + + You can mark this layout as not requiring instant UI with \`export const unstable_instant = false\` if you want to silence this warning. + + Learn more: https://nextjs.org/docs/messages/unrendered-instant-segment", "environmentLabel": "Server", "label": "Console Error", - "source": "app/suspense-in-root/static/multi-depth-deferred-fallback/inner/page.tsx (7:33) @ unstable_instant - > 7 | export const unstable_instant = { level: 'experimental-error' } - | ^", - "stack": [ - "unstable_instant app/suspense-in-root/static/multi-depth-deferred-fallback/inner/page.tsx (7:33)", - ], + "source": null, + "stack": [], } `) } else { @@ -3429,7 +3438,19 @@ describe('instant validation', () => { ) expect(extractBuildValidationError(result.cliOutput)) .toMatchInlineSnapshot(` - "Error: Route "/suspense-in-root/static/multi-depth-deferred-fallback/inner": Could not validate \`unstable_instant\` because the target segment was prevented from rendering for an unknown reason. + "Error: Could not validate instant UI because an expected segment was not rendered. + + Unrendered segment: + app/suspense-in-root/static/multi-depth-deferred-fallback/inner/page.tsx + + Route: /suspense-in-root/static/multi-depth-deferred-fallback/inner + + This can happen when you conditionally render a parallel route, for instance a login page when a user is logged out. + This can happen when a client component opts out of rendering during SSR. + + You can mark this layout as not requiring instant UI with \`export const unstable_instant = false\` if you want to silence this warning. + + Learn more: https://nextjs.org/docs/messages/unrendered-instant-segment at ignore-listed frames Build-time instant validation failed for route "/suspense-in-root/static/multi-depth-deferred-fallback/inner". To get a more detailed stack trace and pinpoint the issue, try one of the following: @@ -3442,6 +3463,134 @@ describe('instant validation', () => { }) }) + describe('unrendered segment file reporting', () => { + it('reports the shallowest unrendered file, not the configured file', async () => { + // Config is on inter/inner/page.tsx. The shallowest boundary + // iteration lands at test-firstmod, and inter/layout.tsx is the + // first child mod that didn't render — not the configured page, + // and not test-firstmod/layout.tsx (which DID render but dropped + // its children). + if (isNextDev) { + const browser = await navigateTo( + '/suspense-in-root/static/test-firstmod/inter/inner' + ) + await expect(browser).toDisplayCollapsedRedbox(` + { + "code": "E1246", + "description": "Could not validate instant UI because an expected segment was not rendered. + + Unrendered segment: + app/suspense-in-root/static/test-firstmod/inter/layout.tsx + + Route: /suspense-in-root/static/test-firstmod/inter/inner + + This can happen when you conditionally render a parallel route, for instance a login page when a user is logged out. + This can happen when a client component opts out of rendering during SSR. + + You can mark this layout as not requiring instant UI with \`export const unstable_instant = false\` if you want to silence this warning. + + Learn more: https://nextjs.org/docs/messages/unrendered-instant-segment", + "environmentLabel": "Server", + "label": "Console Error", + "source": null, + "stack": [], + } + `) + } else { + const result = await prerender( + '/suspense-in-root/static/test-firstmod/inter/inner' + ) + expect(extractBuildValidationError(result.cliOutput)) + .toMatchInlineSnapshot(` + "Error: Could not validate instant UI because an expected segment was not rendered. + + Unrendered segment: + app/suspense-in-root/static/test-firstmod/inter/layout.tsx + + Route: /suspense-in-root/static/test-firstmod/inter/inner + + This can happen when you conditionally render a parallel route, for instance a login page when a user is logged out. + This can happen when a client component opts out of rendering during SSR. + + You can mark this layout as not requiring instant UI with \`export const unstable_instant = false\` if you want to silence this warning. + + Learn more: https://nextjs.org/docs/messages/unrendered-instant-segment + at ignore-listed frames + Build-time instant validation failed for route "/suspense-in-root/static/test-firstmod/inter/inner". + To get a more detailed stack trace and pinpoint the issue, try one of the following: + - Start the app in development mode by running \`next dev\`, then open "/suspense-in-root/static/test-firstmod/inter/inner" in your browser to investigate the error. + - Rerun the production build with \`next build --debug-prerender\` to generate better stack traces. + Stopping prerender due to instant validation errors." + `) + expect(result.exitCode).toBe(1) + } + }) + + it('reports the boundary segment layout when multiple slots are dropped', async () => { + // Layout drops both {children} and {sidebar}. Both have + // configured pages, but only one boundary id is created (at + // the segment level, covering all slots). The reported file is + // the boundary segment's own layout — the nearest mod to the + // boundary placement. + if (isNextDev) { + const browser = await navigateTo( + '/suspense-in-root/static/test-multi-unrendered' + ) + await expect(browser).toDisplayCollapsedRedbox(` + { + "code": "E1246", + "description": "Could not validate instant UI because an expected segment was not rendered. + + Unrendered segments: + app/suspense-in-root/static/test-multi-unrendered/@sidebar/page.tsx + app/suspense-in-root/static/test-multi-unrendered/page.tsx + + Route: /suspense-in-root/static/test-multi-unrendered + + This can happen when you conditionally render a parallel route, for instance a login page when a user is logged out. + This can happen when a client component opts out of rendering during SSR. + + You can mark this layout as not requiring instant UI with \`export const unstable_instant = false\` if you want to silence this warning. + + Learn more: https://nextjs.org/docs/messages/unrendered-instant-segment", + "environmentLabel": "Server", + "label": "Console Error", + "source": null, + "stack": [], + } + `) + } else { + const result = await prerender( + '/suspense-in-root/static/test-multi-unrendered' + ) + expect(extractBuildValidationError(result.cliOutput)) + .toMatchInlineSnapshot(` + "Error: Could not validate instant UI because an expected segment was not rendered. + + Unrendered segments: + app/suspense-in-root/static/test-multi-unrendered/@sidebar/page.tsx + app/suspense-in-root/static/test-multi-unrendered/page.tsx + + Route: /suspense-in-root/static/test-multi-unrendered + + This can happen when you conditionally render a parallel route, for instance a login page when a user is logged out. + This can happen when a client component opts out of rendering during SSR. + + You can mark this layout as not requiring instant UI with \`export const unstable_instant = false\` if you want to silence this warning. + + Learn more: https://nextjs.org/docs/messages/unrendered-instant-segment + at ignore-listed frames + Build-time instant validation failed for route "/suspense-in-root/static/test-multi-unrendered". + To get a more detailed stack trace and pinpoint the issue, try one of the following: + - Start the app in development mode by running \`next dev\`, then open "/suspense-in-root/static/test-multi-unrendered" in your browser to investigate the error. + - Rerun the production build with \`next build --debug-prerender\` to generate better stack traces. + Stopping prerender due to instant validation errors." + `) + expect(result.exitCode).toBe(1) + } + }) + }) + describe('disabling validation', () => { it('in a layout', async () => { if (isNextDev) {