* fix(app-router): honor per-response dynamic stale times on the client
App Router navigation reused and evicted cached RSC responses using one global prefetch TTL, so pages with different unstable_dynamicStaleTime values could not stay fresh independently. Traversal also needed a route instance boundary so browser history restores hidden state instead of reusing a forward-navigation component instance.
The client cache now snapshots dynamic stale-time response metadata, computes expiry per entry, preserves staleAt across cache replays, and derives mounted-slot cache variants from the decoded payload. Browser traversal keeps route instances under React Activity while normal navigations allocate fresh route instances, matching Next's BFCache/stale-time contract.
* fix(link): make prefetch dedupe freshness-aware for navigation prefetches
The old prefetched.has(cacheKey) gate in prefetchUrl returned early for
any exact key match, including stale entries. This meant an expired
prefetch with the same cache key would never reach
hasPrefetchCacheEntryForNavigation, so it would never be deleted and
never refreshed.
The new helper is built to check resolvePrefetchCacheEntryExpiresAt,
delete stale entries, and return false. But the Link path bypassed it
when the old prefetched Set contained the exact key.
Fix: gate the exact-key check on the helper's freshness decision. When
the helper reports the exact entry is stale and deletes it, also remove
the key from the prefetched Set so the fall-through path can issue a
fresh fetch. Keep the second helper call for equivalent _rsc variants.
Add an integration test proving visible Links re-prefetch when their
exact cache entry goes stale, not just that the helper reports stale
entries as unavailable.
* refactor(app-router): collapse redundant prefetch/visited-cache gates
Address review feedback on the client dynamic-stale-time changes:
- link.tsx: the prefetch dedupe path called
hasPrefetchCacheEntryForNavigation twice when an exact entry already
existed. Collapse to a single freshness-aware gate that covers both the
exact entry and equivalent _rsc variants.
- app-browser-entry.ts: drop the redundant early refresh return in
getVisitedResponse; isVisitedResponseCacheEntryFresh already bypasses
the cache for refresh navigations (and the miss path evicts the entry).
* docs(app-router): document intentional refresh eviction in visited cache
Per review feedback: getVisitedResponse now routes refresh navigations
through isVisitedResponseCacheEntryFresh (the single source of truth),
which returns false for refresh and therefore falls through to the read
eviction. Document at the fall-through delete that a refresh intentionally
drops any prior snapshot, since the navigation re-fetches and re-stores a
fresh one.
* docs(navigation): clarify load-bearing prefetch-cache dual checks
Per review feedback, document two checks that look superficially redundant
but preserve observable contracts:
- isPrefetchCacheEntryCompatibleWithMountedSlots: the two clauses accept
either the entry's pinned request-time slot context or the server-declared
snapshot header; they diverge in the prefetchRscResponse case.
- consumePrefetchResponse expiresAt guard: only synthesize expiresAt onto the
returned snapshot when the entry already carried one, so entries without an
explicit expiry round-trip unchanged for callers/tests.
---------
Co-authored-by: James <james@eli.cx>