Repository navigation
fix(app-router): avoid repeated hover prefetches - #2396
Conversation
commit: |
|
|
/bigbonk review for issues |
Performance benchmarksCompared 2 improved · 0 regressed · 4 within ±1.5%
View detailed results and traces 🟢 improvement · 🔴 regression · ⚫ change below 1.5% · paired base/head |
There was a problem hiding this comment.
Review: fix(app-router): avoid repeated hover prefetches
Reviewed the change against Next.js segment-cache behavior. The core fix is correct and the 30s minimum clamp matches Next.js's getStaleTimeMs (Math.max(staleTimeSeconds, 30) * 1000) exactly. Tests pass locally (vitest run tests/link-navigation.test.ts tests/prefetch-cache.test.ts → 83 passed).
A few observations, none blocking:
1. The 30s clamp is not applied to the fallbackTtlMs path (minor parity gap)
In resolvePrefetchedRscResponseExpiresAt (navigation.ts:359), the Math.max(..., MIN_PREFETCH_STALE_TIME_MS) clamp only runs when the server sent a valid dynamicStaleTimeSeconds. When the header is absent, the fallback path returns timestamp + fallbackTtlMs unclamped:
const seconds = cached.dynamicStaleTimeSeconds;
if (!isDynamicStaleTimeSeconds(seconds)) {
return timestamp + fallbackTtlMs; // not clamped to MIN_PREFETCH_STALE_TIME_MS
}
return timestamp + Math.max(seconds * 1000, MIN_PREFETCH_STALE_TIME_MS);Next.js's getStaleTimeMs clamps all prefetch stale times to a 30s floor, including the configured static stale time. With the default config this is harmless (PREFETCH_CACHE_TTL is 300s when the plugin is active, 30s in tests), so the gap only surfaces if a user sets experimental.staleTimes.static below 30s — then vinext would honor a sub-30s prefetch window where Next.js would floor it. Worth either clamping the fallback too (closer parity) or adding a code comment that the fallback is assumed to already be ≥30s.
2. Committed-navigation snapshots inherit the prefetch expiresAt via the consume path (pre-existing seam, worth confirming intent)
The PR body says it "keeps committed navigation snapshots on dynamic freshness, but restores fetched prefetch entries to prefetch-cache freshness." That split holds for the un-consumed prefetch path, but note the consume→commit path:
consumePrefetchResponse(navigation.ts:888) now always synthesizesexpiresAtonto the returned snapshot, sinceprefetchRscResponsealways setsentry.expiresAt.app-browser-entry.ts:1878captures that asnavResponseExpiresAt, then spreads it onto the committed snapshot atapp-browser-entry.ts:2137.storeVisitedResponseSnapshotpassesDYNAMIC_NAVIGATION_CACHE_TTLas the fallback (lines 2172 / 2183) intending dynamic freshness, butresolveCachedRscResponseExpiresAtshort-circuits on the explicitexpiresAt, so the committed snapshot inherits the prefetch-cache window (now clamped/static) rather than the dynamic window.
This propagation predates the PR (introduced in #2251), so it's not a regression this PR creates — but this PR changes the value that flows through it (full/auto prefetches now resolve to the static prefetch window + 30s floor instead of the dynamic window). With staleTimes.dynamic = 0, a navigation that consumes a prefetch will commit a snapshot that stays fresh for ≥30s instead of being immediately revalidatable, which is arguably contrary to the "committed snapshots on dynamic freshness" framing. If that's intended (matching Next.js, where consumed prefetch data carries its prefetch stale time into the committed tree), a one-line comment at app-browser-entry.ts:2137 would prevent a future reader from "fixing" it. If not intended, the commit path should re-resolve expiry against DYNAMIC_NAVIGATION_CACHE_TTL instead of inheriting the prefetch value.
3. Test coverage gap for the consume→commit path
The two new tests cover (a) the visible-link ping not re-prefetching and (b) the 30s clamp in prefetchRscResponse. Neither exercises the consume→commit handoff described in #2 — i.e., that a navigation which consumes a dynamic=0 prefetch commits a visited-response entry with the expected expiry. Adding a test there would lock in whichever behavior is intended and guard the seam.
Nits
- The renamed test "honors an explicit fallback stale window for prefetched responses" (
prefetch-cache.test.ts:468) still passesDYNAMIC_NAVIGATION_CACHE_TTLas the explicit fallback. That's fine as a generic "explicit fallback is honored" assertion, but the name now reads as if it no longer ties to the auto-prefetch behavior it originally covered. Consider asserting the auto/full-mode resolution explicitly so the auto-prefetch fallback choice stays covered after thelink.tsxconditional was removed.
Overall the change is correct and well-scoped for the stated regression. The main thing to resolve before merge is confirming the intent in #2 (commit-path expiry inheritance) and ideally adding the test in #3.
|
Review posted to PR #2396. Summary of findings: The fix is correct and well-scoped. The 30s minimum clamp matches Next.js's Three non-blocking observations:
Plus a nit on the renamed test name no longer reflecting the auto-prefetch behavior it originally guarded. |
|
/bigbonk review for issues |
There was a problem hiding this comment.
Re-review after 4ecfcd5 (floor prefetch fallback stale time)
Re-reviewed the head of the branch against Next.js segment-cache (packages/next/src/client/components/segment-cache/cache.ts). All three observations from the earlier review have been resolved by 4ecfcd5, and the fix now matches Next.js behavior. No blocking issues.
Prior observations — resolved
-
Fallback path now floored.
resolvePrefetchedRscResponseExpiresAt(navigation.ts:368-369) now clamps the no-header fallback toMIN_PREFETCH_STALE_TIME_MS(timestamp + Math.max(fallbackTtlMs, MIN_PREFETCH_STALE_TIME_MS)). This closes the sub-30sstaleTimes.staticparity gap and matchesgetStaleTimeMs(Math.max(staleTimeSeconds, 30) * 1000). Note:max(s, 30)*1000 === max(s*1000, 30000), so theseconds-path form is equivalent to Next.js. New testfloors explicit fallback stale windows for prefetched responseslocks this in. -
Commit-path inheritance documented. The comment at
app-browser-entry.ts:2137now states the intent explicitly: a navigation that consumes prefetched Flight data keeps the prefetch freshness window on commit, matching Next's segment-cache handoff. This confirms the seam is intentional rather than an accidental divergence from the "committed snapshots on dynamic freshness" framing. -
Consume→commit coverage added.
tests/app-visited-response-cache.test.tsnow hasinherits the expiry carried by a consumed prefetch snapshot, and thedynamic=0test inprefetch-cache.test.tsasserts the consumed snapshot carriesexpiresAt === now + 30_000. The seam is now guarded.
Verified independently
dynamicStaleTimeSeconds = 0(valid perisDynamicStaleTimeSeconds,>= 0) correctly hitsMath.max(0, 30000)→ 30s floor, matching Next's "too-short server stale time" clamp. Covered.- The
link.tsxchange (both auto and full prefetches now usePREFETCH_CACHE_TTLas the fallback window) matches Next.js: segment-cache computesstaleAtfrom the samegetStaleTimeMs/STATIC_STALETIME_MSpath regardless ofPrefetchKind. The priorDYNAMIC_NAVIGATION_CACHE_TTLfallback for auto prefetches was the actual divergence this PR removes. The auto-vs-full distinction is about what is cached (shell vs full tree), not the stale window. tests/prefetch-cache.test.ts,tests/link-navigation.test.ts,tests/app-visited-response-cache.test.ts: 91 passed.- Typecheck clean on the three changed source files.
Nit (non-blocking)
- The Next.js no-header default is
STATIC_STALETIME_MS(getStaleAt/getStaleAtFromHeaderincache.ts:3408,3451), not a 30s floor — Next only applies the 30s floor when a stale time value (header or config) is present. vinext's fallback now always floors to 30s. In practicePREFETCH_CACHE_TTLis the configured static stale time and is ≥30s by default, so this is effectively equivalent, but it's a slightly different shape from Next's "default = static, floor only the configured value." Worth a one-line note if you want exact parity, but harmless under default config.
LGTM.
|
Review posted to PR #2396. SummaryI re-reviewed PR #2396 at its current head ( All three prior observations were resolved by
Verified independently:
One non-blocking nit: Next's no-header default is |
Summary
Fixes the hover/visible-link RSC prefetch regression introduced after client cache reuse landed.
The bug was that Link-triggered full prefetches were using the dynamic navigation stale window as their fallback freshness. With Next.js defaults (
staleTimes.dynamic = 0), a just-prefetched payload could be treated as stale by the visible-link ping/dedupe path, so app-router commits or hover/focus churn could issue extra RSC prefetch requests instead of reusing the existing prefetch entry.This keeps committed navigation snapshots on dynamic freshness, but restores fetched prefetch entries to prefetch-cache freshness:
getStaleTimeMs).dynamic=0and for the minimum prefetch stale-time clamp.Validation
vp test run tests/link-navigation.test.ts tests/prefetch-cache.test.tsvp check packages/vinext/src/shims/link.tsx packages/vinext/src/shims/navigation.ts tests/link-navigation.test.ts tests/prefetch-cache.test.ts