Skip to content

fix(cache): keep encoded dynamic prefetches learning-only - #2866

Merged
james-elicx merged 3 commits into
mainfrom
codex/fix-segment-cache-encoded-slash-current
Aug 10, 2026
Merged

fix(cache): keep encoded dynamic prefetches learning-only#2866
james-elicx merged 3 commits into
mainfrom
codex/fix-segment-cache-encoded-slash-current

Conversation

@james-elicx

Copy link
Copy Markdown
Member

Summary

  • keep Cache Components automatic dynamic-route prefetches learning-only so encoded path identity remains authoritative at navigation time
  • dedupe pending and fresh learning-only Link prefetches, while evicting and refetching settled entries after expiry
  • cover pending, settled-fresh, and expired Link remount behavior

Original deploy-suite rows

Next.js v16.2.6 suite: test/e2e/app-dir/segment-cache/encoded-slash-params/encoded-slash-params.test.ts

  • unencoded param > back navigation does not refetch the route tree
  • encoded slash in param > back navigation does not refetch the route tree

Verification

  • exact upstream v16.2.6 baseline: 0/2 passed
  • exact upstream v16.2.6 with this patch: 2/2 passed
  • focused link/navigation units: 274/274 passed
  • scoped format, lint, and type checks passed for all 4 changed files
  • git diff --check passed

Draft pending CI, Big Bonk, and an exact-head deploy-suite audit.

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@pkg-pr-new

pkg-pr-new Bot commented Aug 10, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@vinext/cloudflare@2866
npm i https://pkg.pr.new/create-vinext-app@2866
npm i https://pkg.pr.new/@vinext/types@2866
npm i https://pkg.pr.new/vinext@2866

commit: 804e16f

@github-actions

github-actions Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Performance benchmarks

Compared 804e16f against base 056cd02 using alternating same-runner rounds. Next.js was unchanged and skipped.

0 improved · 0 regressed · 6 within ±1.5%

Scenario Framework Baseline Current Change
Client bundle size (gzip) vinext 134.7 KB 134.8 KB ⚫ +0.1%
Client entry size (gzip) vinext 122.2 KB 122.3 KB ⚫ +0.1%
Dev server cold start vinext 3.01 s 3.01 s ⚫ +0.0%
Production build time vinext 3.30 s 3.34 s ⚫ +1.2%
RSC entry closure size (gzip) vinext 114.3 KB 114.3 KB ⚫ +0.0%
Server bundle size (gzip) vinext 191.9 KB 191.9 KB ⚫ +0.0%

View detailed results and traces

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

@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(cache): keep encoded dynamic prefetches learning-only

Reviewed the policy change, the new hasFreshLearningOnlyPrefetchCacheEntry helper, the <Link> dedupe rewrite, and the new test. The <Link> path and its test are sound (75/75 link-navigation, 127/127 link, checks all green). One real issue.

Issue: router.prefetch() learning-only dedupe was not updated — parity gap with <Link>

packages/vinext/src/shims/navigation.ts:2789

The PR's core behavioral change is switching <Link>'s learning-only dedupe from permanent prefetched.has(cacheKey) membership to the freshness-aware hasFreshLearningOnlyPrefetchCacheEntry (which evicts + allows a refetch once the entry's window expires). But router.prefetch() still uses the old permanent-membership gate for its learning-only (!reusable) branch:

} else if (prefetched.has(cacheKey)) {
  attachPrefetchInvalidationCallback(cacheKey, options?.onInvalidate);
  return;
}

Both <Link> and router.prefetch() resolve policy via the same resolveAutoAppRoutePrefetch and write into the same prefetch cache, so the new policy change (isCacheComponentsDynamicRoutecacheForNavigation: false) applies to router.prefetch() too. That means:

  • router.prefetch("/products/foo%2Fbar") under Cache Components produces a learning-only entry (reusable === false).
  • A settled learning-only entry is never swept on expiry — nothing calls deletePrefetchCacheEntry for it until byte-budget/LRU eviction or a navigation gate (prefetched.delete only fires in deletePrefetchCacheEntry at navigation.ts:960 and LRU eviction at :811).
  • So prefetched.has(cacheKey) stays true forever, and repeated router.prefetch() calls return early at :2789 and never refetch after expiry — the exact behavior this PR fixes for <Link>.

Per AGENTS.md ("request handling logic exists in multiple places that must stay in sync … do not leave known bugs as follow-ups — fix them in the same PR"), router.prefetch()'s learning-only branch should route through hasFreshLearningOnlyPrefetchCacheEntry (with additionalRscUrls handling as needed) so its expiry/refetch semantics match <Link>. Add a router.prefetch() unit mirroring the new Link test (pending dedupe, settled-fresh suppression, expired refetch).

If this divergence is intentional (e.g., router.prefetch() is considered explicit-intent and should stay sticky), please document why in a comment at :2789 and note it in the PR body, since it reads as an accidental miss right now.

Minor / non-blocking

  • Env define shapeString(process.env.__NEXT_CACHE_COMPONENTS) === "true" (app-route-prefetch-policy.ts:103) is correct: the plugin defines this as a boolean literal (index.ts:2361, JSON.stringify(cacheComponents ?? false)), and String(true) === "true". Matches the existing slot.tsx:68 pattern. No change needed — flagging only to confirm it was checked.
  • Test Date.now spy — the new test's vi.spyOn(Date, "now") (link-navigation.test.ts:2096) isn't restored in its own finally, but the suite afterEach runs vi.restoreAllMocks() (:448), so it doesn't leak. Fine as-is.
  • Helper exact-key lookuphasFreshLearningOnlyPrefetchCacheEntry looks up only the exact encodeCacheKey(rscUrl, ctx). That's correct for learning-only entries because cacheForNavigation === false entries never get rendered-path aliases (addRenderedPathAndSearchPrefetchAlias early-returns at navigation.ts:1447), so there's no alias key to miss.

Overall the encoded-slash learning-only fix for <Link> is well-targeted and tested; the blocking concern is the un-mirrored router.prefetch() path.

@ask-bonk

ask-bonk Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2866. Summary of my findings:

Review Summary

I examined the policy change, the new hasFreshLearningOnlyPrefetchCacheEntry helper, the <Link> dedupe rewrite, and the new test. Ran the relevant test files (75/75 link-navigation, 127/127 link) and vp check on all 4 changed files — all green.

One blocking issue

router.prefetch() learning-only dedupe was not updated (navigation.ts:2789). The PR's whole point is switching <Link>'s learning-only dedupe from permanent prefetched.has(cacheKey) membership to the freshness-aware helper that refetches after expiry. But router.prefetch() shares the same policy resolver (resolveAutoAppRoutePrefetch) and the same prefetch cache, yet still uses the old permanent-membership gate for its learning-only branch. Since the new isCacheComponentsDynamicRoute policy makes those entries cacheForNavigation: false for router.prefetch() too, and settled learning-only entries are never swept on expiry, repeated router.prefetch() calls will return early forever and never refetch after expiry — the exact bug fixed for <Link>. AGENTS.md explicitly requires keeping these parallel code paths in sync in the same PR.

Non-blocking (verified, no change needed)

  • Env define shapeString(process.env.__NEXT_CACHE_COMPONENTS) === "true" is correct given the boolean define; matches slot.tsx.
  • Test Date.now spy — not restored in the test's own finally, but suite afterEach runs vi.restoreAllMocks(), so no leak.
  • Helper exact-key lookup — correct, because learning-only entries never get rendered-path aliases.

github run

@github-actions

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

@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(cache): keep encoded dynamic prefetches learning-only

Reviewed both commits — the original learning-only policy change (fb136ef) and the follow-up that closed the router.prefetch() parity gap (7c5c680). The core <Link> + router.prefetch() fix is well-targeted, correctly freshness-aware, and tested (148/148 across link-navigation + prefetch-cache, vp check green on all changed source files).

Blocking issue from the earlier review is resolved

The prior review flagged that router.prefetch()'s learning-only branch still used the permanent prefetched.has(cacheKey) gate. Commit 7c5c680 routes it through hasFreshLearningOnlyPrefetchCacheEntry (navigation.ts:2789) and adds a mirroring unit in prefetch-cache.test.ts covering pending-dedupe, settled-fresh suppression, and expired refetch. <Link> and router.prefetch() are now in sync. Verified:

  • The cacheForNavigation === true fall-through in link.tsx is behavior-preserving vs. the old code (old inner if (!cacheForNavigation) never returned in that branch either).
  • The helper's exact-key lookup is correct — learning-only entries (cacheForNavigation === false) never get rendered-path aliases, so there's no alias key to miss.
  • deletePrefetchCacheEntry(..., notify=true) on expiry-sweep matches every other expiry-based deletion in the file (e.g. evictPrefetchCacheIfNeeded at :787, hasPrefetchCacheEntryForNavigation default at :674).
  • resolvePrefetchCacheEntryExpiresAt falls back to timestamp + PREFETCH_CACHE_TTL for learning-only entries lacking an explicit expiresAt, so the freshness gate is well-defined.

Non-blocking: same learning-only dedupe gap still exists in next/form

packages/vinext/src/shims/form.tsx:343

<Form>'s viewport prefetch writes a learning-only entry (cacheForNavigation: false, form.tsx:365) and dedupes with the exact permanent-membership pattern this PR just replaced in link.tsx / navigation.ts:

const prefetched = getPrefetchedUrls();
if (prefetched.has(cacheKey)) return;
if (hasPrefetchCacheEntryForNavigation(rscUrl, interceptionContext, mountedSlotsHeader)) return;

Because a settled learning-only entry is never swept on expiry (nothing calls deletePrefetchCacheEntry for it until LRU/byte-budget eviction or a navigation gate), prefetched.has(cacheKey) stays true forever and <Form> never refetches after expiry — the exact behavior this PR fixes for <Link> and router.prefetch().

This is a pre-existing gap, not a regression from this PR: form.tsx always hardcodes cacheForNavigation: false and does not consult resolveAutoAppRoutePrefetch, so the new isCacheComponentsDynamicRoute policy branch doesn't change its behavior. It's out of scope for the stated encoded-slash fix, so not blocking. But since AGENTS.md calls out keeping these parallel prefetch-dedupe paths in sync, it's worth either folding form.tsx into hasFreshLearningOnlyPrefetchCacheEntry here or filing a follow-up so the three learning-only callers don't drift.

Minor (verified, no change needed)

  • Env define shapeString(process.env.__NEXT_CACHE_COMPONENTS) === "true" (app-route-prefetch-policy.ts:100) matches the boolean define and the existing slot.tsx pattern. Correct.
  • Test Date.now spies — neither new test restores its vi.spyOn(Date, "now") in a finally, but suite-level afterEach vi.restoreAllMocks() covers it. Fine.

Overall LGTM on the encoded-slash learning-only fix. The one thing I'd want a decision on before merge is whether form.tsx's identical learning-only gate should be folded in now or tracked as a follow-up.

@ask-bonk

ask-bonk Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2866.

Summary

I reviewed both commits on the PR — the original learning-only policy change (fb136ef) and a follow-up (7c5c680) that landed after the earlier ask-bonk review.

The previously-flagged blocking issue is resolved. The follow-up commit routes router.prefetch()'s learning-only branch through the new hasFreshLearningOnlyPrefetchCacheEntry helper (navigation.ts:2789) and adds a mirroring unit test in prefetch-cache.test.ts. <Link> and router.prefetch() are now in sync. I verified the fall-through behavior is preserved for the navigation-reusable path, the exact-key lookup is correct for learning-only entries, and the expiry-sweep notify=true matches the rest of the file. Ran 148/148 tests green and vp check clean.

One non-blocking finding I surfaced independently: next/form's viewport prefetch (form.tsx:343) has the identical learning-only dedupe gap — it writes cacheForNavigation: false entries and gates on permanent prefetched.has(cacheKey) membership, so it never refetches after expiry. This is a pre-existing gap (not a regression, since form.tsx doesn't consult the new Cache Components policy) and is out of scope for the encoded-slash fix, but per AGENTS.md's "keep parallel paths in sync" guidance I recommended either folding it into the same helper now or tracking it as a follow-up so the three learning-only callers don't drift.

Overall the encoded-slash fix is well-targeted and tested; the only open question for merge is the form.tsx parity decision.

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(cache): keep encoded dynamic prefetches learning-only

Reviewed all three commits, including the third (804e16f "preserve static prefetch paths") that landed after the previous ask-bonk reviews and had not been analyzed yet. Ran link.test.ts + link-navigation.test.ts + prefetch-cache.test.ts (276/276 green) and vp check on all three changed source files (format/lint/types clean). Confirmed the narrowing against the upstream fixture.

The un-reviewed commit 804e16f is the substantive change — and it's correct

The earlier reviews only saw fb136ef + 7c5c680, where the policy was "any Cache Components dynamic route → learning-only". Commit 804e16f narrows that to:

const isFullyDynamicRootRoute =
  route.patternParts.length === 1 && route.patternParts[0]?.startsWith(":");
const hasCacheComponentsLearningOnlyDynamicPath =
  route.isDynamic &&
  String(process.env.__NEXT_CACHE_COMPONENTS) === "true" &&
  (isFullyDynamicRootRoute || ENCODED_PATH_DELIMITER_RE.test(routeUrl.pathname));

This is the right call and matches the upstream fixture exactly. The Next.js test (encoded-slash-params.test.ts, v16.2.6) explicitly documents "The fixture is intentionally fully dynamic (no generateStaticParams)" and is a single-segment root route (/[slug]), exercising both /foo (unencoded root) and /foo%2Fbar (encoded). Both must stay learning-only. The prior blanket rule also demoted prefixed dynamic routes like /products/[id] to learning-only, killing navigation-reuse where the encoded-slash cache-key mismatch doesn't apply — hence the narrowing "preserves static prefetch paths." Verified:

  • URL.pathname preserves %2F/%5C (uppercase + lowercase) — the regex /%(?:2f|5c)/i matches all four; the delimiter check works.
  • route.patternParts is a required (non-optional) field on VinextLinkPrefetchRoute and is populated by the manifest generator (index.ts:3873), so .length is always safe; the ?. guards the empty-array (/) case.
  • The encoded-delimiter branch is correctly gated behind route.isDynamic, so a static route containing %2F is not demoted.
  • requiresDynamicNavigationRequest / canPrefetchLoadingShell still cover the general "dynamic nav needs fresh data" cases independently — this gate is scoped to the encoded-slash + root-dynamic identity problem only.

Note: catch-all root routes (/[...slug]patternParts: [":slug+"], /[[...slug]][":slug*"]) also satisfy isFullyDynamicRootRoute and stay learning-only. That's consistent with the fully-dynamic-root rationale, but it's a behavior the upstream fixture doesn't directly cover — worth a one-line confirmation from the author that it's intended (not blocking).

<Link> dedupe rewrite is behavior-preserving

The old gate returned early only inside the !cacheForNavigation branch; the new gate !autoPrefetch.cacheForNavigation && hasFreshLearningOnlyPrefetchCacheEntry(...) returns in exactly that same case, and never early-returns for cacheForNavigation === true (matching the old fall-through). prefetched and cacheKey remain in use downstream (link.tsx:634–646), so no dead variables. The helper's exact-key lookup is correct — addRenderedPathAndSearchPrefetchAlias early-returns for cacheForNavigation === false (navigation.ts:1447), so learning-only entries never get an alias key to miss.

Agreement with the prior reviews (no new blocking issues)

  • The router.prefetch() parity gap flagged in the first review is genuinely closed by 7c5c680 (navigation.ts:2789 routes through the freshness helper, with a mirroring prefetch-cache.test.ts unit).
  • The next/form viewport-prefetch learning-only gap (form.tsx:343) is real but pre-existing and out of scopeform.tsx hardcodes cacheForNavigation: false and never consults resolveAutoAppRoutePrefetch, so this PR doesn't change its behavior. Recommend tracking it as a follow-up so the three learning-only callers (link.tsx, navigation.ts, form.tsx) don't drift, per AGENTS.md "keep parallel paths in sync."

Minor (verified, no change needed)

  • Env define shapeString(process.env.__NEXT_CACHE_COMPONENTS) === "true" matches the boolean define (JSON.stringify(cacheComponents ?? false)) and the existing slot.tsx pattern.
  • Test Date.now spies — not restored in-test, but suite afterEach vi.restoreAllMocks() covers it.

Verdict: LGTM. No blocking issues. The only open items are the author's confirmation that catch-all-root learning-only demotion is intended, and a decision to fold form.tsx in now vs. a tracked follow-up.

@ask-bonk

ask-bonk Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2866.

Summary

I focused on the third commit 804e16f ("preserve static prefetch paths"), which landed after the two prior ask-bonk reviews and had not yet been analyzed. That commit is the substantive change of the PR.

No new blocking issues. The encoded-slash learning-only fix is correct and well-targeted.

Key findings:

  • The narrowing is right. fb136ef made all Cache Components dynamic routes learning-only; 804e16f narrows that to isFullyDynamicRootRoute || encoded-delimiter (%2F/%5C). This matches the upstream v16.2.6 fixture, which is explicitly "intentionally fully dynamic" and a single-segment root route. The blanket rule was over-demoting prefixed routes like /products/[id], which is why the narrowing "preserves static prefetch paths."
  • Verified mechanics: URL.pathname preserves %2F/%5C (both cases; regex matches all four), patternParts is always populated and safely indexed, the delimiter check is correctly gated behind route.isDynamic, and the <Link> rewrite is behavior-preserving with no dead variables.
  • Agreed with prior reviews: the router.prefetch() parity gap is genuinely closed by 7c5c680; the next/form gap is pre-existing/out-of-scope.

Two open non-blocking items for the author: confirm that catch-all-root routes (/[...slug]) staying learning-only is intended (upstream fixture doesn't cover it directly), and decide whether to fold form.tsx into the shared helper now vs. a tracked follow-up.

Verdict: LGTM.

github run

@james-elicx

Copy link
Copy Markdown
Member Author

Final exact-head deploy-suite audit for 804e16fdf5cce928b898fc40a1ae50db0c0cc266:

  • Full deploy suite: https://github.com/cloudflare/vinext/actions/runs/31398197230
  • Report totals: 2619 passed / 185 failed / 631 skipped (nightly baseline 31350589613: 2617 / 187 / 631)
  • Both encoded-slash-param target assertions pass.
  • All five pass-to-fail rows from the previous fix(cache): keep encoded dynamic prefetches learning-only #2866 deploy run 31393161755 are recovered (the interception-route row plus all four vary-params rows).
  • Assertion-level comparison against baseline surfaced two apparent pass-to-fail rows, both cleared as ambient noise:
    • reuses dynamic data within the per-page stale time window: passed in exact same-head targeted execution and passed again on clean tracked main; it has also failed on unrelated run 31382866933.
    • should behave consistently on recursive rewrites: reproduced identically on exact same-head and clean tracked main (Home Page instead of About Page), and also appears on unrelated run 31391938306.
  • Big Bonk LGTM: fix(cache): keep encoded dynamic prefetches learning-only #2866 (comment)
  • All 66 PR checks are green; GitHub reports the PR mergeable and clean.

Audit verdict: no regression attributable to #2866.

@james-elicx
james-elicx marked this pull request as ready for review August 10, 2026 14:48
@james-elicx
james-elicx merged commit 4e95e97 into main Aug 10, 2026
66 checks passed
@james-elicx
james-elicx deleted the codex/fix-segment-cache-encoded-slash-current branch August 10, 2026 17:41
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