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
33 changes: 25 additions & 8 deletions packages/next/src/server/app-render/create-component-tree.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,10 @@ async function createComponentTreeInternal(
workStore.fetchCache = layoutOrPageMod?.fetchCache
}

// Capture this segment's own dynamic config after applying it, before
// children recursion mutates workStore further.
const segmentForceDynamic = workStore.forceDynamic

if (typeof layoutOrPageMod?.revalidate !== 'undefined') {
validateRevalidate(layoutOrPageMod?.revalidate, workStore.route)
}
Expand Down Expand Up @@ -519,6 +523,14 @@ async function createComponentTreeInternal(
tree,
})

// Save workStore dynamic fields before recursing into children. Each child
// call mutates these fields for its own segment; we restore them afterward so
// that the parent segment's state is not contaminated by its children.
const savedForceDynamic = workStore.forceDynamic
const savedForceStatic = workStore.forceStatic
const savedDynamicShouldError = workStore.dynamicShouldError
const savedFetchCache = workStore.fetchCache

// TODO: Combine this `map` traversal with the loop below that turns the array
// into an object.
const parallelRouteMap = await Promise.all(
Expand Down Expand Up @@ -710,6 +722,14 @@ async function createComponentTreeInternal(
)
)

// Restore workStore to this segment's values after children have finished.
// Children set their own dynamic config during their traversal; without this
// restore, the parent's PPR check (below) would see the last child's state.
workStore.forceDynamic = savedForceDynamic
workStore.forceStatic = savedForceStatic
workStore.dynamicShouldError = savedDynamicShouldError
workStore.fetchCache = savedFetchCache
Comment on lines +725 to +731
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

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

The workStore dynamic fields are restored only after await Promise.all(...) completes successfully. If any child traversal throws/rejects, this function exits early and leaves workStore.forceDynamic/forceStatic/dynamicShouldError/fetchCache mutated for the remainder of the render, which can leak incorrect state into upstream error handling or subsequent logic. Wrap the Promise.all traversal in a try/finally (with the restore in finally) so restoration happens even on errors.

Copilot uses AI. Check for mistakes.

// Convert the parallel route map into an object after all promises have been resolved.
let parallelRouteProps: { [key: string]: React.ReactNode } = {}
let parallelRouteCacheNodeSeedData: {
Expand Down Expand Up @@ -773,16 +793,13 @@ async function createComponentTreeInternal(
// replace it with a node that will postpone the render. This ensures that the
// postpone is invoked during the react render phase and not during the next
// render phase.
// @TODO this does not actually do what it seems like it would or should do. The idea is that
// if we are rendering in a force-dynamic mode and we can postpone we should only make the segments
// that ask for force-dynamic to be dynamic, allowing other segments to still prerender. However
// because this comes after the children traversal and the static generation store is mutated every segment
// along the parent path of a force-dynamic segment will hit this condition effectively making the entire
// render force-dynamic. We should refactor this function so that we can correctly track which segments
// need to be dynamic
// Only postpone if THIS segment explicitly set force-dynamic. We use
// segmentForceDynamic (captured before children ran) rather than
// workStore.forceDynamic so that a child's force-dynamic does not cause
// ancestor segments to postpone unnecessarily.
Comment on lines +796 to +799
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

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

This comment says postponing happens only when this segment explicitly set force-dynamic, but segmentForceDynamic is captured from workStore.forceDynamic which can be true due to an ancestor segment (since forceDynamic is not cleared when a child is force-static/auto). Consider rewording to reflect that this is the force-dynamic state in effect for the current segment (captured before rendering children), not necessarily an explicit local config.

Copilot uses AI. Check for mistakes.
if (
workStore.isStaticGeneration &&
workStore.forceDynamic &&
segmentForceDynamic &&
experimental.isRoutePPREnabled
) {
return createSeedData(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { PageSentinel } from '../../getSentinelValue'

export const dynamic = 'force-dynamic'

export default function ForceDynamicPage() {
return (
<div>
<PageSentinel />
</div>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { LayoutSentinel } from '../getSentinelValue'

export const dynamic = 'force-static'

export default function ForceStaticLayout({ children }) {
return (
<div>
<LayoutSentinel />
{children}
</div>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
const { PHASE_PRODUCTION_BUILD } = require('next/constants')

export function getSentinelValue() {
if (typeof process === 'object') {
if (process.env.NEXT_PHASE === PHASE_PRODUCTION_BUILD) {
return 'at buildtime'
}
}
return 'at runtime'
}

export function LayoutSentinel() {
return <div id="layout">{getSentinelValue()}</div>
}

export function PageSentinel() {
return <div id="page">{getSentinelValue()}</div>
}
7 changes: 7 additions & 0 deletions test/e2e/app-dir/force-dynamic-scoping/fixtures/app/layout.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export default function RootLayout({ children }) {
return (
<html>
<body>{children}</body>
</html>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
module.exports = {
experimental: {
ppr: true,
},
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { nextTestSetup } from 'e2e-utils'

describe('force-dynamic-scoping', () => {
const { next, isNextDev, skipped } = nextTestSetup({
files: __dirname + '/fixtures',
skipDeployment: true,
})

if (skipped) {
return
}

/**
* Regression test for https://github.com/vercel/next.js/issues/86424
*
* A layout marked `force-static` must remain statically rendered even when a
* nested page is marked `force-dynamic`. Before the fix, `workStore` mutations
* from the child leaked back to the parent, causing all ancestor layouts to
* incorrectly enter the PPR postpone path as if they were force-dynamic.
*/
it('renders a force-static layout at buildtime when a nested page is force-dynamic', async () => {
const $ = await next.render$(
'/force-static-parent/force-dynamic-child',
{},
{}
)

if (isNextDev) {
// In dev every segment re-renders on every request.
expect($('#layout').text()).toBe('at runtime')
expect($('#page').text()).toBe('at runtime')
} else {
// The layout is force-static: it must be prerendered at build time.
expect($('#layout').text()).toBe('at buildtime')
// The page is force-dynamic: it must be rendered at runtime.
expect($('#page').text()).toBe('at runtime')
}
})
})
Loading