Skip to content

fix(app-router): only full-page cache routes Next.js classifies as static or SSG - #3451

Draft
james-elicx wants to merge 22 commits into
mainfrom
isr-query/01-static-eligibility
Draft

james-elicx wants to merge 22 commits into
mainfrom
isr-query/01-static-eligibility

Conversation

@james-elicx

@james-elicx james-elicx commented Sep 24, 2026 •

Copy link
Copy Markdown
Member

First PR in the stack that brings ISR query-key handling in line with Next.js. It classifies App pages the way Next.js does before any caching.

Problem

vinext ISR-cached some App pages that Next.js treats as dynamic (ƒ) and renders per request:

  • a dynamic-segment route that sets revalidate (or a cacheLife) but has no generateStaticParams;
  • a route whose generateStaticParams is only on a parent segment, not at the last dynamic segment;
  • an edge-runtime route.

These routes also returned 405 for non-action POST/PUT. In Next.js they accept those methods.

Fix

  • hasAppPageGenerateStaticParamsAtLastDynamicSegment ports Next.js's breadth-first segment walk from buildAppStaticPaths (N11):
    • a dynamic segment without generateStaticParams clears the SSG flag, and one with it sets the flag;
    • segments are deduped on (name, file), so a parallel slot's copy of a layout-less dynamic folder doesn't count twice.
  • isAppPageStaticEligible combines that result with the route's config:
    • force-dynamic, revalidate = 0 and the edge runtime make a route dynamic;
    • force-static and dynamic = "error" make it static;
    • otherwise the route is static if it has no dynamic segment, or if it passed the walk.
    • The runtime is resolved the way Next.js does: the page wins, then the nearest layout, and slots are ignored.
  • Dispatch gates on that classification. For a route that isn't static or SSG, vinext:
    • doesn't read or write the cache and doesn't capture RSC;
    • sends Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate;
    • reports cacheable: false at admission;
    • skips the 405 guard.
  • cacheComponents (PPR) routes keep their current behaviour and are out of scope for this stack.
  • A direct intercepted RSC response renders its source route, so it takes the source's classification, not the matched target's. Classifying it by the intercepting route's own tree, as Next.js does, is in fix(app-router): classify and render an intercepted page by its intercepting tree #3473.

The build report and the deploy-time route classification (edge runtime, listed paths) come later in the stack. Until then the Response Store adapter still looks these routes up, finds nothing, and the app never writes them.

Behaviour change

A dynamic-segment App route that relies on ISR now needs generateStaticParams. Returning [] opts every path into on-demand ISR, as in Next.js. I added it to these routes so they keep caching:

  • the cf-app-basic CDN-stage fixtures;
  • the tpr-demo product page;
  • the apps/web benchmark pages.

ppr-impact-demo's static-to-dynamic/[slug] gets the path its checked-in manifest already lists.

Tests

  • Unit tests:
    • tests/app-segment-config.test.ts: the walk (page, layout at the last dynamic segment, parent-only, slots, BFS order), runtime precedence, and eligibility;
    • tests/app-page-dispatch.test.ts and tests/app-page-render.test.ts: routes that aren't static or SSG are never read or written and get the never-cache header;
    • tests/app-page-method.test.ts: the method guard.
  • Response Store e2e (packages/cloudflare/tests/response-store-adapter.e2e.test.ts): a new /dynamic-segment/[slug] demo route renders per request, sends the never-cache header for HTML and RSC, and leaves no metadata entry. 21/21 pass locally.
  • Deployed e2e (tests/e2e/cloudflare-workers/cache-prewarm.spec.ts): the same check against the response-store, workers-cache and KV deployments.

…atic or SSG

A dynamic-segment App page with revalidate but no generateStaticParams at
its last dynamic segment, or one on the edge runtime, is dynamic in
Next.js: it renders per request, is never ISR-cached, and accepts POST.
vinext cached it. Classify routes with the Next.js segment walk and skip
the cache read, write and RSC capture for routes that are not static or
SSG, returning the never-cache Cache-Control header.
@pkg-pr-new

pkg-pr-new Bot commented Sep 24, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

@vinext/cloudflare

npm i https://pkg.pr.new/@vinext/cloudflare@3451

create-vinext-app

npm i https://pkg.pr.new/create-vinext-app@3451

@vinext/types

npm i https://pkg.pr.new/@vinext/types@3451

vinext

npm i https://pkg.pr.new/vinext@3451

@cloudflare/workers-response-store

npm i https://pkg.pr.new/@cloudflare/workers-response-store@3451

commit: b2b9419

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues
Please review exact head 73626bbdebdc0ae6e043a41da96d0e3b33b9e188 without modifying or pushing the branch. Report all actionable findings within your 8-minute time limit.

@github-actions

github-actions Bot commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Performance benchmarks

Compared b2b9419 against base 42d3ab9 using alternating same-runner rounds. Next.js was unchanged and skipped.

1 improved · 0 regressed · 5 within ±1.5%

Scenario Framework Baseline Current Change
Client bundle size (gzip) vinext 142.3 KB 142.3 KB ⚫ +0.0%
Client entry size (gzip) vinext 130.4 KB 130.4 KB ⚫ +0.0%
Dev server cold start vinext 2.34 s 2.31 s 🟢 -1.5%
Production build time vinext 2.49 s 2.50 s ⚫ +0.2%
RSC entry closure size (gzip) vinext 128.2 KB 129.6 KB ⚫ +1.0%
Server bundle size (gzip) vinext 226.9 KB 228.1 KB ⚫ +0.5%

View detailed results and traces

🟢 improvement · 🔴 regression · ⚫ change below 1.5% · paired base/head

if (options.isStaticGenerationEdgeRuntime) return false;
if (options.dynamicConfig === "force-dynamic" || options.revalidateSeconds === 0) return false;
if (options.dynamicConfig === "force-static" || options.dynamicConfig === "error") return true;
return !options.isDynamicRoute || options.hasGenerateStaticParams;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P2] Preserve paths fully generated by an ancestor

This fallback makes every nested dynamic route whose last dynamic segment lacks its own generateStaticParams ineligible. Next.js also creates prerenderedRoutes when an earlier generator returns values for all pathname params (hadAllParamsGenerated in build/static-paths/app.ts). For example, /[category]/[slug] is SSG when the [category] layout returns { category: "docs", slug: "intro" }, even with no generator below it. Vinext already preserves and validates those extra keys in generateChainedStaticParams, but this check now skips cache reads/writes (and the 405 policy) for that generated path. Please include the concrete generated-path result in eligibility rather than relying only on the export's position.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Intentionally out of scope, and documented in 95171ca. Classifying this shape needs generateStaticParams output. vinext only calls the generator on a miss for dynamicParams = false routes, and calling it on every request just to classify the route would tax the common version of this shape, which is ƒ in Next.js. So all backends treat it as dynamic. vinext stores less here, never more. The workaround is valid Next.js too: add generateStaticParams to the last dynamic segment (even return []).

@ask-bonk

ask-bonk Bot commented Sep 24, 2026

Copy link
Copy Markdown
Contributor

Posted 1 actionable inline finding. Targeted tests passed: 203 tests across 4 files.

github run

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues
Please review exact head 95171ca0c757b0f13004e8f22957bf27caa972a8 without modifying or pushing the branch. Report all actionable findings within your 8-minute time limit.

if (!branch) continue;
const branchSegments = branch.routeSegments ?? [];
// The slot folder is a child of the main-tree folder at this depth.
const slotDepth = routeSegments.length - branchSegments.length + 1;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P2] Use the slot owner position instead of deriving depth

This inference breaks when the main and slot branches have different URL-invisible route groups. For example, with app/(main)/[id]/page.tsx exporting generateStaticParams, and a root app/@panel/[id]/page.tsx without it, Next.js BFS visits the slot [id] before the main page and finishes with the page export, so the route is SSG. Here routeSegments.length - branchSegments.length counts (main) as though it were above the slot, placing the slot [id] at the same depth as the main page; the later slot segment clears the flag and makes the route dynamic. Thread the slot’s actual ownerTreePosition into this collector (the route graph already records it) rather than reconstructing ownership from branch lengths.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 8bd74be. The static-params walk now visits segments in Next.js's loader tree order, using each slot's real ownerTreePosition from the route graph instead of working ownership out from branch lengths. That covers the (main) route group case.

@ask-bonk

ask-bonk Bot commented Sep 24, 2026

Copy link
Copy Markdown
Contributor

Posted 1 actionable inline finding. Targeted tests passed: 203 tests across 4 files.

github run

… tree order

Visit slots with a matched page before children at each level and default
slots after, place each slot under the folder that owns it, and read only a
default slot's default module. Require isStaticEligible on every cache policy
and finalizer, and keep the route-cache-identity fixture ISR-cached.
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues
Please review exact head 8bd74be8678062673c2e7d29d078aebfa894478e without modifying or pushing the branch. Report all actionable findings within your 8-minute time limit.

isForceDynamic: options.isForceDynamic,
isForceStatic: options.isForceStatic,
isProduction: options.isProduction,
isStaticEligible: options.isStaticEligible,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P1] Mark ineligible routes dynamic in client cache metadata

This flag only disables the server cache policy. The RSC path below still sets staleTimePending because no dynamic API was observed, and the HTML metadata getter still returns kind: "static". A pure dynamic-segment page without generateStaticParams will therefore seed/reuse client navigation data with the static/pending TTL even though this PR now classifies it as dynamic and rerenders it on every server request. Include !isStaticEligible in the known-dynamic stale-time and initial-navigation-kind decisions so client navigations do not reuse this output as static.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in af80b3a.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Reverted in 7e27732: marking ineligible routes dynamic for the client broke explicit full-prefetch reuse (segment-cache-client-params E2E). Eligibility stays a server read/write gate only; client metadata keeps following force-dynamic or observed dynamic usage.

"layoutTreePositions" | "layouts" | "page" | "parallelBranches" | "routeSegments"
>,
): AppPageStaticParamsWalkSegment[] {
const routeSegments = options.routeSegments ?? [];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P2] Model a default children branch instead of the synthetic URL path

For routes materialized only by a nested slot, routeSegments describes the slot URL while route.page can be the owner’s default.tsx (childrenSlot.state === "default"). This collector currently places that default page after every synthetic URL segment. In Next.js the loader tree places __DEFAULT__ directly below the slot owner, so BFS can visit it before a deeper slot dynamic segment. For example, a root children default exporting generateStaticParams plus @feed/[id]/page without it ends false in Next’s walk but true here, enabling full-page caching. Thread the children-slot state/owner into this collector and emit __DEFAULT__ at its actual tree position.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in af80b3a.

isStaticGenerationEdgeRuntime: __isEdgeRuntime(
__resolveAppPageStaticGenerationRuntime([
...route.layouts.map((layout) => layout?.runtime),
route.page?.runtime,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P2] Resolve runtime from the page that materialized a slot-only route

Ignoring slots is correct for an ordinary children page, but vinext also synthesizes URLs whose only matched page is a nested slot and whose route.page is merely the children fallback. For app/@feed/foo/page.tsx with runtime = "edge", this checks the fallback module instead of the slot page, so the static URL remains eligible and can be cached; Next builds that route from the slot page file and sees its edge runtime. Preserve the originating page for synthetic routes (or otherwise select the active slot leaf) when resolving static-generation runtime.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in af80b3a.

const response = await request.get(url);
const headers = response.headers();
expect(response.ok(), JSON.stringify({ backend, headers })).toBe(true);
expect(headers["cache-control"]).toBe(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P1] Do not require one backend’s exact no-store spelling

This spec runs for response-store, workers-cache, and kv. The Workers Cache completed-response admission intentionally rewrites a denied response through responseWithCachePolicy(..., null), which emits no-store, must-revalidate, so the workers-cache deploy job will fail this exact equality even though the response is correctly uncacheable. Assert that the value contains no-store (as the force-dynamic check above does), or branch by backend.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in af80b3a.

@ask-bonk

ask-bonk Bot commented Sep 25, 2026

Copy link
Copy Markdown
Contributor

Posted 4 actionable inline findings. Targeted tests passed: 206 tests across 4 files. Worktree unchanged.

github run

… Next.js

- Treat routes that can't be static as known-dynamic for the client
  navigation stale time, not only force-dynamic ones.
- Walk a slot-only route's children default directly under its owner.
- Read the runtime of a slot-only route from the slot page that
  materializes it.
- Accept any no-store spelling in the deployed spec, since Workers Cache
  rewrites denied responses.
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues
Please review exact head af80b3ae2562d22b56de7bfa7c12152a80c8daa4 without modifying or pushing the branch. Report all actionable findings within your 8-minute time limit.

@github-actions

Copy link
Copy Markdown
Contributor
Example Preview Production Original
app-router-cloudflare preview production
pages-router-cloudflare preview production
app-router-playground preview production original
realworld-api-rest preview production
nextra-docs-template preview production
benchmarks preview production
hackernews preview production original
response-store-demo preview production
workers-cache preview production
kv preview production
static-export preview production
web preview production

let treePath = [...slotPath];
branchSegments.forEach((name, index) => {
treePath = [...treePath, 0];
const layout = configLayoutsByPosition.get(index + 1);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P2] Include route-group layouts when the slot has no URL segments

configLayoutsByPosition can contain positions that have no corresponding branchSegments entry. In particular, discoverParallelSlots represents app/[id]/@details/(variant)/page.tsx with routeSegments: [] and the (variant)/layout.tsx at position 1. This loop never emits that layout, so if it exports generateStaticParams, Next.js's BFS sets the SSG flag after visiting [id], while vinext returns false and disables full-page caching. Walk all config-layout tree positions, including URL-invisible route-group levels, rather than only looking them up while iterating routeSegments.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 2ee6ad0.

): unknown[] {
const layoutRuntimes = (options.layouts ?? []).map((layout) => layout?.runtime);
const childrenDefaultPosition = resolveChildrenDefaultTreePosition(options.childrenSlot);
if (childrenDefaultPosition === null) return [...layoutRuntimes, options.page?.runtime];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P1] Detect slot-only routes whose children come from a catch-all

A slot-only synthetic route can have childrenSlot.state === "active" because its children fall through to a sibling catch-all (app/[...catchAll]/page.tsx plus app/@slot/baz/page.tsx; this shape is already covered in app-route-graph.test.ts). In that case this early return reads the catch-all page's runtime and ignores the slot page that materialized /baz. If the slot page exports runtime = "edge", the static /baz route remains eligible and can be full-page cached, unlike Next.js. Preserve the materializing-slot identity independently of whether children uses a default or catch-all, and use that slot's runtime here.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 2ee6ad0.

@ask-bonk

ask-bonk Bot commented Sep 25, 2026

Copy link
Copy Markdown
Contributor

Posted 2 actionable inline findings. Targeted tests passed: 211 tests across 4 files. Worktree unchanged.

github run

…slot's runtime

- Walk a slot's route-group layouts even when its page has no URL
  segments.
- Mark routes that only a nested slot page materializes, and read their
  static-generation runtime from that slot page whatever renders the
  children.
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues
Please review exact head 2ee6ad0cb304a23dc60e5bf2a1318cc0250973f4 without modifying or pushing the branch. Report all actionable findings within your 8-minute time limit.

@ask-bonk

ask-bonk Bot commented Sep 25, 2026

Copy link
Copy Markdown
Contributor

@james-elicx Bonk workflow was cancelled.

View workflow run · To retry, trigger Bonk again.

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

Please review exact head 2ee6ad0cb304a23dc60e5bf2a1318cc0250973f4 without modifying or pushing the branch. Report all actionable findings within your 8-minute time limit.

// Next.js renders these routes per request, so they are dynamic before any
// request API runs. Their payload is never stored, even when a cacheLife
// resolves during the render.
const isKnownDynamicRoute = options.isForceDynamic || !options.isStaticEligible;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P1] Keep full-prefetch payloads reusable for config-dynamic routes

Treating every ISR-ineligible route as known-dynamic also changes the client-cache metadata, and the existing segment-cache-client-params E2E now fails consistently: after a full hover prefetch of /.../clothing/1, clicking issues another full RSC request instead of consuming that payload. This route has dynamic segments and no generateStaticParams, but consists only of client components; Next.js still allows the explicit full prefetch to be reused even though the server must not persist it in the full-page cache. Please keep isStaticEligible as the server read/write gate without using it to force the dynamic stale-time/initial-navigation classification; those should remain based on force-dynamic or observed dynamic usage.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 7e27732.

@ask-bonk

ask-bonk Bot commented Sep 25, 2026

Copy link
Copy Markdown
Contributor

Posted 1 actionable inline finding. Targeted unit tests passed: 213 tests across 4 files. Worktree unchanged.

github run

…static

Full-page cache eligibility is a server read/write gate. Using it for the
client stale time and initial-navigation kind stopped explicit full
prefetches of client-only dynamic-segment pages from being reused.
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

Please review exact head 7e27732034a62132f415d198ae2c331a54225f44 without modifying or pushing the branch. Report all actionable findings within your 8-minute time limit.

@ask-bonk

ask-bonk Bot commented Sep 25, 2026

Copy link
Copy Markdown
Contributor

Posted 3 actionable inline findings. Targeted tests passed: 241 tests across 4 files. Worktree unchanged.

github run

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

Please review exact head 03bab6cf449eb5b653b4110338b6748d9a8f66a9 without modifying or pushing the branch. Report all actionable findings within your 8-minute time limit.

// Loading the intercepting tree for admission is like loading the
// route's own modules: generated params are still checked only once the
// cache misses, so a cache hit never runs generateStaticParams.
await loadAppPageCurrentRouteIntercept(currentRouteIntercept, route, routeHasSlot);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P1] Skip loading a slot intercept that is not attached

A real slot intercept can resolve to a concrete current-route variant that does not contain intercept.slotKey; __resolveRouteStaticEligible correctly classifies that case as the unchanged source tree. This call nevertheless runs __pageLoader/layout/not-found loaders before that attachment check. On the newly added cache-hit path, an unused intercept module with a top-level throw or fetch can now fail or delay the request before isrGet, even though the cached/source response does not render that branch. Gate loadAppPageCurrentRouteIntercept on the sibling-page sentinel or routeHasSlot(route, currentRouteIntercept.slotKey) (as the owner-default loader already does).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in bac7c59 and d7df05b. loadAppPageCurrentRouteIntercept returns early unless the intercept is attached (the sibling-page sentinel, or routeHasSlot(route, slotKey)), so on the cache gate an unattached slot intercept evaluates none of its page, not-found, layout or owner-default modules before isrGet; eligibility still classifies it as the unchanged source tree. The render path (resolveAppPageIntercept), the pre-render probe (resolveAppPageProbeIntercept), and the rerender target used by stale regeneration and core RSC hits (resolveAppPageInterceptionRerenderTarget, via an optional routeHasSlot) apply the same check. Tests cover the HIT, the miss, the stale regeneration and the probe fan-out with throwing loaders.

@ask-bonk

ask-bonk Bot commented Sep 25, 2026

Copy link
Copy Markdown
Contributor

Posted 1 actionable inline finding. Targeted tests passed: 148 tests across 2 files. Worktree unchanged.

github run

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

Please review exact head d7df05b6febb677ebdd3b27029d43face4b76552 without modifying or pushing the branch. Report all actionable findings within your 8-minute time limit.

const interceptOwner = resolveParallelBranchOwnerPosition(slot, sourceDepth);
const parallelBranches = replaceSiblingsWithDefaults(interceptOwner, options.slotIndex);
parallelBranches[options.slotIndex] = {
configLayouts: interceptLayouts,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P1] Preserve common slot layouts above the intercept marker

interceptLayouts contains only layouts at or below the marker (the route scanner starts collectInterceptingPages at interceptDir), so replacing slot.configLayouts wholesale drops layouts on the shared slot ancestry. For example, app/@modal/gallery/layout.tsx with dynamic = "force-dynamic" plus app/@modal/gallery/(.)photo/page.tsx loses the gallery config here; both eligibility passes can then classify the intercepted tree static and allow its RSC response/cache variant without no-store, unlike Next.js. Keep the prefix of slot.configLayouts before the marker and append the intercept layouts (with corresponding tree positions).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 3d4c26d. A slot intercept's tree now keeps the slot's layouts in the folders between the slot root and the intercept marker, found by the route scanner and emitted as interceptAncestorLayouts with tree positions. This is the same walk Next.js's loader tree does (app_structure.rs directory_tree_to_loader_tree_internal). I didn't keep a prefix of slot.configLayouts: those come from the source's active slot page, which in this example doesn't exist (/gallery's @modal renders its default), and they can come from a different subtree, such as a catch-all. Tests cover the tree shape and dynamic mode (app-segment-config), the codegen, dispatch's classification (private, no-store), and the module loader.

@ask-bonk

ask-bonk Bot commented Sep 25, 2026

Copy link
Copy Markdown
Contributor

Posted 1 actionable inline finding. Focused tests passed: 183 tests across 2 files. Worktree unchanged.

github run

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

Please review exact head 3d4c26d1e478fa1f3549dbc36a1f7ae61cacca2d without modifying or pushing the branch. Report all actionable findings within your 8-minute time limit.

Comment thread tests/app-router-isr-codegen.test.ts Fixed
Comment thread tests/app-router-isr-codegen.test.ts Fixed
targetPattern: ${JSON.stringify(ir.targetPattern)},
sourceMatchPattern: ${JSON.stringify(ir.sourceMatchPattern)},
sourcePageSegments: ${JSON.stringify(ir.sourcePageSegments)},
interceptAncestorLayouts: ${moduleArray(ir.ancestorLayoutPaths?.length ?? 0)},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P1] Include ancestor layouts in the rendered intercept tree

These modules now flow into static eligibility, but the actual slot override and head inputs still consume only interceptLayouts (buildSlotOverrides and resolveActiveParallelRouteHeadInputs). For app/@modal/gallery/layout.tsx plus app/@modal/gallery/(.)photo/page.tsx, Next.js wraps the intercepting page and metadata with the gallery layout; vinext loads and classifies that module but never renders it. This also lets a static-enabling config from an unrendered layout influence cache admission. Merge the ancestor modules and their segment positions into the render/head intercept branch as well (or expose one combined layout chain).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 23c73f7. The slot's layouts above the marker now start the intercept's own layout chain in the route scanner: for @modal/gallery/(.)photo that's layoutPaths [@modal/gallery/layout.tsx, @modal/gallery/(.)photo/layout.tsx] with segments [["gallery"], ["gallery", "(.)photo"]]. Slot overrides, the slot branch render, head/metadata inputs, boundary render, static eligibility, the request-stage forceDynamic scan and the action-owner manifest all read that one list, so the gallery layout wraps the intercepted page and its metadata as in Next.js (next-app-loader createSubtreePropsFromSegmentPath nests each folder's layout). The separate interceptAncestorLayouts plumbing from 3d4c26d is removed. Tests: app-page-element-builder (render and metadata include the gallery layout), app-route-graph and the codegen test.

@ask-bonk

ask-bonk Bot commented Sep 25, 2026

Copy link
Copy Markdown
Contributor

Posted 1 actionable inline finding. Targeted tests passed: 218 tests across 4 files. Worktree unchanged.

github run

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

Please review exact head 23c73f72d022b81d9b9bdf71982a6231ff9fac8f without modifying or pushing the branch. Report all actionable findings within your 8-minute time limit.

);
const cachePolicy = {
isDraftMode,
isStaticEligible,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P2] Use the effective interception eligibility for this early response

A current-route interception may already have changed renderStaticEligible above, but a generated-param miss is stamped with the base route’s isStaticEligible. For example, a static /feed/[slug] with dynamicParams = false plus a force-dynamic current-route intercept skips the cache, then an unknown slug returns this 404 without no-store, even though the same effective tree’s normal render is never-cache. Pass renderStaticEligible here so both the rendered and plain miss responses use the tree that was classified for admission.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in c2a6e98: the generated-params miss block's cache policy now uses renderStaticEligible, so both the rendered and the plain 404 follow the tree classified for admission. The test covers your example (a force-dynamic current-route intercept over a static /feed/[slug] with dynamicParams = false): 404 with the never-cache header, no cache read and no write. It fails without the fix.

@ask-bonk

ask-bonk Bot commented Sep 25, 2026

Copy link
Copy Markdown
Contributor

Posted 1 actionable inline finding. Targeted tests passed: 318 tests across 4 files. Worktree unchanged.

github run

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

Please review exact head c2a6e98bdb2f1dd4ffa15e53f0c5354618dec57e without modifying or pushing the branch. Report all actionable findings within your 8-minute time limit.

// response's cacheability too, like the render it replaces.
const cachePolicy = {
isDraftMode,
isStaticEligible: renderStaticEligible,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P2] Classify uncached current-route intercept misses

renderStaticEligible is only updated in the shouldReadCache block above. When cache reads are disabled (notably isProduction: false, but also the interception-cache bypass), generated-param validation still runs and returns here before resolveAppPageIntercept can classify the active tree. A static /feed/[slug] with dynamicParams = false and a force-dynamic current-route intercept therefore sends an unknown-slug 404 without no-store in dev, while its normal render gets the never-cache policy. The new test inherits isProduction: true, so it misses this branch. Resolve/classify the attached intercept before stamping this early response as well.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 9d4c852. The current-route intercept is now matched, loaded (only when the route has its slot) and classified into renderStaticEligible whether or not the cache is read, before the generated-params miss returns. The cache read is still gated on shouldReadCache, and generated params are still checked only after a miss. Tests cover your example in dev and with the interception-cache bypass, and both fail without the fix. A guard test checks that an intercept the route has no slot for still isn't loaded.

@ask-bonk

ask-bonk Bot commented Sep 25, 2026

Copy link
Copy Markdown
Contributor

Posted 1 actionable inline finding. tests/app-page-dispatch.test.ts passed (103 tests). Worktree unchanged.

github run

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

Please review exact head 9d4c852fbfb3838fbdd405c2f46f3aec93820753 without modifying or pushing the branch. Report all actionable findings within your 8-minute time limit.

interceptSegment,
...path.relative(interceptRoot, path.dirname(page)).split(path.sep).filter(Boolean),
];
const ancestorLayouts = slotRootDir

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P1] Preserve ancestor layouts for sibling intercepts too

This only collects layouts above the marker when slotRootDir exists, so slot-less sibling intercepts still lose route-group/folder layouts between the source owner and the marker. For example, app/feed/page.tsx plus app/feed/(shell)/layout.tsx exporting dynamic = "force-dynamic" and app/feed/(shell)/(.)photo/page.tsx produces an intercept with no (shell) layout. The sibling tree is therefore rendered/classified without that layout and its RSC response can omit no-store, whereas Next.js includes every folder on the intercepting app path. Derive the sibling ancestor chain from the source owner/marker path as well.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

The interception work moved out of this PR into #3473, which now fixes this in 4714822: a sibling intercept below its source page's folder starts its branch, layout chain and loading positions at that folder, so app/feed/(shell)/layout.tsx (and a loading.tsx there) wrap app/feed/(shell)/(.)photo and feed its classification. Route-graph and codegen tests cover it.

@ask-bonk

ask-bonk Bot commented Sep 25, 2026

Copy link
Copy Markdown
Contributor

Posted 1 actionable inline finding. Targeted tests passed; worktree unchanged.

github run

…er tree

The walk put slots that matched a page before children and slots that render
default after it, as the webpack loader does. Next.js's default build
(Turbopack) puts children first and every slot after it in folder-name order,
and collectAppPageSegments walks that tree breadth-first. A main [id] page
with generateStaticParams next to a slot [id] segment without it ended static
here, but dynamic in Next.js, so the route could be full-page cached.
… loader tree

Slot runtimes counted only for a route that a slot page materializes, so
app/page.tsx with an edge app/@panel/page.tsx left / static-eligible and
full-page cacheable. Next.js's default build (Turbopack) derives the runtime
from the whole loader tree: every parallel branch (children, matched slots
and default slots) merges at each node, and the node's own layout, page or
default fills only an unset value. The runtime now comes from the same tree
the generateStaticParams walk builds, which drops the materializedBySlot
route flag.
…issing default export response

The 500 for a page module without a default export returned before any
place that applies the route's cache policy, so an edge-runtime or
dynamic-segment page sent it without Cache-Control.
@james-elicx
james-elicx removed this pull request from stack #3471 September 25, 2026 18:50
@james-elicx
james-elicx force-pushed the isr-query/01-static-eligibility branch from 9d4c852 to b2b9419 Compare September 25, 2026 18:50
@james-elicx
james-elicx added this pull request to stack #3474 September 25, 2026 18:51
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

Please review exact head b2b9419564b49ae42ce4b5e3e9dbd7d31f131da8 without modifying or pushing the branch. Report all actionable findings within your 8-minute time limit.

@ask-bonk

ask-bonk Bot commented Sep 25, 2026

Copy link
Copy Markdown
Contributor

LGTM!

github run

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants