mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
610cf2e63a
* 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>
94 lines
2.7 KiB
TypeScript
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);
|
|
});
|
|
});
|