fix(app-render): scope force-dynamic workStore mutations to their own segment#92064
fix(app-render): scope force-dynamic workStore mutations to their own segment#92064ossaidqadri wants to merge 1 commit intovercel:canaryfrom
Conversation
… segment Nested pages with `dynamic = "force-dynamic"` were mutating the shared WorkStore and leaking that state up to ancestor layouts, causing layouts explicitly marked `force-static` to incorrectly enter the PPR postpone path. Capture each segment's own config before children recurse, save and restore the four dynamic fields around the Promise.all traversal, and use the captured local value in the PPR check instead of workStore. Fixes vercel#86424
|
Allow CI Workflow Run
Note: this should only be enabled once the PR is ready to go and can only be enabled by a maintainer |
There was a problem hiding this comment.
Pull request overview
Fixes an App Router rendering bug where dynamic = "force-dynamic" in a nested page mutated shared WorkStore state and incorrectly forced ancestor layouts into the PPR postpone path, even when those layouts were configured force-static.
Changes:
- Capture the current segment’s
forceDynamicvalue before child traversal and use it for the PPR postpone decision. - Save/restore
WorkStoredynamic-related fields around the parallel route (Promise.all) traversal to avoid child-to-parent state leakage. - Add a new e2e regression fixture + test to validate static parent layout + dynamic nested page behavior under PPR.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/next/src/server/app-render/create-component-tree.tsx | Scopes WorkStore dynamic config mutations per segment and updates the PPR postpone check to avoid ancestor contamination. |
| test/e2e/app-dir/force-dynamic-scoping/force-dynamic-scoping.test.ts | New regression test asserting a force-static layout prerenders at build time while a nested force-dynamic page renders at runtime (prod). |
| test/e2e/app-dir/force-dynamic-scoping/fixtures/next.config.js | Enables experimental.ppr for the regression fixture. |
| test/e2e/app-dir/force-dynamic-scoping/fixtures/app/layout.js | Root layout for the new fixture app. |
| test/e2e/app-dir/force-dynamic-scoping/fixtures/app/getSentinelValue.tsx | Shared sentinel used to distinguish build-time vs runtime rendering. |
| test/e2e/app-dir/force-dynamic-scoping/fixtures/app/force-static-parent/layout.js | Static parent layout segment under test. |
| test/e2e/app-dir/force-dynamic-scoping/fixtures/app/force-static-parent/force-dynamic-child/page.js | Dynamic nested page segment under test. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // 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 |
There was a problem hiding this comment.
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.
| // 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. |
There was a problem hiding this comment.
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.
Summary
export const dynamic = "force-dynamic"in a nested page was mutating the sharedWorkStoreand leaking state up to ancestor layouts, causing layouts explicitly markedforce-staticto incorrectly enter the PPR postpone pathforceDynamic,forceStatic,dynamicShouldError,fetchCache) around thePromise.alltraversal, and uses the captured local value in the PPR check instead ofworkStore.forceDynamicFixes #86424
Changes
packages/next/src/server/app-render/create-component-tree.tsx— 4 surgical changes to scope mutations per-segmenttest/e2e/app-dir/force-dynamic-scoping/— new e2e test fixture verifying aforce-staticlayout prerenders at buildtime even when a nested page isforce-dynamicTest plan
NEXT_SKIP_ISOLATE=1 NEXT_TEST_MODE=start pnpm testheadless test/e2e/app-dir/force-dynamic-scoping/NEXT_SKIP_ISOLATE=1 NEXT_TEST_MODE=start pnpm testheadless test/e2e/app-dir/app-static/NEXT_SKIP_ISOLATE=1 NEXT_TEST_MODE=start pnpm testheadless test/e2e/app-dir/dynamic-data/