Skip to content

perf(app-router): run a dynamicParams = false route's generators only on a cache miss - #4634

Open
james-elicx wants to merge 3 commits into
fix/route-handler-dynamic-params-stackedfrom
perf/dynamic-params-check-on-cache-miss
Open

james-elicx wants to merge 3 commits into
fix/route-handler-dynamic-params-stackedfrom
perf/dynamic-params-check-on-cache-miss

Conversation

@james-elicx

@james-elicx james-elicx commented Nov 9, 2026 •

Copy link
Copy Markdown
Member

Stacked on #4125.

The two PRs below run a dynamicParams = false route's generateStaticParams per request, because vinext has no build-time params list to check against. This PR makes the generators run only when the cache can't answer the request.

What changes

  • Cache hits. The routing-time check for apps with fallback rewrites ran the generators before the cache, on every request. It's gone. The page and route handler dispatches already check after their cache read. On a miss, the dispatch now returns a bare 404 without rendering anything. Routing discards it and resumes with the fallback rewrites after the one that reached the rejected route. Next.js does the same when a route rejects its params (NoFallbackError): it routes again, skipping the route it already tried.
  • Route-tree prefetches. When no fallback rewrite could claim the URL, a /_tree prefetch no longer runs the generators. vinext builds the tree from the route definition only, so it holds the route's segments and nothing rendered for the param. The prefetch of the page's content still gets the 404. Next.js answers 404 for the tree itself, so this is a documented difference. When a fallback rewrite could claim the URL, the tree's check still runs. On a miss, routing resumes, so a claiming rewrite answers with its own route's tree, as in Next.js, and an unclaimed URL gets the unmatched route's 404.
  • Mixed-method route handlers. A handler exporting POST, PUT, DELETE, PATCH or OPTIONS isn't cached. In production it no longer runs its generator, and every param is served. Next.js does the same once its generator lists a param; when the generator lists none, Next.js answers 404 for every param. That difference is documented too.
Request to a dynamicParams = false route Generator runs, before After
Cache hit, app with fallback rewrites 1 0
/_tree prefetch (one for each link the client sees), no fallback rewrite left to claim it 1 0
/_tree prefetch, app with fallback rewrites 1 1
Mixed-method route handler, production 1 0
Cache miss 1 1

Keeping the 404 behaviour

  • A rejected param still never renders. The dispatch always checks on a cache miss, and when routing resumes it returns a bare 404 before any layout, generateMetadata, page or handler runs.
  • Before routing resumes, the dispatch resets what it set up for the rejected route: navigation params, root params, headers context and fetch state. The handler then clears the trace route, root params, navigation params and the route that shared-cache admission was classifying. The discarded 404 isn't marked as a route miss, so it doesn't keep the fallback response out of the shared cache. A route handler puts back a draft-mode Set-Cookie it took, so the fallback response carries it.
  • Only admitted params are stored. A fallback target's entry is keyed by ?__vinext_rewrite=, so it is separate from the rejected path's entry.
  • Each resume starts at a later fallback rewrite, so it ends when the list runs out; a URL no later rewrite claims gets the unmatched route's 404. An earlier fallback rewrite never sees the rejected route's URL, and a later one can't route the request back to the rejected route, even with params it lists.
  • Resuming drops the cache bypass routing set for the rejected match, then works it out again for the new match.
  • Server Action and progressive-action page renders don't resume, because the action has already run for the matched page. They keep the 404.

Limitations

  • The split request/response-stage path still renders the miss as a 404 even when there are fallback rewrites, as it did before. That stage has no route modules.
  • An intercepted route's miss doesn't resume. Routing ends at the intercepted route, so it keeps the 404.
  • A few page answers come before the params check and don't resume: the 405 for a non-GET request to a static page, the 500 for a page without a default export, and the cacheability identity probe's 204. A fallback rewrite can't claim those requests. The routing-time check this PR removes did resume them; main never did.

…aticParams in production

A route handler that also exports POST, PUT, DELETE, PATCH or OPTIONS is never
cached, so the check ran its generator on every request only to find the one
case Next.js 404s: a generator that returns no params. Once it lists a param,
Next.js serves every param, so production now serves these handlers without
running the generator. Dev still checks them.
@james-elicx
james-elicx added this pull request to stack #4632 November 9, 2026 17:11
@james-elicx james-elicx changed the title perf/dynamic params check on cache miss perf(app-router): run a dynamicParams = false route's generators only on a cache miss Nov 9, 2026
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

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

@pkg-pr-new

pkg-pr-new Bot commented Nov 9, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

@vinext/cloudflare

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

create-vinext-app

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

@vinext/types

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

vinext

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

@cloudflare/workers-response-store

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

commit: e528191

if (replace || (route === undefined && nextRoute !== undefined)) {
route = nextRoute;
onRoute?.(nextRoute);
if (nextRoute !== undefined) onRoute?.(nextRoute);

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.

The new reset updates this function’s local route to undefined, but deliberately skips onRoute. That callback is how app-response-stage-entry.ts retains the latest route when callback throws (the destructuring assignment never runs in that case). If routing rejects a dynamic-param match, resets, and then a fallback rewrite/render throws, the generated 500 therefore carries the rejected route in X-Vinext-Trace-Route. Notify the callback on resets too (and widen its parameter to string | undefined) so the error path cannot retain stale trace metadata.

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 63941a9: captureFrameworkRequestRoute now reports every route change to onRoute, including the reset, and its parameter is string | undefined, so a response stage whose resumed render throws no longer names the rejected route. Covered in tests/request-tracing.test.ts.

@ask-bonk

ask-bonk Bot commented Nov 9, 2026

Copy link
Copy Markdown
Contributor

Review: 1 findings.

Posted 1 actionable inline warning.

github run

@github-actions

github-actions Bot commented Nov 9, 2026

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-assets-cache preview production
static-assets-pages preview production
static-assets-pages-i18n preview production
static-export preview production
web preview production

@github-actions

github-actions Bot commented Nov 9, 2026 •

Copy link
Copy Markdown
Contributor

Performance benchmarks

Compared c106ea0 against base a6a18de using alternating same-runner rounds. Next.js was unchanged and skipped.

0 improved · 0 regressed · 7 within ±1.5%

Scenario Framework Baseline Current Change
Client bundle size (gzip) vinext 153.5 KB 153.5 KB ⚫ +0.0%
Client entry size (gzip) vinext 141.7 KB 141.7 KB ⚫ +0.0%
Dev server cold start vinext 2.73 s 2.71 s ⚫ -0.7%
Dev server cold start (large Pages Router app) vinext 21.81 s 21.79 s ⚫ -0.1%
Production build time vinext 3.28 s 3.25 s ⚫ -1.0%
RSC entry closure size (gzip) vinext 142.8 KB 142.8 KB ⚫ +0.0%
Server bundle size (gzip) vinext 253.2 KB 253.1 KB ⚫ -0.0%

View detailed results and traces

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

@james-elicx
james-elicx force-pushed the perf/dynamic-params-check-on-cache-miss branch from 87795d3 to 94d9531 Compare November 9, 2026 17:21
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

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

setCurrentFetchRevalidate(null);
setCurrentForceDynamicFetchDefault(false);
// Routing discards this response, so it marks nothing for the request.
if (options.resumeRoutingAfterMiss?.()) return new Response(null, { status: 404 });

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.

setHeadersContext(requestHeadersContext) immediately above clears draftModeCookieHeader (headers.ts:454-462). If middleware called draftMode().disable(), isDraftMode is false, so an unlisted param reaches this new handoff; fallback routing then proceeds after the outgoing disable cookie was erased, and the client remains in draft mode. Preserve and restore the pending draft transition across this response discard, as the route-handler path 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.

Checked this one: dispatchAppPage runs inside runWithFetchDedupe, which gives the dispatch a shallow child of the unified request state, so setHeadersContext there clears only the child's draftModeCookieHeader. The handler's state keeps middleware's pending header and the resumed dispatch inherits it. 998601f adds coverage for it (middleware draftMode().disable() with and without force-static, asserting the pending disable cookie survives the resume); the assertion holds without any change to the dispatch, so I left the code as is.

@ask-bonk

ask-bonk Bot commented Nov 9, 2026

Copy link
Copy Markdown
Contributor

Review: 1 findings.

Posted 1 actionable inline warning.

github run

@james-elicx
james-elicx force-pushed the perf/dynamic-params-check-on-cache-miss branch from 94d9531 to f8d7c70 Compare November 9, 2026 17:30
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

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

});
setRootParams(null);
match = null;
return finishRouting();

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.

The resumed pass starts from the rejected route's already-rewritten cleanPathname but replays the fallback list from index zero. An earlier rule that did not match the original URL can now match this intermediate destination and incorrectly claim the request. For example, with rules /bridge/:slug -> /final/:slug followed by /start/:slug -> /bridge/:slug, a rejected /bridge/[slug] match for /start/x is incorrectly rerouted to /final/x; sequential fallback routing has already passed the first rule. Resume after the rule that produced the rejected match, or restart from the original routing state while excluding that match.

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 92af6d0. Routing now records the fallback rewrite that produced the match and resumes at the next one, keeping the rewritten URL. Next.js does the same: after NoFallbackError it routes again and skips the output it already invoked, so a later rule sees the rewritten pathname and an earlier one never matches it. Because each resume starts at a later rule, routing can resume more than once and still ends when the list runs out. Tests: "resumes routing after the fallback rewrite that reached the rejected route" (your /bridge case) and "resumes routing at each later fallback rewrite".

params: {},
});
setRootParams(null);
match = null;

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.

Clearing only match leaves isInterceptionMatch/isFinalRouteMatch true from a rejected interception source. If the fallback rewrite then reaches a Pages-only route, renderPagesForMatchKind() still sees the stale finality at line 2545, skips renderPagesFallback, and returns 404. Reset or recompute the interception-derived finality before rerouting.

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 92af6d0. An intercepted match no longer resumes. Routing ends at the intercepted route (isFinalRouteMatch, the same finality that already skips afterFiles and fallback rewrites for it), so its miss keeps the dispatch's 404 and never sees the stale finality. The PR body lists this as a limitation. Test: "doesn't resume routing for an intercepted route".

shouldResumeRoutingAfterMiss = false;
// The rejected match no longer names the request.
resetFrameworkRequestRoute();
resetRouteCacheability();

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.

The resume reset leaves the rejected route's cache-bypass state latched. A normalized/raw-path Route Handler can set bypassInterceptionContextCache and the outer interceptionResponseUncacheable, reject its params, and then fall back to a cacheable page whose own route check would not set either flag. That page consequently skips ISR and line 3497 replaces its public policy with never-cache. Restore the pre-dispatch interception/cache-bypass baseline before the resumed finishRouting().

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 92af6d0. Before applying a match's own bypass, routing records bypassInterceptionContextCache and the outer uncacheable flag. The handler now tracks that flag locally and reports it outward. Resuming restores both, and the resumed pass works them out again for the new match: fallback rewrites still invalidate the interception proof, and the raw-spelling check runs for the new match. Test: "drops the rejected route's cache bypass when routing resumes" (a route handler match with a raw-spelling bypass resumes to a cacheable page).

@ask-bonk

ask-bonk Bot commented Nov 9, 2026

Copy link
Copy Markdown
Contributor

Review: 3 findings.

Posted 3 actionable inline warnings. Targeted tests passed: 784 tests across 5 files.

github run

@james-elicx
james-elicx force-pushed the perf/dynamic-params-check-on-cache-miss branch from f8d7c70 to 92af6d0 Compare November 9, 2026 17:46
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

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

}
const rewrittenPublicFileResponse = servePublicFileIfMatched();
if (rewrittenPublicFileResponse) return rewrittenPublicFileResponse;
match = matchCleanPathname();

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.

This rematches every route from the rewritten pathname, including the route output that just rejected its params. A later fallback can therefore re-enter that same output with different params and serve it: for example, /start/:slug -> /closed/:slug followed by /closed/:slug -> /closed/listed lets a rejected /closed/[slug] request serve listed. Next.js keeps an invokedOutputs set across the retry and checkTrue() skips dynamic routes whose route.page was already invoked, so that output cannot claim the request again. Track rejected route outputs across resumes and exclude them from matchCleanPathname() (while still allowing later rewrites to reach other routes).

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 c106ea0. Routing now keeps the routes a dispatch rejected across resumes, and a resumed fallback rewrite that matches one of them leaves the request unmatched. Like checkTrue() skipping an invoked route.page, this holds whatever the params, so later rewrites can still reach other routes, including Pages routes. Test: "doesn't route a resumed request back to the rejected route" (your /closed/listed case).

@ask-bonk

ask-bonk Bot commented Nov 9, 2026

Copy link
Copy Markdown
Contributor

Review: 1 findings.

Posted 1 actionable inline warning. Prior findings are fixed.

github run

@james-elicx
james-elicx force-pushed the perf/dynamic-params-check-on-cache-miss branch from 92af6d0 to c106ea0 Compare November 9, 2026 17:54
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

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

@ask-bonk

ask-bonk Bot commented Nov 9, 2026

Copy link
Copy Markdown
Contributor

LGTM!

github run

@james-elicx
james-elicx marked this pull request as ready for review November 9, 2026 18:08

@clanky-the-clanker clanky-the-clanker Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Superseded by the review of 8365884b.

This review, of c106ea00

💬 Approved with comments — One WARNING: in apps with fallback rewrites, a route-tree prefetch for a param the route doesn't list now gets the rejected route's tree, even when a fallback rewrite claims that URL. There is also one INFO about the docs.

Findings: 1 warning · 1 info (2 are inline comments on the diff)

The decision is advisory: this review is a comment and never approves, blocks or requests changes on the pull request.

🔧 Fix with your agent

Paste the following into your coding agent to investigate and fix these issues.

Fix the following code review issues found in pull request #4634.

Before making changes, review each issue and present a brief summary table:
- For each finding, state whether you agree, disagree, or need clarification
- If you disagree (e.g., the fix would require disproportionate changes
  for minimal ROI, or the finding is factually incorrect), explain why
- If you need to clarify something before deciding, ask those questions
- Then share your plan for which issues to tackle and in what order

## WARNING (1)

### [Code Quality] A route-tree prefetch for a rejected param that a fallback rewrite claims now returns the rejected route's tree instead of the fallback target's.
- **File:** packages/vinext/src/server/app-rsc-handler.ts:2975

## INFO (1)

### [Documentation] The docs say a fallback rewrite can claim a rejected param without noting that the split request/response-stage path still answers 404.
- **File:** docs/reference/differences.mdx:76

📋 Code Quality: ⚠️ 1 warning

A route-tree prefetch skips fallback rewrites for a rejected dynamicParams = false param.

Recommendation: 📝 Minor Comments

  • [WARNING] A route-tree prefetch for a rejected param that a fallback rewrite claims now returns the rejected route's tree instead of the fallback target's. (packages/vinext/src/server/app-rsc-handler.ts:2975)
View full review

Route-tree prefetch now ignores fallback rewrites (packages/vinext/src/server/app-rsc-handler.ts:2975)

Before this change, in an app with fallback rewrites, routing ran the generators for a /_tree prefetch. A rejected param fell through to the fallback rewrites, so a URL that a rewrite claims got the rewrite target's tree.

Now the tree is built from route as soon as the route matches. The dispatch never runs, so routing never resumes. For the same URL:

  • the /_tree prefetch returns the rejected [slug] route's segments;
  • the content request resumes routing and renders the fallback target.

The client can end up holding a route tree for a different route than the one it navigates to.

The test that covered tree prefetches with fallback rewrites was removed, and the new fallback-rewrite tests have no /_tree case.

Two possible fixes:

  • Keep the short-circuit only when options.configRewrites.fallback is empty or the route isn't dynamicParams = false.
  • Or document that tree prefetches ignore fallback rewrites.

Add a /_tree test with a claiming fallback rewrite either way.


📝 Documentation: 💬 1 info

The fallback-rewrite statement doesn't mention the split request/response-stage limitation.

Recommendation: 💬 Optional Nits

  • [INFO] The docs say a fallback rewrite can claim a rejected param without noting that the split request/response-stage path still answers 404. (docs/reference/differences.mdx:76)
View full review
  • docs/reference/differences.mdx:76 says a fallback rewrite can still claim a param that dynamicParams = false rejects. It doesn't say that the split request/response-stage path still answers 404 for that miss, a limitation the pull request description lists.

✅ All other checks passed

🔒 Security: ✅ LGTM

  • Resumed routing: a rejected route can't be routed back into, because rejectedRoutes persists across resumes. The cache-bypass and interception-uncacheable flags are restored before routing resumes.
  • No render for a rejected param: the dispatch checks params on every cache miss, so a rejected param never reaches a page or handler render. Server Action requests and intercepted routes don't resume.
  • Draft mode: the restore only puts back a Set-Cookie the dispatch had taken.
  • Mixed-method route handlers: skipping the generator in production is a documented conformance difference, not an access-control boundary.

📖 AGENTS.md: ✅ LGTM

This pull request doesn't change tooling or structure in a way that calls for an AGENTS.md update.


⚡ Performance: ✅ LGTM

Cache hits and /_tree prefetches no longer run the generators. The resume loop ends because the fallback rewrite index only moves forward. The discarded 404 body is cancelled before routing resumes.


Reviewed commit: c106ea00

// Next.js 404s a dynamicParams = false miss's route tree. vinext doesn't run
// the route's generators for each prefetched link: the tree holds only the
// route's segments, and the prefetch of its content gets the 404.
if (isRouteTreePrefetchRequest(request) && !route.routeHandler) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ WARNING · Code Quality

A route-tree prefetch for a rejected param that a fallback rewrite claims now returns the rejected route's tree instead of the fallback target's.

This short-circuit answers /_tree from the matched route before the dispatch can reject the param and resume routing with the fallback rewrites. In an app with fallback rewrites, a prefetch for a param that dynamicParams = false rejects gets the rejected route's segments. The content request for the same URL renders the fallback target, so the client can hold a tree for a different route than the one it navigates to. Before this change, the routing-time check sent such a request to the fallback rewrite. Consider skipping the short-circuit for dynamicParams = false routes when options.configRewrites.fallback is non-empty, or document the difference and add a /_tree test with a claiming fallback rewrite.

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 e528191. When a fallback rewrite could still claim the URL, the tree's check runs again. On a miss it now resumes routing instead of answering 404, so a claiming fallback rewrite answers with its own route's tree, as in Next.js, and an unclaimed URL gets the unmatched route's 404. The tree is answered from the route alone only when no fallback rewrite is left, or for an intercepted route. Tests: "answers a route-tree prefetch with the tree of the fallback rewrite that claims the URL", "answers a route-tree prefetch that no fallback rewrite claims with the unmatched route's 404" and "answers a route-tree prefetch from the route alone without fallback rewrites". The docs and PR body describe the split.

Comment thread docs/reference/differences.mdx Outdated
Next.js also treats an otherwise static route as SSG when a layout above its last dynamic segment returns every param from `generateStaticParams`. vinext doesn't run the generator to classify requests, so it renders such a route on every request unless it exports `dynamic = "force-static"` or `"error"`. Adding `generateStaticParams` to the last dynamic segment, even one that returns `[]`, makes the route static in both.

Under `dynamicParams = false`, Next.js checks a request against the params its build generated and runs no `generateStaticParams` per request. For a route the check applies to, vinext runs the route's generators when the cache doesn't answer a request, including a request for params the route doesn't list. In an app with `fallback` rewrites, it runs them while routing for every request to such a route, outside draft mode, so a rewrite can claim a param the route doesn't list, as in Next.js. A child segment's generator runs only for the parent params that match the request, but a generator in a parallel route slot runs for every combination its parents generate. If a child's generator returns a different value for a parent param, vinext runs it for every parent combination once it sees that change. If the generator changes a parent param only for parent values that don't match the request, vinext can miss the change and answer 404 where Next.js renders the page. A cached image metadata response is served without this check, and so is a cached page or route handler response in an app without `fallback` rewrites, so with a pinned `generateBuildId` and a cache that persists across deploys, an entry an earlier deploy stored can outlive the removal of its params. Keep generators cheap, for example by caching the data they fetch.
Under `dynamicParams = false`, Next.js checks a request against the params its build generated and runs no `generateStaticParams` per request. For a route the check applies to, vinext runs the route's generators when the cache doesn't answer a request, including a request for params the route doesn't list. A page's route-tree prefetch for params the route doesn't list gets the route's tree, which holds only its segments, where Next.js answers 404. The prefetch of the page's content gets the 404. A `fallback` rewrite can still claim a param the route doesn't list, as in Next.js. A route handler that exports a method other than `GET` or `HEAD` runs no generator in production and serves every param, as Next.js does once its generator lists one. When that generator lists none, Next.js answers 404 for every param. A child segment's generator runs only for the parent params that match the request, but a generator in a parallel route slot runs for every combination its parents generate. If a child's generator returns a different value for a parent param, vinext runs it for every parent combination once it sees that change. If the generator changes a parent param only for parent values that don't match the request, vinext can miss the change and answer 404 where Next.js renders the page. A cached page, route handler or image metadata response is served without this check, so with a pinned `generateBuildId` and a cache that persists across deploys, an entry an earlier deploy stored can outlive the removal of its params. Keep generators cheap, for example by caching the data they fetch.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

ℹ️ INFO · Documentation

The docs say a fallback rewrite can claim a rejected param without noting that the split request/response-stage path still answers 404.

This paragraph says a fallback rewrite can still claim a param the route doesn't list. The split request/response-stage path has no route modules, though, so it still answers that miss with a 404. The pull request description lists this as a limitation. Consider adding that caveat here so users of that deployment shape aren't misled.

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 e528191. The paragraph now says that on Cloudflare with the Workers Response Store or Workers Cache, the URL gets the 404 instead, because routing runs in a stage without the route's modules. Both adapters route every request through that request stage.

… resuming routing on a miss

With fallback rewrites, routing ran a dynamicParams = false route's
generators for every request before the cache could answer it. The
dispatch now checks the params only on a cache miss and, when a fallback
rewrite could claim the URL, resumes routing with the fallback rewrites
once, as Next.js does when its route rejects the params.
@james-elicx
james-elicx force-pushed the perf/dynamic-params-check-on-cache-miss branch 3 times, most recently from 797fe8e to e528191 Compare November 9, 2026 18:20
@james-elicx

Copy link
Copy Markdown
Member Author

clanky review

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

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

@clanky-the-clanker clanky-the-clanker Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Superseded by the review of e528191d.

This review, of 8365884b

💬 Approved with comments — The earlier route-tree prefetch warning is fixed, and nothing else in the code needs a change. One INFO remains: the docs don't list the cases where a miss still answers 404.

Findings: 1 info (1 is an inline comment on the diff)

The decision is advisory: this review is a comment and never approves, blocks or requests changes on the pull request.


📝 Documentation: 💬 1 info

The docs don't list the cases where a fallback rewrite doesn't claim a rejected param.

Recommendation: 💬 Optional Nits

  • [INFO] The docs say a fallback rewrite can claim a rejected param without listing the cases where the miss still answers 404. (docs/reference/differences.mdx:76)
View full review

docs/reference/differences.mdx:76 says a fallback rewrite can still claim a param the route doesn't list. In these cases the miss still answers 404:

  • the split request/response-stage path;
  • an intercepted route;
  • a Server Action or progressive-action render.

✅ All other checks passed

🔒 Security: ✅ LGTM

  • Rejected params: a rejected param still never renders. The dispatch checks on every cache miss and returns a bare 404 before any handler or page runs. Server Action renders and intercepted routes don't resume.
  • Resumed routing: rejectedRoutes persists across resumes, so a later rewrite can't route the request back into a rejected route.
  • Cache-bypass flags: they are restored before routing resumes, then worked out again for the new match.
  • Draft mode: restoreDraftModeCookieHeader only puts back a Set-Cookie the dispatch itself took.
  • Mixed-method route handlers: skipping the generator in production is a documented difference from Next.js, not an access-control boundary.

📖 AGENTS.md: ✅ LGTM

This pull request doesn't change tooling or project structure in a way that calls for an AGENTS.md update.


📋 Code Quality: ✅ LGTM

  • Route-tree prefetch with fallback rewrites: fixed. When canResumeRoutingAfterMiss() is true, a /_tree prefetch runs isDynamicParamsMiss. On a miss it resumes routing, so a claiming rewrite answers with its own route's tree. tests/app-rsc-handler.test.ts covers the claiming, unclaimed and no-fallback cases.
  • Trace route reset: resetFrameworkRequestRoute and the replace handling in request-tracing.ts keep first-wins behaviour for calls that don't replace. A reset is now reported to onRoute.

⚡ Performance: ✅ LGTM

  • Cache hits and route-tree prefetches that no fallback rewrite could claim no longer run generateStaticParams.
  • The resume loop ends, because nextFallbackRewriteIndex only moves forward.
  • The discarded 404 body is cancelled before routing resumes.

Reviewed commit: 8365884b

Comment thread docs/reference/differences.mdx Outdated
Next.js also treats an otherwise static route as SSG when a layout above its last dynamic segment returns every param from `generateStaticParams`. vinext doesn't run the generator to classify requests, so it renders such a route on every request unless it exports `dynamic = "force-static"` or `"error"`. Adding `generateStaticParams` to the last dynamic segment, even one that returns `[]`, makes the route static in both.

Under `dynamicParams = false`, Next.js checks a request against the params its build generated and runs no `generateStaticParams` per request. For a route the check applies to, vinext runs the route's generators when the cache doesn't answer a request, including a request for params the route doesn't list. In an app with `fallback` rewrites, it runs them while routing for every request to such a route, outside draft mode, so a rewrite can claim a param the route doesn't list, as in Next.js. A child segment's generator runs only for the parent params that match the request, but a generator in a parallel route slot runs for every combination its parents generate. If a child's generator returns a different value for a parent param, vinext runs it for every parent combination once it sees that change. If the generator changes a parent param only for parent values that don't match the request, vinext can miss the change and answer 404 where Next.js renders the page. A cached image metadata response is served without this check, and so is a cached page or route handler response in an app without `fallback` rewrites, so with a pinned `generateBuildId` and a cache that persists across deploys, an entry an earlier deploy stored can outlive the removal of its params. Keep generators cheap, for example by caching the data they fetch.
Under `dynamicParams = false`, Next.js checks a request against the params its build generated and runs no `generateStaticParams` per request. For a route the check applies to, vinext runs the route's generators when the cache doesn't answer a request, including a request for params the route doesn't list. When no `fallback` rewrite could still claim the URL, a page's route-tree prefetch for params the route doesn't list gets the route's tree, which holds only its segments, where Next.js answers 404. The prefetch of the page's content gets the 404. When one could, vinext runs the generators for a route-tree prefetch outside draft mode too. A `fallback` rewrite can still claim a param the route doesn't list, as in Next.js. A route handler that exports a method other than `GET` or `HEAD` runs no generator in production and serves every param, as Next.js does once its generator lists one. When that generator lists none, Next.js answers 404 for every param. A child segment's generator runs only for the parent params that match the request, but a generator in a parallel route slot runs for every combination its parents generate. If a child's generator returns a different value for a parent param, vinext runs it for every parent combination once it sees that change. If the generator changes a parent param only for parent values that don't match the request, vinext can miss the change and answer 404 where Next.js renders the page. A cached page, route handler or image metadata response is served without this check, so with a pinned `generateBuildId` and a cache that persists across deploys, an entry an earlier deploy stored can outlive the removal of its params. Keep generators cheap, for example by caching the data they fetch.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

ℹ️ INFO · Documentation

The docs say a fallback rewrite can claim a rejected param without listing the cases where the miss still answers 404.

This paragraph says a fallback rewrite can still claim a param the route doesn't list, with no exceptions. The pull request description lists cases where the miss still answers 404: the split request/response-stage path, an intercepted route, and a Server Action or progressive-action render. One sentence naming those cases would keep users of those paths from expecting the fallback rewrite to take over.

@clanky-the-clanker clanky-the-clanker Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Superseded by the review of c851f650.

This review, of e528191d

💬 Approved with comments — Nothing in the code needs a change. One INFO remains: the differences page doesn't name every case where a rejected param still gets the 404 instead of being claimed by a fallback rewrite.

Findings: 1 info (1 is an inline comment on the diff)

The decision is advisory: this review is a comment and never approves, blocks or requests changes on the pull request.


📝 Documentation: 💬 1 info

The differences page still omits two cases where a rejected param keeps the 404.

Recommendation: 💬 Optional Nits

  • [INFO] The fallback-rewrite sentence still omits intercepted routes and Server Action renders, which keep the 404 for a rejected param. (docs/reference/differences.mdx:76)
View full review

The updated paragraph now covers the route-tree prefetch difference, the Cloudflare split-stage 404 and the production mixed-method route handler difference.

It still says a fallback rewrite can claim a param the route doesn't list without naming two cases where the request keeps the 404 instead: an intercepted route, and a Server Action or progressive-action render.


✅ All other checks passed

🔒 Security: ✅ LGTM

The only change since the last review is a wording update to docs/reference/differences.mdx, and it has no security impact.

  • A rejected param still never renders: the dispatch checks on every cache miss and returns a bare 404 before any handler or page runs.
  • A later rewrite can't route the request back into a route that rejected its params.

📖 AGENTS.md: ✅ LGTM

This pull request doesn't change tooling or project structure in a way that calls for an AGENTS.md update.


📋 Code Quality: ✅ LGTM

  • Resume loop: it ends, because nextFallbackRewriteIndex only moves forward. rejectedRoutes persists across resumes. Cache-bypass flags are restored before routing resumes and worked out again for the new match.
  • No resume: intercepted routes (isFinalRouteMatch) and Server Action or progressive-action renders keep the 404. The route-tree prefetch check also excludes intercepted routes.
  • Request tracing: resetFrameworkRequestRoute keeps first-wins behaviour for calls that don't replace, and the reset is reported to onRoute.
  • Route handlers: restoreDraftModeCookieHeader puts back only a Set-Cookie the dispatch itself took. The discarded 404 body is cancelled before routing resumes. The removed enforce-when-none-generated policy leaves no dead code behind.

⚡ Performance: ✅ LGTM

The only change since the last review is a documentation edit, so it has no performance impact.

  • Cache hits no longer run generateStaticParams.
  • Route-tree prefetches that no fallback rewrite could claim no longer run it either.

Reviewed commit: e528191d

Comment thread docs/reference/differences.mdx Outdated
Next.js also treats an otherwise static route as SSG when a layout above its last dynamic segment returns every param from `generateStaticParams`. vinext doesn't run the generator to classify requests, so it renders such a route on every request unless it exports `dynamic = "force-static"` or `"error"`. Adding `generateStaticParams` to the last dynamic segment, even one that returns `[]`, makes the route static in both.

Under `dynamicParams = false`, Next.js checks a request against the params its build generated and runs no `generateStaticParams` per request. For a route the check applies to, vinext runs the route's generators when the cache doesn't answer a request, including a request for params the route doesn't list. In an app with `fallback` rewrites, it runs them while routing for every request to such a route, outside draft mode, so a rewrite can claim a param the route doesn't list, as in Next.js. A child segment's generator runs only for the parent params that match the request, but a generator in a parallel route slot runs for every combination its parents generate. If a child's generator returns a different value for a parent param, vinext runs it for every parent combination once it sees that change. If the generator changes a parent param only for parent values that don't match the request, vinext can miss the change and answer 404 where Next.js renders the page. A cached image metadata response is served without this check, and so is a cached page or route handler response in an app without `fallback` rewrites, so with a pinned `generateBuildId` and a cache that persists across deploys, an entry an earlier deploy stored can outlive the removal of its params. Keep generators cheap, for example by caching the data they fetch.
Under `dynamicParams = false`, Next.js checks a request against the params its build generated and runs no `generateStaticParams` per request. For a route the check applies to, vinext runs the route's generators when the cache doesn't answer a request, including a request for params the route doesn't list. When no `fallback` rewrite could still claim the URL, a page's route-tree prefetch for params the route doesn't list gets the route's tree, which holds only its segments, where Next.js answers 404. The prefetch of the page's content gets the 404. When one could, vinext runs the generators for a route-tree prefetch outside draft mode too. A `fallback` rewrite can still claim a param the route doesn't list, as in Next.js. On Cloudflare with the Workers Response Store or Workers Cache, the URL gets the 404 instead, because routing runs in a stage without the route's modules. A route handler that exports a method other than `GET` or `HEAD` runs no generator in production and serves every param, as Next.js does once its generator lists one. When that generator lists none, Next.js answers 404 for every param. A child segment's generator runs only for the parent params that match the request, but a generator in a parallel route slot runs for every combination its parents generate. If a child's generator returns a different value for a parent param, vinext runs it for every parent combination once it sees that change. If the generator changes a parent param only for parent values that don't match the request, vinext can miss the change and answer 404 where Next.js renders the page. A cached page, route handler or image metadata response is served without this check, so with a pinned `generateBuildId` and a cache that persists across deploys, an entry an earlier deploy stored can outlive the removal of its params. Keep generators cheap, for example by caching the data they fetch.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

ℹ️ INFO · Documentation

The fallback-rewrite sentence still omits intercepted routes and Server Action renders, which keep the 404 for a rejected param.

This paragraph now names the Cloudflare split-stage case. It still doesn't mention that an intercepted route, or a Server Action or progressive-action page render, keeps the 404 for a param the route doesn't list, even when a later fallback rewrite would claim the URL. A short clause after "as in Next.js" listing those two cases would keep readers from expecting the rewrite to always take over.

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.

Done in c851f65. The paragraph now also says that a page an intercepted route renders, or a page a Server Action has run on, gets the 404. With the Cloudflare stage case added earlier, it names every case where a fallback rewrite can't claim a rejected param.

…teStaticParams

Client navigation prefetches a route tree for each link it sees, and the
tree holds only the route's segments. When no fallback rewrite could claim
the URL, answering it from the route alone keeps a page's generators off
every prefetched link; the prefetch of the page's content still gets the
dynamicParams = false 404. When one could, the tree's check still runs, so
a claiming fallback rewrite answers with its own route's tree.
@james-elicx
james-elicx force-pushed the perf/dynamic-params-check-on-cache-miss branch from e528191 to c851f65 Compare November 9, 2026 18:24
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

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

@clanky-the-clanker clanky-the-clanker Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

✅ Approved — The earlier documentation note is fixed and nothing new turned up: the resume logic, cache-bypass reset and route-tree prefetch handling are sound, and the updated docs match the behaviour.

The decision is advisory: this review is a comment and never approves, blocks or requests changes on the pull request.


✅ All checks passed

🔒 Security: ✅ LGTM

A rejected param still never renders. A later rewrite can't route the request back into a route that rejected its params.


📖 AGENTS.md: ✅ LGTM

No AGENTS.md update is needed.


📋 Code Quality: ✅ LGTM

  • Resume loop: it ends, because nextFallbackRewriteIndex only moves forward. rejectedRoutes persists across resumes, and the cache-bypass flags are restored before each resume.
  • Route handler miss: a draft-mode Set-Cookie the dispatch took is put back through restoreDraftModeCookieHeader before the bare 404 is returned.
  • Dead code: removing the enforce-when-none-generated policy leaves no references behind.

⚡ Performance: ✅ LGTM

Cache hits no longer run generateStaticParams. Route-tree prefetches that no fallback rewrite could claim no longer run it either.


📝 Documentation: ✅ LGTM

The paragraph about fallback rewrites in docs/reference/differences.mdx now names intercepted routes and pages a Server Action has run on, as well as the Cloudflare split-stage case.


Reviewed commit: c851f650

@ask-bonk

ask-bonk Bot commented Nov 9, 2026

Copy link
Copy Markdown
Contributor

@james-elicx Bonk workflow timed out.

View workflow run · To retry, trigger Bonk again.

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

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

@ask-bonk

ask-bonk Bot commented Nov 9, 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.

1 participant