-
Notifications
You must be signed in to change notification settings - Fork 30.7k
fix(app-render): scope force-dynamic workStore mutations to their own segment #92064
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: canary
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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) | ||
| } | ||
|
|
@@ -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( | ||
|
|
@@ -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 | ||
|
|
||
| // Convert the parallel route map into an object after all promises have been resolved. | ||
| let parallelRouteProps: { [key: string]: React.ReactNode } = {} | ||
| let parallelRouteCacheNodeSeedData: { | ||
|
|
@@ -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
|
||
| if ( | ||
| workStore.isStaticGeneration && | ||
| workStore.forceDynamic && | ||
| segmentForceDynamic && | ||
| experimental.isRoutePPREnabled | ||
| ) { | ||
| return createSeedData( | ||
|
|
||
| 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> | ||
| } |
| 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') | ||
| } | ||
| }) | ||
| }) |
There was a problem hiding this comment.
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 leavesworkStore.forceDynamic/forceStatic/dynamicShouldError/fetchCachemutated for the remainder of the render, which can leak incorrect state into upstream error handling or subsequent logic. Wrap thePromise.alltraversal in atry/finally(with the restore infinally) so restoration happens even on errors.