Skip to content

fix(app-router): vary segment prefetches by accessed params#2474

Open
james-elicx wants to merge 7 commits into
mainfrom
codex/fix-segment-cache-vary-params-28478866791
Open

fix(app-router): vary segment prefetches by accessed params#2474
james-elicx wants to merge 7 commits into
mainfrom
codex/fix-segment-cache-vary-params-28478866791

Conversation

@james-elicx

Copy link
Copy Markdown
Member

Summary

  • add App Router prefetch metadata for params/searchParams that are accessed before runtime boundaries
  • key navigation/loading/runtime prefetch reuse by the tracked vary params, including runtime-prefetch shells
  • preserve runtime-prefetch optimistic page content while suspending or appending the recorded loading fallback
  • add focused unit coverage plus a small Next.js-ported browser fixture for searchParams segment-cache reuse

Covers GitHub Actions run 28478866791, job 84413308650 for test/e2e/app-dir/segment-cache/vary-params/vary-params.test.ts.

Original failing rows

  • renders cached loading state instantly during navigation
  • renders cached loading state instantly with runtime prefetching
  • does not reuse prefetched segment when page accesses searchParams
  • reuses prefetched segment when page does not access searchParams
  • tracks param access in generateMetadata
  • caches head segment when generateMetadata does not access params
  • reuses page segment when layout varies but page does not
  • does not reuse cached segment for optional catch-all when page accesses slug
  • does not reuse cached segment for optional catch-all when page enumerates params
  • does not reuse cached segment for optional catch-all when page checks slug with in operator
  • shares cached segment across all params when none accessed statically (runtime prefetch)
  • does not share cached segment when all params accessed statically (runtime prefetch)
  • shares cached segment across search params when not accessed (runtime prefetch)
  • tracks metadata param access separately from body (runtime prefetch)
  • tracks vary params per-segment with layout/page split (runtime prefetch)
  • tracks root param access via rootParams API

Validation

  • vp fmt --check packages/vinext/src/client/vinext-next-data.ts packages/vinext/src/entries/app-browser-entry.ts packages/vinext/src/server/app-browser-entry.ts packages/vinext/src/server/app-optimistic-routing.ts packages/vinext/src/shims/link.tsx packages/vinext/src/shims/navigation.ts tests/app-optimistic-routing.test.ts tests/app-prefetch-vary-analysis.test.ts tests/e2e/app-router/nextjs-compat/vary-params.browser.spec.ts tests/fixtures/app-basic/app/nextjs-compat/segment-cache-vary-params
  • vp test run tests/app-optimistic-routing.test.ts tests/app-prefetch-vary-analysis.test.ts
  • vp check tests/app-optimistic-routing.test.ts tests/app-prefetch-vary-analysis.test.ts
  • REPO="$(pwd)" NEXTJS_DIR="/Users/jamesanderson/Developer/vinext/.nextjs-ref" NEXT_TEST_CONCURRENCY=1 ./scripts/run-targeted-nextjs-e2e.sh test/e2e/app-dir/segment-cache/vary-params/vary-params.test.ts (16/16 passed)

@james-elicx james-elicx marked this pull request as draft July 1, 2026 10:01
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

Comment thread packages/vinext/src/entries/app-browser-entry.ts Fixed
@james-elicx james-elicx force-pushed the codex/fix-segment-cache-vary-params-28478866791 branch from 4c8d470 to 6928f9f Compare July 1, 2026 10:05
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

1 similar comment
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@james-elicx james-elicx marked this pull request as ready for review July 1, 2026 10:09
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk

ask-bonk Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

@james-elicx Bonk workflow was cancelled.

View workflow run · To retry, trigger Bonk again.

@james-elicx james-elicx force-pushed the codex/fix-segment-cache-vary-params-28478866791 branch from 6928f9f to 9a028cb Compare July 1, 2026 10:13
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@pkg-pr-new

pkg-pr-new Bot commented Jul 1, 2026

Copy link
Copy Markdown

Open in StackBlitz

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

commit: 8e671bf

@github-actions

github-actions Bot commented Jul 1, 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
workers-cache preview production
web preview production

@ask-bonk

ask-bonk Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Review: fix(app-router): vary segment prefetches by accessed params

Focused on correctness and CI parity. One blocking issue found (a required Vitest check will fail), plus a few soundness observations.

Blocking — tests/link.test.ts was not updated for the prefetchShellFirstrenderLoadingShell rename

resolveAutoAppRoutePrefetch / resolveMatchedAutoAppRoutePrefetch changed their return shape (renamed prefetchShellFirstrenderLoadingShell, and the semantics flipped for several cases), but tests/link.test.ts still asserts the old shape with .toEqual. This test is not in the PR diff and fails locally:

FAIL  tests/link.test.ts > Link App Router prefetch mode >
  shell-prefetches routes with loading boundaries and full-prefetches routes without them
- "prefetchShellFirst": true,
+ "renderLoadingShell": false,

All 6 assertions at tests/link.test.ts:325-357 are affected (e.g. /about was prefetchShellFirst: true and is now renderLoadingShell: false; /settings was cacheForNavigation: false and is now renderLoadingShell: true). Since Vitest is a required check, this must be updated (and the expected values re-derived against the new semantics, not just the key renamed) before merge.

Dead build-time define — __VINEXT_PREFETCH_INLINING

link.tsx dropped the __prefetchInlining constant and its use in the fetchPromise gate, but packages/vinext/src/index.ts:1751 still injects process.env.__VINEXT_PREFETCH_INLINING and global.d.ts:424 still declares it. The env var is now unused in link.tsx. Please confirm the removal of the || __prefetchInlining gate is intentional — it changes prefetch behavior when experimental.prefetchInlining is configured — and either remove the now-dead define or document why it stays. tests/link-navigation.test.ts:1920 still stubs it; that test happens to still pass, but it is now asserting behavior that no longer depends on the flag.

Prefetch scheduling change: requestIdleCallbacksetTimeout(fn, 0)

prefetchUrl changed the scheduler from window.requestIdleCallback ?? setTimeout(fn, 100) to setTimeout(fn, 0) (link.tsx). This makes idle prefetches run eagerly on the next macrotask instead of during idle time, which can contend with more important work on link-heavy pages. If this is deliberate (to make the segment-cache reuse deterministic in tests), a comment explaining the rationale would help; otherwise it looks like an accidental regression of the idle-scheduling behavior.

Soundness of regex-based static param analysis (analyzePrefetchVary)

The new analysis infers param/searchParams access by scanning source with regexes at build time. This is inherently approximate; the risky direction is a false negative (missing an access → over-sharing the cache → serving wrong content). A few gaps worth confirming against Next.js's own tracking:

  • Arrow-form generateMetadata: extractExportedFunction/removeExportedFunction only match export (async) function generateMetadata. export const generateMetadata = async (...) => {...} is not split out, so its accesses are attributed to the page body instead of metadata. This errs toward over-varying (safe), but means the metadata-vs-body split silently doesn't apply for arrow-form exports.
  • staticPrefetchRegion uses indexOf("connection(") with no word boundary, so myConnection( or connection( inside a string/identifier would truncate the analyzed region early and drop later param accesses. Consider a \bconnection\s*\( match (comments are already stripped, but strings/identifiers are not).
  • Next.js tracks param access at runtime via the params proxy. Since this is a static approximation, please make sure the ported Next.js e2e rows cover the false-negative cases (helper-function param reads, computed member access like params[key], spread into a helper), since those are the ones that would surface as wrong cached content rather than a perf regression.

CodeQL "double unescaping" alert — appears to be a false positive

decodeSimpleJsxText is intentionally single-pass (the test decodes runtime-prefetch fallback entities without double-decoding asserts <<), so the flagged double-unescape is by design. Worth a one-line comment on the function noting the single-pass intent so the alert can be dismissed with justification rather than looking unhandled.

Nits

  • replaceInnermostSuspenseChildren guards recursion at depth > 100; a brief comment on why 100 (and that exceeding it returns the value unchanged, i.e. no fallback replacement) would help future readers.

@ask-bonk

ask-bonk Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Posted the review to PR #2474. Summary of findings:

Review posted

Blocking issue (will fail CI):

  • tests/link.test.ts was not updated for the prefetchShellFirstrenderLoadingShell rename. resolveAutoAppRoutePrefetch changed both the key name and the semantics, but the test still asserts the old shape via .toEqual. I confirmed this fails locally (all 6 assertions at tests/link.test.ts:325-357). Vitest is a required check, so this blocks merge.

Other observations:

  • Dead define: __VINEXT_PREFETCH_INLINING is still injected in index.ts:1751 and declared in global.d.ts, but link.tsx no longer reads it (the || __prefetchInlining gate was removed). Needs confirmation the behavior change is intentional.
  • Scheduling change: prefetch scheduler went from requestIdleCallback/setTimeout(100) to setTimeout(fn, 0) — eager rather than idle. Flagged as possibly unintentional.
  • Static analysis soundness: analyzePrefetchVary uses build-time regex scanning to infer param access. Flagged false-negative risks (arrow-form generateMetadata not split out, connection( matched without word boundary, computed/helper param reads) since these can lead to serving wrong cached content vs. Next.js's runtime proxy tracking.
  • CodeQL double-unescape alert: appears to be a false positive — decodeSimpleJsxText is intentionally single-pass and has an explicit test for it; suggested a clarifying comment.

The core implementation (optimistic template variant keying, suspense preservation, cache-key varying) is coherent and its focused unit tests pass. The main required action is fixing the stale tests/link.test.ts assertions.

github run

@james-elicx james-elicx force-pushed the codex/fix-segment-cache-vary-params-28478866791 branch from 9a028cb to a7fe652 Compare July 1, 2026 10:17
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

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.

Review: fix(app-router): vary segment prefetches by accessed params

Re-reviewed after the previous round. The prior blocking issue (tests/link.test.ts asserting the old prefetchShellFirst shape) is now fixed — the test was updated to renderLoadingShell and passes locally (121/121). The two new unit files pass too (app-optimistic-routing.test.ts 14/14, app-prefetch-vary-analysis.test.ts 6/6). No blocking issues remain; the notes below are correctness/hygiene follow-ups.

Dead build-time define — __VINEXT_PREFETCH_INLINING (introduced by this PR)

This PR removed const __prefetchInlining and its || __prefetchInlining term from the fetchPromise gate in link.tsx (confirmed in the commit diff), so the flag is no longer read anywhere in the shim (only a comment remains). But the build-time define and its type are still present:

  • packages/vinext/src/index.ts:1751 still injects process.env.__VINEXT_PREFETCH_INLINING
  • packages/vinext/src/global.d.ts:426 still declares __VINEXT_PREFETCH_INLINING?: string

Two things to resolve:

  1. Confirm the behavior change is intentional. Before this PR, configuring experimental.prefetchInlining forced the two-phase shell-then-full fetchPromise path even for mode === "auto". Now that path is gated only on mode === "full" || mode === "full-after-shell", so prefetchInlining no longer changes prefetch behavior. If that's the intended consolidation, remove the now-dead define + global.d.ts entry; if not, the gate needs the flag back.
  2. tests/link-navigation.test.ts:1927 is now a misleading stub. That test still does vi.stubEnv("__VINEXT_PREFETCH_INLINING", "true"), but the asserted shell-only prefetch for /teams/vercel/dashboard now happens via the requiresDynamicNavigationRequestrenderLoadingShell path regardless of the flag. The test passes, but the stub implies a dependency that no longer exists. Drop the stub (or replace it with a comment) so the test doesn't imply flag-gated behavior.

Prefetch scheduling change: requestIdleCallbacksetTimeout(fn, 0) (introduced by this PR)

prefetchUrl's low-priority scheduler changed from window.requestIdleCallback ?? ((fn) => setTimeout(fn, 100)) to (fn) => setTimeout(fn, 0) (link.tsx). This makes viewport/idle prefetches run on the very next macrotask instead of during browser idle time, which can contend with more important work on link-heavy pages. If this is deliberate (e.g. to make segment-cache reuse deterministic for the new tests), please add a one-line comment with the rationale; otherwise it looks like an accidental regression of the idle-scheduling behavior. Worth confirming whether any existing test actually required the change, or whether requestIdleCallback can be retained.

Soundness of regex-based static param analysis (analyzePrefetchVary)

The analysis infers param/searchParams access by scanning source with regexes at build time (app-browser-entry.ts). This is inherently approximate; the dangerous direction is a false negative — missing an access leads to over-sharing the segment cache and serving wrong content (a correctness bug), not just a perf regression. Confirmed gaps:

  • Arrow-form generateMetadata is not split out. extractExportedFunction/removeExportedFunction only match export (async) function generateMetadata. export const generateMetadata = async (...) => {} stays in the page body, so its accesses are attributed to the body instead of metadata. This errs toward over-varying (safe), but silently disables the metadata-vs-body split for arrow-form exports — worth a comment noting the limitation or handling the const form.
  • staticPrefetchRegion truncates at indexOf("connection(") with no word boundary. myConnection(, or the literal string "connection(" in unrelated code, would truncate the analyzed region early and drop later param/searchParams accesses (a false negative → over-share). Comments are stripped but strings/identifiers are not. Consider \bconnection\s*\(.
  • Runtime vs static tracking. Next.js tracks param access at runtime via the params proxy; this is a static approximation. Please make sure the ported Next.js e2e rows cover the false-negative shapes — helper-function param reads, computed member access (params[key]), and spread-into-helper — since those are the cases that surface as wrong cached content rather than a perf hit.

CodeQL "double unescaping" alert — false positive (confirm with a comment)

decodeSimpleJsxText (app-browser-entry.ts:280) uses a single-pass String.prototype.replace with a global regex; replacement output is not re-scanned, so << (the test decodes runtime-prefetch fallback entities without double-decoding asserts exactly this). The CodeQL alert is a false positive. Add a one-line comment noting the single-pass intent so the alert can be dismissed with justification.

Nit

  • replaceInnermostSuspenseChildren (app-optimistic-routing.ts) guards recursion at depth > 100 and returns the value unchanged past that. A brief comment on why 100 and that exceeding it means "no fallback replacement" would help future readers.

@ask-bonk

ask-bonk Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2474. Summary of findings:

Review posted

The previous round's blocking issue is resolved: tests/link.test.ts was updated for the prefetchShellFirstrenderLoadingShell rename and passes (121/121). Both new unit test files also pass. No blocking issues remain.

Follow-ups flagged (all confirmed against the commit diff):

  • Dead define __VINEXT_PREFETCH_INLINING — this PR removed the || __prefetchInlining gate in link.tsx, so the flag is no longer read, but index.ts:1751 and global.d.ts:426 still inject/declare it. Also tests/link-navigation.test.ts:1927 still stubs it in a now-misleading way. Needs confirmation the behavior change (prefetchInlining no longer forces two-phase prefetch on mode === "auto") is intentional, then remove the dead define + stub.

  • Scheduler regression — low-priority prefetch scheduling changed from requestIdleCallback ?? setTimeout(100) to setTimeout(fn, 0), making idle prefetches run eagerly. Flagged as possibly accidental; needs a rationale comment or revert.

  • Static analysis false-negative risks in analyzePrefetchVary (regex-based param access detection): arrow-form generateMetadata isn't split from the page body, and staticPrefetchRegion truncates at indexOf("connection(") without a word boundary. These can lead to over-sharing the segment cache → serving wrong content. Suggested word-boundary fix and confirming e2e coverage for helper/computed param reads.

  • CodeQL double-unescape alert — false positive (decodeSimpleJsxText is single-pass with an explicit test); suggested a clarifying comment for dismissal.

  • Nit — document the depth > 100 recursion guard in replaceInnermostSuspenseChildren.

The core implementation (variant-keyed optimistic templates, suspense-child preservation, shared-cache-key varying) is coherent and its focused unit tests pass.

github run

@james-elicx james-elicx force-pushed the codex/fix-segment-cache-vary-params-28478866791 branch from a7fe652 to fa75126 Compare July 1, 2026 10:25
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

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.

Review: fix(app-router): vary segment prefetches by accessed params

Re-reviewed the current commit (fa75126). Most of the earlier rounds' follow-ups are now resolved in the tree, but I found a new blocking regression in a required Vitest check that the prior rounds missed, plus notes on what is now stale.

Blocking — tests/link-navigation.test.ts regresses on this branch (required Vitest check fails)

tests/link-navigation.test.ts is not in the PR diff, but this PR changes behavior it asserts. The test passes on main and fails on this branch:

FAIL  tests/link-navigation.test.ts >
  Link prefetch scheduling >
  uses a shell-only automatic prefetch for dynamic routes requiring fresh navigation

  AssertionError: expected null to be 'prefetch-loading-shell'
  - Expected: "prefetch-loading-shell"
  + Received: null
    tests/link-navigation.test.ts:1949

Root cause: resolveMatchedAutoAppRoutePrefetch decoupled renderLoadingShell from the optimistic-route-shell decision. For the fixture route { patternParts: ["teams", ":team", "dashboard"], isDynamic: true, canPrefetchLoadingShell: false, requiresDynamicNavigationRequest: true } (tests/link-navigation.test.ts:59-64), the new fallback branch returns:

const hasLoadingShell = route.canPrefetchLoadingShell; // false
return {
  cacheForNavigation: !hasLoadingShell && route.requiresDynamicNavigationRequest !== true, // false
  renderLoadingShell: hasLoadingShell, // false
  shouldPrefetch: true,
};

So renderLoadingShell === false, and prefetchUrl then builds headers with renderMode: renderLoadingShell ? APP_RSC_RENDER_MODE_PREFETCH_LOADING_SHELL : undefinedundefined (link.tsx:528-531). On main, this same route was an optimistic-route-shell prefetch (!cacheForNavigation) and therefore requested APP_RSC_RENDER_MODE_PREFETCH_LOADING_SHELL.

This is a real behavioral change for dynamic routes that require a fresh navigation request but have no loading boundary: previously they were prefetched as a loading shell; now they are prefetched without the shell render mode. Two things to resolve before merge:

  1. Decide whether the behavior change is intentional. If requiresDynamicNavigationRequest routes with no loading boundary should still request the shell render mode, the fallback branch needs to keep renderLoadingShell (or the render-mode gate) tied to shouldLearnOptimisticRouteShell, not only to canPrefetchLoadingShell.
  2. Either way, tests/link-navigation.test.ts must be updated in this PR so the required Vitest check is green. Since it is not in the diff, it was not re-derived against the new semantics.

I verified this by running the test on both branches:

  • main: passes (1 passed | 54 skipped)
  • this branch: fails at tests/link-navigation.test.ts:1949

The full tests/link.test.ts, tests/app-optimistic-routing.test.ts, and tests/app-prefetch-vary-analysis.test.ts all pass (144 total), and tests/features.test.ts + tests/app-router.test.ts pass (459 total). The regression is isolated to link-navigation.test.ts.

Now-stale prior review notes (no action needed)

For the record, several items flagged in earlier rounds are not present in the current commit and need no change:

  • __VINEXT_PREFETCH_INLINING is not dead. The || __prefetchInlining gate is still in link.tsx:616, the define is still injected at index.ts:1751-1752, and the type is still declared at global.d.ts:426. The flag remains consistently wired, and the link-navigation.test.ts:1927 stub is still meaningful. Earlier rounds claimed this was removed; it was not.
  • Scheduler is unchanged. prefetchUrl still uses window.requestIdleCallback ?? ((fn) => setTimeout(fn, 100)) (link.tsx:461), not setTimeout(fn, 0). No idle-scheduling regression.
  • staticPrefetchRegion already uses a word boundary (\bconnection\s*\(, app-browser-entry.ts:128), with a dedicated test (does not truncate static analysis on connection substrings).
  • decodeSimpleJsxText already documents its single-pass intent (app-browser-entry.ts:291), with the decodes runtime-prefetch fallback entities without double-decoding test — the CodeQL alert is a documented false positive.
  • replaceInnermostSuspenseChildren already has the depth > 100 comment (app-optimistic-routing.ts:347).

Soundness note (non-blocking)

The regex-based static param analysis in analyzePrefetchVary errs toward over-varying for unknown/computed/helper reads and has good coverage for those shapes. The one remaining approximation: arrow-form export const generateMetadata = async (...) => {} is not split from the page body by extractExportedFunction/removeExportedFunction (only export (async) function is matched), so metadata accesses are attributed to the body. This over-varies (safe), but silently disables the metadata-vs-body split for arrow-form exports. Worth a one-line comment noting the limitation, or handling the const form.

The core implementation (variant-keyed optimistic templates, suspense-child preservation, sharedCacheKey-based cache reuse) is coherent. The blocking item is the link-navigation.test.ts regression.

@ask-bonk

ask-bonk Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2474.

Summary

I found one blocking regression that earlier review rounds missed:

Blocking — tests/link-navigation.test.ts fails on this branch (required Vitest check):

  • The test uses a shell-only automatic prefetch for dynamic routes requiring fresh navigation passes on main but fails on this branch (expected null to be 'prefetch-loading-shell').
  • Root cause: resolveMatchedAutoAppRoutePrefetch decoupled renderLoadingShell from the optimistic-route-shell decision. For a dynamic route with requiresDynamicNavigationRequest: true and no loading boundary, renderLoadingShell is now false, so prefetchUrl no longer sets APP_RSC_RENDER_MODE_PREFETCH_LOADING_SHELL. On main, that route was prefetched as a loading shell.
  • I verified this by running the test on both branches (main: passes; this branch: fails at line 1949). The test is not in the PR diff, so it wasn't re-derived against the new semantics.

Verified stale (no action) — prior rounds were incorrect/outdated on these:

  • __VINEXT_PREFETCH_INLINING is NOT dead — the gate at link.tsx:616, the define at index.ts:1751, and the declaration at global.d.ts:426 are all still consistently wired.
  • The scheduler is unchanged (requestIdleCallback ?? setTimeout(fn, 100)) — no idle regression.
  • staticPrefetchRegion already uses \bconnection\s*\( (with a test), and decodeSimpleJsxText already has its single-pass comment, and replaceInnermostSuspenseChildren already documents the depth > 100 guard.

Non-blocking: arrow-form export const generateMetadata = ... isn't split from the page body (over-varies, safe) — worth a comment.

Other suites (link.test.ts, app-optimistic-routing.test.ts, app-prefetch-vary-analysis.test.ts, features.test.ts, app-router.test.ts — 603 tests total) pass; the regression is isolated to link-navigation.test.ts.

github run

@james-elicx james-elicx force-pushed the codex/fix-segment-cache-vary-params-28478866791 branch from fa75126 to 5ce0276 Compare July 1, 2026 10:31
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

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.

Review: fix(app-router): vary segment prefetches by accessed params

Re-reviewed the current HEAD (5ce0276). The core implementation (variant-keyed optimistic templates, suspense-child preservation, sharedCacheKey-based cache reuse, and the regex analyzePrefetchVary that over-varies on unknown/computed/helper reads) is coherent and its focused unit tests pass. But there is one blocking regression in a required Vitest check that this PR introduces and does not update.

Blocking — tests/link-navigation.test.ts regresses on this branch (required Vitest check fails)

tests/link-navigation.test.ts is not in the PR diff, but this PR changes behavior it asserts. The test passes on main and fails on this branch:

FAIL  tests/link-navigation.test.ts >
  Link prefetch scheduling >
  uses a shell-only automatic prefetch for dynamic routes requiring fresh navigation

  AssertionError: expected null to be 'prefetch-loading-shell'
  - Expected: "prefetch-loading-shell"
  + Received: null
    tests/link-navigation.test.ts:1949

I verified this by running the test on this branch (fails at tests/link-navigation.test.ts:1949) and confirming the test source is byte-identical on main (so it passed there).

Root cause — the render-mode header was decoupled from the optimistic-route-shell decision.

On main, the shell render mode was gated on !cacheForNavigation:

const isOptimisticRouteShellPrefetch = !autoPrefetch.cacheForNavigation;
renderMode: isOptimisticRouteShellPrefetch ? APP_RSC_RENDER_MODE_PREFETCH_LOADING_SHELL : undefined,

On this branch it is gated only on renderLoadingShell (link.tsx:530):

renderMode: renderLoadingShell ? APP_RSC_RENDER_MODE_PREFETCH_LOADING_SHELL : undefined,

For the fixture route { patternParts: ["teams", ":team", "dashboard"], isDynamic: true, canPrefetchLoadingShell: false, requiresDynamicNavigationRequest: true } (tests/link-navigation.test.ts:59-64), the new resolveMatchedAutoAppRoutePrefetch fallback branch (link.tsx:336-345) returns:

const hasLoadingShell = route.canPrefetchLoadingShell; // false
cacheForNavigation: !hasLoadingShell && route.requiresDynamicNavigationRequest !== true, // true && false => false
renderLoadingShell: hasLoadingShell, // false

So cacheForNavigation === false (→ shouldLearnOptimisticRouteShell === true), but renderLoadingShell === false. The header therefore becomes undefined. On main, the same route was an optimistic-route-shell prefetch (!cacheForNavigation) and thus requested APP_RSC_RENDER_MODE_PREFETCH_LOADING_SHELL.

This is a real behavioral change for dynamic routes that require a fresh navigation request but have no loading boundary: previously prefetched as a loading shell; now prefetched with no render-mode header. Note optimisticRouteShell: shouldLearnOptimisticRouteShell && renderLoadingShell (link.tsx:696) also collapses to false for this route, so it is no longer cached as an optimistic route shell either.

Two things to resolve before merge:

  1. Decide whether the behavior change is intentional. If requiresDynamicNavigationRequest routes with no loading boundary should still request the shell render mode (and be learned as an optimistic route shell), the render-mode gate / optimisticRouteShell flag needs to stay tied to shouldLearnOptimisticRouteShell, not only to renderLoadingShell.
  2. Either way, tests/link-navigation.test.ts must be updated in this PR so the required Vitest check is green. Since it is not in the diff, it was not re-derived against the new semantics.

Verified stale (no action) — earlier rounds were incorrect on these

For the record, several items flagged in earlier rounds are not present in the current commit and need no change. I re-checked each against HEAD:

  • __VINEXT_PREFETCH_INLINING is not dead. The || __prefetchInlining gate is still in link.tsx:616, the const __prefetchInlining read is at link.tsx:164, the define is still injected in index.ts, and the declaration is still in global.d.ts. The flag remains consistently wired; the link-navigation.test.ts:1927 stub is still meaningful. Earlier claims that this was removed were wrong.
  • Scheduler is unchanged. prefetchUrl still uses window.requestIdleCallback ?? ((fn) => setTimeout(fn, 100)) (link.tsx:461), not setTimeout(fn, 0). No idle-scheduling regression.
  • staticPrefetchRegion already uses a word boundary. app-browser-entry.ts:136 uses /\bconnection\s*\(/.
  • decodeSimpleJsxText already documents its single-pass intent (app-browser-entry.ts:299), with the decodes runtime-prefetch fallback entities without double-decoding test — the CodeQL "double unescaping" alert (scanning alert #238) is a documented false positive.
  • replaceInnermostSuspenseChildren already documents the depth > 100 guard (app-optimistic-routing.ts:347), as does elementHasSuspenseFallback (app-optimistic-routing.ts:271-272).

Soundness note (non-blocking)

The regex-based static param analysis in analyzePrefetchVary (collectParamAccesses, app-browser-entry.ts:200-251) correctly over-varies on the dangerous shapes — spread, Object.keys/values/entries/assign, computed params[...]/Reflect.get, "x" in params, and passing params/await params to a helper. That is the safe direction (over-varying is a perf hit, not wrong content). The one remaining approximation: arrow-form export const generateMetadata = async (...) => {} is not split from the page body by extractExportedFunction/removeExportedFunction (they only match export (async) function generateMetadata, app-browser-entry.ts:182-198), so its accesses are attributed to the body and the metadata-vs-body split is silently skipped. This over-varies (safe) but is worth a one-line comment noting the limitation, or handling the const form.

Verification run on this branch: tests/link.test.ts (121), tests/app-optimistic-routing.test.ts (14), and tests/app-prefetch-vary-analysis.test.ts (10) all pass. The regression is isolated to tests/link-navigation.test.ts.

@ask-bonk

ask-bonk Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2474.

Summary

I found one blocking regression in a required Vitest check that this PR introduces.

Blocking — tests/link-navigation.test.ts fails on this branch:

  • The test uses a shell-only automatic prefetch for dynamic routes requiring fresh navigation passes on main (test source is byte-identical) but fails on this branch (expected null to be 'prefetch-loading-shell' at line 1949). I verified by running it on this branch.
  • Root cause: the render-mode header was decoupled from the optimistic-route-shell decision. On main the shell render mode was gated on !cacheForNavigation; on this branch it's gated only on renderLoadingShell (link.tsx:530). For a dynamic route with requiresDynamicNavigationRequest: true and canPrefetchLoadingShell: false, cacheForNavigation is false (so it's still an optimistic-route-shell prefetch) but renderLoadingShell is false, so the APP_RSC_RENDER_MODE_PREFETCH_LOADING_SHELL header is now dropped. optimisticRouteShell (link.tsx:696) also collapses to false for this route.
  • Needs a decision on intent + a tests/link-navigation.test.ts update (it's not in the diff, so it wasn't re-derived against the new semantics).

Verified stale (no action): I re-checked every follow-up from the earlier conflicting rounds against HEAD:

  • __VINEXT_PREFETCH_INLINING is NOT dead — the gate (link.tsx:616), read (link.tsx:164), define, and declaration are all still wired.
  • Scheduler is unchanged (requestIdleCallback ?? setTimeout(fn, 100), link.tsx:461) — no idle regression.
  • staticPrefetchRegion already uses \bconnection\s*\(, decodeSimpleJsxText already has its single-pass comment (CodeQL alert is a documented false positive), and both recursion guards already document depth > 100.

Non-blocking: arrow-form export const generateMetadata = ... isn't split from the page body (over-varies, safe) — worth a comment.

The core implementation is coherent; link.test.ts, app-optimistic-routing.test.ts, and app-prefetch-vary-analysis.test.ts all pass (145 tests). The regression is isolated to link-navigation.test.ts.

github run

@james-elicx james-elicx force-pushed the codex/fix-segment-cache-vary-params-28478866791 branch from 5ce0276 to 779f36a Compare July 1, 2026 10:38
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

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.

Review: fix(app-router): vary segment prefetches by accessed params

Re-reviewed at HEAD (779f36a). Rebuilt the package and ran the affected suites. The blocking regression from the earlier rounds is resolved, and I confirmed the "verified stale" items. I did find one real soundness asymmetry in the static analysis that is worth addressing (or at least documenting) because it can serve wrong cached content.

Resolved — tests/link-navigation.test.ts no longer regresses

The prior rounds flagged uses a shell-only automatic prefetch for dynamic routes requiring fresh navigation as failing on this branch. It now passes:

✓ tests/link-navigation.test.ts (55 tests | 54 skipped)
    ✓ uses a shell-only automatic prefetch for dynamic routes requiring fresh navigation

Root cause was fixed in resolveMatchedAutoAppRoutePrefetch (link.tsx:344): the fallback branch now returns renderLoadingShell: hasLoadingShell || requiresDynamicNavigationRequest (previously hasLoadingShell), so a dynamic route with requiresDynamicNavigationRequest: true and no loading boundary again requests APP_RSC_RENDER_MODE_PREFETCH_LOADING_SHELL. tests/link.test.ts (121), tests/app-optimistic-routing.test.ts (14), and tests/app-prefetch-vary-analysis.test.ts (10) all pass; vp check is clean on the changed files.

Soundness — searchParams access detection is weaker than params (false-negative → wrong content)

This is the direction that actually matters for correctness: a missed access → over-shared segment cache → serving one URL's content for another. collectParamAccesses (app-browser-entry.ts:200) deliberately over-varies on helper-passes and awaited aliases, but sourceAccessesSearchParams (app-browser-entry.ts:272) does not have the equivalent handling, so the two are asymmetric. Confirmed by running the actual regexes:

  • searchParams passed to a helper is not detected. export default function Page({ searchParams }) { return helper(searchParams); }sourceAccessesSearchParams returns false, so prefetchVarySearchParams stays unset and the segment is shared across all query strings even though the helper reads them. The param path handles the analogous shape via passesParamsToHelper; the searchParams path has no such branch.
  • Prop-renamed searchParams is not detected. export default async function Page({ searchParams: sp }) { const q = await sp; return q.foo; }false. (For completeness, the same prop-rename gap exists for params{ params: p } then await p is also missed — because collectParamAccesses only tracks const x = await params aliases, not the destructured-prop rename.)

Next.js does not hit these cases because it tracks access at runtime via the params/searchParams proxy; this is a vinext-specific limitation of the build-time regex approach, and the ported e2e rows only exercise the direct await searchParams shape. Suggested fix: give sourceAccessesSearchParams the same over-vary treatment (detect helper(searchParams) and destructured-prop renames the way collectParamAccesses does), or at minimum add a comment documenting that renamed/helper-passed searchParams/params are not tracked, plus e2e coverage for those shapes.

Verified stale (no action) — earlier conflicting rounds were outdated on these

  • __VINEXT_PREFETCH_INLINING is not dead: the || __prefetchInlining gate is still at link.tsx:617, and the define/declaration remain wired.
  • Scheduler is unchanged — prefetchUrl still uses window.requestIdleCallback ?? setTimeout(fn, 100). No idle regression.
  • staticPrefetchRegion uses \bconnection\s*\( (app-browser-entry.ts:136).
  • decodeSimpleJsxText is single-pass with an explaining comment (app-browser-entry.ts:299) — the CodeQL "double unescaping" alert is a documented false positive.
  • replaceInnermostSuspenseChildren documents its depth > 100 guard (app-optimistic-routing.ts).

Nit

  • Arrow-form export const generateMetadata = async (...) => {} is not split from the page body (extractExportedFunction/removeExportedFunction only match export (async) function), so its accesses are attributed to the body. This over-varies (safe), but silently disables the metadata-vs-body split for arrow-form exports — worth a one-line comment or handling the const form. This is the same class of gap as the searchParams note above.

The core implementation (variant-keyed optimistic templates, suspense-child preservation, sharedCacheKey-based reuse) is coherent, and the regression is gone. The searchParams-vs-params detection asymmetry is the one item I'd want addressed or explicitly documented before merge.

@ask-bonk

ask-bonk Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2474.

Summary

I rebuilt the package and ran the affected suites. Findings:

Resolved — the blocking regression from prior rounds is fixed.

  • tests/link-navigation.test.ts (uses a shell-only automatic prefetch for dynamic routes requiring fresh navigation) now passes on this branch. The fix is at link.tsx:344: the fallback branch returns renderLoadingShell: hasLoadingShell || requiresDynamicNavigationRequest (previously just hasLoadingShell), restoring the shell render-mode header for dynamic routes that require a fresh navigation but have no loading boundary. link.test.ts (121), app-optimistic-routing.test.ts (14), app-prefetch-vary-analysis.test.ts (10) all pass; vp check is clean.

New soundness finding (the one item worth acting on): sourceAccessesSearchParams is weaker than collectParamAccesses in the false-negative direction, which serves wrong cached content:

  • helper(searchParams) (passing to a helper) is not detected → over-shares cache across query strings. The params path handles this via passesParamsToHelper; searchParams has no equivalent.
  • Prop-renamed { searchParams: sp } / { params: p } then await is not detected.

I confirmed both by running the actual regexes. Next.js avoids these via runtime proxy tracking, so they aren't in the ported e2e rows. Suggested: mirror the over-vary treatment or document the limitation + add e2e coverage.

Verified stale (no action): __VINEXT_PREFETCH_INLINING gate still wired, scheduler unchanged (requestIdleCallback ?? setTimeout(100)), \bconnection\s*\( word boundary present, decodeSimpleJsxText single-pass comment present (CodeQL alert is a false positive), depth > 100 guard documented.

Nit: arrow-form export const generateMetadata = ... isn't split from the page body (over-varies, safe) — worth a comment.

github run

@james-elicx james-elicx force-pushed the codex/fix-segment-cache-vary-params-28478866791 branch from 779f36a to 59376b9 Compare July 1, 2026 11:32
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

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.

Review: fix(app-router): vary segment prefetches by accessed params

Re-reviewed the current HEAD (59376b9, the branch has been squashed to a single commit). I rebuilt the package (vp run vinext#build — clean) and ran the affected suites. No blocking issues remain. The blocking regression and the searchParams-detection asymmetry flagged in earlier rounds are both resolved. One small non-blocking nit remains.

Resolved — prior blocking regression is gone

tests/link-navigation.test.ts > uses a shell-only automatic prefetch for dynamic routes requiring fresh navigation now passes on this branch. The fix is in resolveMatchedAutoAppRoutePrefetch (link.tsx:344): the fallback branch returns renderLoadingShell: hasLoadingShell || requiresDynamicNavigationRequest, so a dynamic route with requiresDynamicNavigationRequest: true and no loading boundary again requests APP_RSC_RENDER_MODE_PREFETCH_LOADING_SHELL.

Verification on this branch:

  • tests/link-navigation.test.ts — 55 pass
  • tests/link.test.ts — 121 pass
  • tests/app-optimistic-routing.test.ts — 14 pass
  • tests/app-prefetch-vary-analysis.test.ts — 14 pass
  • tests/entry-templates.test.ts (40) + tests/rsc-reference-validation-compat.test.ts (5) pass
  • vp check clean on the changed source files

Resolved — searchParams detection is no longer asymmetric with params

The previous round flagged that sourceAccessesSearchParams missed helper-passes and prop-renamed searchParams. That is fixed at HEAD (app-browser-entry.ts:298-332): the searchParams path now collects prop aliases via collectPropAliases, tracks const x = await searchParams aliases, and detects the same dangerous shapes as the params path — spread, Object.keys/values/entries/assign, Reflect.get, computed access, and helper-passes (helper(searchParams) / helper(await searchParams), line 328-330). So both params and searchParams now over-vary in the same false-negative-avoiding way. tests/app-prefetch-vary-analysis.test.ts grew to 14 tests covering these shapes.

Verified sound

  • sharedCacheKey navigation reuse (navigation.ts:494-527): the cross-URL match in findPrefetchCacheEntryForNavigation only bypasses the URL-normalization check when options.sharedCacheKey is a non-null string and entry.sharedCacheKey === options.sharedCacheKey. Correct — reuse only happens when the analysis proved the segment doesn't vary by the differing URL portion.
  • staticPrefetchRegion uses \bconnection\s*\( (word boundary present, app-browser-entry.ts:141).
  • decodeSimpleJsxText is single-pass with an explaining comment (app-browser-entry.ts:354) — the CodeQL "double unescaping" alert (#238) is a documented false positive.
  • replaceInnermostSuspenseChildren documents its depth > 100 guard (app-optimistic-routing.ts:322-323).

Nit (non-blocking, safe direction)

Arrow-form export const generateMetadata = async (...) => {} is still not split from the page body. extractExportedFunction / removeExportedFunction (app-browser-entry.ts:187-203) only match export (async) function generateMetadata, so for the const/arrow form metadataSource is empty and the arrow function stays in pageBodySource. I confirmed this by running the regex. The effect is that metadata param accesses get attributed to the page body (and to runtimePrefetchParamNames), which over-varies — the safe direction, never wrong content. It just silently disables the metadata-vs-body split for arrow-form exports. Worth a one-line comment noting the limitation, or handling the const form, but not a blocker.

The core implementation (variant-keyed optimistic templates, suspense-child preservation, sharedCacheKey-based reuse, and the over-varying regex analysis) is coherent and its unit tests pass. Good to merge once the arrow-form nit is either addressed or consciously deferred.

@ask-bonk

ask-bonk Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2474.

Summary

I reviewed the current HEAD (59376b9 — the branch has been squashed into a single commit). I rebuilt the package and ran all affected test suites. No blocking issues remain.

Resolved since prior rounds:

  • Blocking regression fixedtests/link-navigation.test.ts > uses a shell-only automatic prefetch for dynamic routes requiring fresh navigation now passes. The resolveMatchedAutoAppRoutePrefetch fallback returns renderLoadingShell: hasLoadingShell || requiresDynamicNavigationRequest (link.tsx:344), restoring the shell render mode for dynamic routes requiring fresh navigation.
  • searchParams detection asymmetry fixedsourceAccessesSearchParams (app-browser-entry.ts:298-332) now handles prop aliases, awaited aliases, helper-passes, spread, Object.keys/values/entries, Reflect.get, and computed access, matching the params path's over-varying (safe) behavior.

Verified sound:

  • sharedCacheKey navigation reuse only bypasses URL normalization when the key is a non-null string and matches exactly — correct.
  • Word-boundary \bconnection\s*\(, single-pass decodeSimpleJsxText (CodeQL feat: add Nix flake for reproducible dev environment #238 is a documented false positive), and depth > 100 recursion guard are all present.

Test verification (all pass): link-navigation (55), link (121), app-optimistic-routing (14), app-prefetch-vary-analysis (14), entry-templates (40), rsc-reference-validation-compat (5); vp check clean.

Non-blocking nit: Arrow-form export const generateMetadata = async (...) => {} isn't split from the page body (only export function is matched), so its accesses over-vary — the safe direction, never wrong content. Worth a comment or const-form handling, but not a blocker.

github run

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

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.

Review: fix(app-router): vary segment prefetches by accessed params

Re-reviewed at HEAD (35ef1e8 — a new commit fix(app-router): detect props-based prefetch vary access sits on top of the squashed 59376b9). I rebuilt the package (vp run vinext#build — clean) and ran all affected suites. No blocking issues remain.

Verified — all prior blocking items are resolved

  • tests/link-navigation.test.ts no longer regresses. uses a shell-only automatic prefetch for dynamic routes requiring fresh navigation passes (55/55). The fix is in resolveMatchedAutoAppRoutePrefetch (link.tsx:344): the fallback branch returns renderLoadingShell: hasLoadingShell || requiresDynamicNavigationRequest, restoring the shell render mode for dynamic routes that require a fresh navigation but have no loading boundary.
  • searchParams detection is symmetric with params. The new commit gives sourceAccessesSearchParams (app-browser-entry.ts:324-363) the same over-varying treatment as collectParamAccesses: I ran the actual regexes and confirmed it now detects sync prop access ({ searchParams }searchParams.q), renamed props ({ searchParams: sp }sp.q), awaited aliases, and helper-passes (helper(searchParams)). All are the safe (over-vary) direction, so no missed access → no wrong cached content.

Verified sound

  • sharedCacheKey cross-URL reuse is correctly gated (navigation.ts:512-524). An entry is only reused across a differing URL when options.sharedCacheKey is a non-null string and entry.sharedCacheKey === options.sharedCacheKey; otherwise the normalized-URL equality still governs. Reuse only happens when the static analysis proved the segment doesn't vary by the differing URL portion. resolveAppPrefetchSharedCacheKey (navigation.ts:432-473) folds accessed vary params into the key and leaves non-accessed params as :name placeholders, so distinct accessed-param values produce distinct keys.
  • Helper-pass over-vary is safe, not overreaching. fn(params.category) matches passesParamsToHelper (over-vary), while params.category alone does not — correct.
  • staticPrefetchRegion uses \bconnection\s*\( (word boundary, app-browser-entry.ts:141); decodeSimpleJsxText is single-pass with an explaining comment (app-browser-entry.ts:384-385) — the CodeQL "double unescaping" alert #238 is a documented false positive; replaceInnermostSuspenseChildren documents its depth > 100 guard (app-optimistic-routing.ts).
  • __VINEXT_PREFETCH_INLINING is not dead — the || __prefetchInlining gate is still wired at link.tsx:617, and the define/declaration remain consistent. (Earlier rounds that flagged this as dead and claimed the scheduler changed to setTimeout(fn, 0) were incorrect; the scheduler is still requestIdleCallback ?? setTimeout(fn, 100) at link.tsx:462.)

Scope note (non-blocking)

This PR also carries a global-error-document refactor (default-global-error-document.tsx, error-boundary.tsx, app-ssr-entry.ts) and a new rsc-reference-validation-compat.ts plugin, which are unrelated to the vary-params title. They look correct in isolation (the SSR-only server-component mirror avoids creating a plugin-rsc client reference from the SSR environment; the validation plugin only accepts a decoded key already present in plugin-rsc's allowlist), and tests/rsc-reference-validation-compat.test.ts (5) passes. Worth a one-line note in the PR body that these are bundled fixes, so reviewers aren't surprised by the diff scope.

Nit (non-blocking, safe direction)

Arrow-form export const generateMetadata = async (...) => {} is still not split from the page body — extractExportedFunction/removeExportedFunction (app-browser-entry.ts:187-203) only match export (async) function generateMetadata. I confirmed the const/arrow form yields an empty metadataSource, so metadata param accesses are attributed to the page body (and runtimePrefetchParamNames), which over-varies — the safe direction, never wrong content. It silently disables the metadata-vs-body split for arrow-form exports and has no test/comment. Worth a one-line comment noting the limitation, or handling the const form, but not a blocker.

Test verification (all pass)

link-navigation (55), link (121), app-optimistic-routing (14), app-prefetch-vary-analysis (18), entry-templates (40), rsc-reference-validation-compat (5), and app-router/features (338). vp check clean on the changed source files.

The core implementation (variant-keyed optimistic templates, suspense-child preservation, sharedCacheKey-based reuse, and the now-symmetric over-varying regex analysis) is coherent. Good to merge once the arrow-form nit is either addressed or consciously deferred.

@ask-bonk

ask-bonk Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2474.

Summary

Re-reviewed at HEAD (35ef1e8, a new commit sits on top of the squashed 59376b9). Rebuilt the package and ran all affected suites. No blocking issues remain.

Verified resolved:

  • tests/link-navigation.test.ts — the prior blocking regression (uses a shell-only automatic prefetch for dynamic routes requiring fresh navigation) now passes. Fixed at link.tsx:344 (renderLoadingShell: hasLoadingShell || requiresDynamicNavigationRequest).
  • searchParams detection asymmetry — the new commit gives sourceAccessesSearchParams the same over-varying treatment as collectParamAccesses. I ran the actual regexes and confirmed it now catches sync access, renamed props, awaited aliases, and helper-passes (all safe over-vary direction).

Verified sound:

  • sharedCacheKey cross-URL reuse is correctly gated (only reuses when the key is a non-null string and matches exactly).
  • __VINEXT_PREFETCH_INLINING is NOT dead, scheduler is unchanged (requestIdleCallback ?? setTimeout(fn, 100)), word-boundary \bconnection\s*\(, single-pass decodeSimpleJsxText (CodeQL feat: add Nix flake for reproducible dev environment #238 is a false positive), and depth > 100 guard all present — several earlier rounds were incorrect on these.

Scope note (non-blocking): the PR bundles an unrelated global-error-document refactor + a new rsc-reference-validation-compat plugin; both look correct in isolation but are outside the vary-params title — worth calling out in the PR body.

Nit (non-blocking, safe): arrow-form export const generateMetadata = ... still isn't split from the page body (over-varies, never wrong content).

All tests pass (253 in the focused suites + 338 in app-router/features); vp check clean.

github run

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

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.

Review: fix(app-router): vary segment prefetches by accessed params

Re-reviewed at HEAD (bfe151d, fix(app-router): tighten segment prefetch vary analysis — a new commit on top of the previously-reviewed tree). Rebuilt and ran all affected suites. No blocking issues remain.

New commit review — the connection-detection tightening is a real correctness improvement

bfe151d fixes a genuine false-positive in the static-analysis cutoff. Previously findConnectionCallIndex truncated staticPrefetchRegion at any identifier named connection( regardless of origin. Now it only treats connection() as the Server-side cutoff when the identifier actually resolves to a next/server import:

  • collectNextServerConnectionIdentifiers (app-browser-entry.ts:168) scans import { connection } from "next/server" (and next/server.js, and connection as alias), and findConnectionCallIndex bails early (returns null) when no such import exists.

I ran the actual import regex against edge cases and confirmed it behaves correctly:

  • import { connection } from "next/server"connection
  • import { connection } from "next/server.js"connection
  • import { connection as waitFor } from "next/server"waitFor
  • import { NextResponse, connection } from "next/server"connection
  • import { connection } from "./db" → (empty — local helper no longer truncates)
  • import type { connection } from "next/server" → (empty — type-only import correctly ignored, since import type { isn't matched by import\s*\{)

This is the safe direction: before, a local connection() helper or a ./db import would truncate the region early and drop later param accesses (a false negative → over-shared cache → wrong content). The three new tests (does not truncate ... on local connection helpers, ... on non-Next connection imports, uses aliased next/server.js connection imports as the static analysis cutoff) lock this in.

The commit also adds two param-access shapes to collectParamAccesses:

  • destructured-from-alias: const { itemId } = resolved; where resolved = await params (app-browser-entry.ts:399-406)
  • awaited-member: const itemId = (await params).itemId; (app-browser-entry.ts:407-414)

Both are bounded by sortedKnownParams (line 569) which filters to route.params, so any over-matching stays in the over-vary (safe) direction. Verified by running the regexes.

Verified sound (re-checked at this HEAD)

  • sharedCacheKey cross-URL reuse is correctly gated (navigation.ts): reuse across a differing URL only happens when options.sharedCacheKey is a non-null string and exactly matches entry.sharedCacheKey; otherwise normalized-URL equality governs.
  • searchParams detection is symmetric with params (sourceAccessesSearchParams, app-browser-entry.ts:455): prop aliases, awaited aliases, spread, Object.keys/values/entries/assign, Reflect.get, computed access, and helper-passes are all detected (over-vary direction).
  • decodeSimpleJsxText is single-pass with an explaining comment (app-browser-entry.ts:521) — the CodeQL "double unescaping" alert #238 is a documented false positive.
  • replaceInnermostSuspenseChildren documents its depth > 100 guard (app-optimistic-routing.ts:324).
  • __VINEXT_PREFETCH_INLINING remains consistently wired (gate + define + declaration); scheduler unchanged.

Scope note (non-blocking)

This PR still bundles the unrelated global-error-document refactor (default-global-error-document.tsx, error-boundary.tsx, app-ssr-entry.ts) and the new dev-only rsc-reference-validation-compat.ts plugin. I re-checked the plugin: it's apply: "serve", and shouldAcceptDecodedViteRscReferenceValidation only accepts a decoded key that is already present in plugin-rsc's clientReferenceMetaMap allowlist (it just normalizes the \0 vs __x00__ encoding mismatch), so it doesn't widen the trust boundary. Correct in isolation, but worth a one-line note in the PR body that these are bundled fixes so reviewers aren't surprised by the diff scope.

Nit (non-blocking, safe direction)

Arrow-form export const generateMetadata = async (...) => {} is still not split from the page body — extractExportedFunction/removeExportedFunction (app-browser-entry.ts:273-289) only match export (async) function generateMetadata. I confirmed the const/arrow form yields an empty metadataSource, so metadata accesses are attributed to the page body (and runtimePrefetchParamNames), which over-varies — safe, never wrong content. It silently disables the metadata-vs-body split for arrow-form exports and has no test/comment. Worth a one-line comment or const-form handling, but not a blocker.

Test verification (all pass)

app-prefetch-vary-analysis (25), app-optimistic-routing (14), link (121), link-navigation (55) — 215 total; vp check clean on the changed source files.

The core implementation (variant-keyed optimistic templates, suspense-child preservation, sharedCacheKey-based reuse, and the now-tightened over-varying regex analysis) is coherent. Good to merge once the arrow-form nit is either addressed or consciously deferred.

@ask-bonk

ask-bonk Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2474.

Summary

Re-reviewed at HEAD (bfe151d, a new commit fix(app-router): tighten segment prefetch vary analysis on top of the previously-reviewed tree). Rebuilt and ran all affected suites. No blocking issues remain.

The new commit is a real correctness improvement

bfe151d fixes a genuine false-positive in the static-analysis cutoff:

  • Connection detection now requires a real next/server import. Previously any connection( identifier truncated the analyzed region — a local connection() helper or a ./db import would drop later param accesses (false negative → over-shared cache → wrong content). Now collectNextServerConnectionIdentifiers only treats connection() as the cutoff when it resolves to a next/server/next/server.js import (including connection as alias). I ran the import regex against 7 edge cases and confirmed correct behavior (local imports and type-only imports correctly ignored).
  • Two new param-access shapes added to collectParamAccesses: destructured-from-alias (const { itemId } = resolved where resolved = await params) and awaited-member (const itemId = (await params).itemId). Both are bounded by sortedKnownParams (over-vary/safe direction).

Verified sound at this HEAD

  • sharedCacheKey cross-URL reuse correctly gated; searchParams detection symmetric with params; single-pass decodeSimpleJsxText (CodeQL feat: add Nix flake for reproducible dev environment #238 is a false positive); depth > 100 recursion guard documented; __VINEXT_PREFETCH_INLINING consistently wired; scheduler unchanged.

Non-blocking

  • Scope note: the PR still bundles an unrelated global-error-document refactor and a dev-only rsc-reference-validation-compat plugin (both correct in isolation) — worth a note in the PR body.
  • Nit: arrow-form export const generateMetadata = ... still isn't split from the page body (over-varies, safe direction).

All 215 tests in the affected suites pass; vp check clean. Good to merge once the arrow-form nit is addressed or consciously deferred.

github run

@james-elicx

Copy link
Copy Markdown
Member Author

Backlog coverage check for GitHub Actions run 28478866791 / job 84413308650: this PR looks like the likely owner for the functional test/e2e/app-dir/segment-cache/client-params/client-params.test.ts failure (Timed out waiting for a request). It already fixes the same segment-cache vary/reuse machinery and has exact proof for segment-cache/vary-params, so I am not opening a duplicate branch for client-params unless the targeted wrapper still fails on this head.

Please include or rerun the exact targeted wrapper for test/e2e/app-dir/segment-cache/client-params/client-params.test.ts before considering this backlog item closed.

# Conflicts:
#	packages/vinext/src/index.ts
#	packages/vinext/src/server/app-browser-entry.ts
#	packages/vinext/src/shims/link.tsx
#	packages/vinext/src/shims/navigation.ts
@github-actions

github-actions Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Performance benchmarks

Compared 8e671bf against base 239595e using alternating same-runner rounds. Next.js was unchanged and skipped.

0 improved · 1 regressed · 5 within ±1.5%

Scenario Framework Baseline Current Change
Client bundle size (gzip) vinext 125.6 KB 126.9 KB ⚫ +1.0%
Client entry size (gzip) vinext 119.8 KB 120.8 KB ⚫ +0.8%
Dev server cold start vinext 2.69 s 2.68 s ⚫ -0.3%
Production build time vinext 3.25 s 3.30 s 🔴 +1.6%
RSC entry closure size (gzip) vinext 96.8 KB 96.8 KB ⚫ -0.0%
Server bundle size (gzip) vinext 163.2 KB 164.6 KB ⚫ +0.9%

View detailed results and traces

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

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