Files
cloudflare__vinext/tests/app-visited-response-cache.test.ts
T
Nathan Nguyen 610cf2e63a fix(app-router): honor per-response dynamic stale times on the client (#1712)
* 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>
2026-06-04 16:01:43 +01:00

94 lines
2.7 KiB
TypeScript

import { describe, expect, it } from "vite-plus/test";
import {
MAX_TRAVERSAL_CACHE_TTL,
VISITED_RESPONSE_CACHE_TTL,
createVisitedResponseCacheEntry,
isVisitedResponseCacheEntryFresh,
} from "../packages/vinext/src/server/app-visited-response-cache.js";
import type { CachedRscResponse } from "../packages/vinext/src/shims/navigation.js";
function createCachedResponse(overrides: Partial<CachedRscResponse> = {}): CachedRscResponse {
return {
buffer: new TextEncoder().encode("flight").buffer,
contentType: "text/x-component",
paramsHeader: null,
url: "/dynamic.rsc",
...overrides,
};
}
describe("visited response cache freshness", () => {
it("uses per-response dynamic stale time for regular navigations", () => {
// Ported from Next.js: test/e2e/app-dir/segment-cache/staleness/segment-cache-per-page-dynamic-stale-time.test.ts
const now = 1_000_000;
const entry = createVisitedResponseCacheEntry({
now,
params: {},
response: createCachedResponse({ dynamicStaleTimeSeconds: 10 }),
});
expect(entry.expiresAt).toBe(now + 10_000);
expect(
isVisitedResponseCacheEntryFresh(entry, {
navigationKind: "navigate",
now: now + 9_999,
}),
).toBe(true);
expect(
isVisitedResponseCacheEntryFresh(entry, {
navigationKind: "navigate",
now: now + 10_000,
}),
).toBe(false);
});
it("falls back to the default visited response TTL without server metadata", () => {
const now = 1_000_000;
const entry = createVisitedResponseCacheEntry({
now,
params: {},
response: createCachedResponse(),
});
expect(entry.expiresAt).toBe(now + VISITED_RESPONSE_CACHE_TTL);
});
it("keeps traversal restores independent from dynamic stale expiry", () => {
const now = 1_000_000;
const entry = createVisitedResponseCacheEntry({
now,
params: {},
response: createCachedResponse({ dynamicStaleTimeSeconds: 10 }),
});
expect(
isVisitedResponseCacheEntryFresh(entry, {
navigationKind: "traverse",
now: now + 20_000,
}),
).toBe(true);
expect(
isVisitedResponseCacheEntryFresh(entry, {
navigationKind: "traverse",
now: now + MAX_TRAVERSAL_CACHE_TTL,
}),
).toBe(false);
});
it("never reuses visited responses for refresh navigations", () => {
const now = 1_000_000;
const entry = createVisitedResponseCacheEntry({
now,
params: {},
response: createCachedResponse({ dynamicStaleTimeSeconds: 60 }),
});
expect(
isVisitedResponseCacheEntryFresh(entry, {
navigationKind: "refresh",
now,
}),
).toBe(false);
});
});