Skip to content
Merged
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
72 changes: 72 additions & 0 deletions errors/unrendered-instant-segment.mdx
Original file line number Diff line number Diff line change
@@ -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 (
<main>
{children}
{
// `modal` is only rendered conditionally.
showModal ? modal : null
}
</main>
)
}
```

### 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 <ClientOnly>{children}</ClientOnly>
}
```

## 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.
3 changes: 2 additions & 1 deletion packages/next/errors.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
19 changes: 17 additions & 2 deletions packages/next/src/build/templates/app-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1922,14 +1922,29 @@ export async function handler(
const transformer = new TransformStream<Uint8Array, Uint8Array>()
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,
})
Expand Down
25 changes: 19 additions & 6 deletions packages/next/src/server/app-render/action-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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',
Expand Down
26 changes: 20 additions & 6 deletions packages/next/src/server/app-render/app-render.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -6153,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)
}
}

Expand Down Expand Up @@ -6410,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
}
}
}
Expand Down
34 changes: 30 additions & 4 deletions packages/next/src/server/app-render/dynamic-rendering.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.`
Expand Down
Original file line number Diff line number Diff line change
@@ -1,19 +1,26 @@
export type ValidationBoundaryTracking = {
requiredIds: Set<string>
/**
* 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<string, string[]>
renderedIds: Set<string>
}

export function createValidationBoundaryTracking(): ValidationBoundaryTracking {
return {
requiredIds: new Set(),
requiredIds: new Map(),
renderedIds: new Set(),
}
}

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
}
Expand Down
Loading
Loading