From 2d4bf89eacdef7eaf0179514d8dcc36a1eedcdf9 Mon Sep 17 00:00:00 2001 From: Divanshu Chauhan Date: Tue, 10 Mar 2026 19:20:31 -0700 Subject: [PATCH 1/3] perf: add TTL-based eviction sweep for prefetch cache Before this change, expired prefetch cache entries were only cleaned on individual access or displaced by FIFO when at the 50-entry cap. On link-heavy pages, expired entries wasted slots and caused premature eviction of still-valid entries. Now storePrefetchResponse() sweeps all expired entries before falling back to FIFO eviction, so fresh entries survive longer under pressure. --- packages/vinext/src/shims/navigation.ts | 16 ++- tests/prefetch-cache.test.ts | 130 ++++++++++++++++++++++++ 2 files changed, 144 insertions(+), 2 deletions(-) create mode 100644 tests/prefetch-cache.test.ts diff --git a/packages/vinext/src/shims/navigation.ts b/packages/vinext/src/shims/navigation.ts index 8840d75a..667093a5 100644 --- a/packages/vinext/src/shims/navigation.ts +++ b/packages/vinext/src/shims/navigation.ts @@ -166,7 +166,7 @@ function withBasePath(p: string): string { // --------------------------------------------------------------------------- /** Maximum number of entries in the RSC prefetch cache. */ -const MAX_PREFETCH_CACHE_SIZE = 50; +export const MAX_PREFETCH_CACHE_SIZE = 50; /** TTL for prefetch cache entries in ms (matches Next.js static prefetch TTL). */ export const PREFETCH_CACHE_TTL = 30_000; @@ -220,11 +220,23 @@ export function getPrefetchedUrls(): Set { */ export function storePrefetchResponse(rscUrl: string, response: Response): void { const cache = getPrefetchCache(); - // Evict oldest entry if at capacity (Map iterates in insertion order) + + // Sweep expired entries before resorting to FIFO eviction + if (cache.size >= MAX_PREFETCH_CACHE_SIZE) { + const now = Date.now(); + for (const [key, entry] of cache) { + if (now - entry.timestamp >= PREFETCH_CACHE_TTL) { + cache.delete(key); + } + } + } + + // FIFO fallback if still at capacity after sweep if (cache.size >= MAX_PREFETCH_CACHE_SIZE) { const oldest = cache.keys().next().value; if (oldest !== undefined) cache.delete(oldest); } + cache.set(rscUrl, { response, timestamp: Date.now() }); } diff --git a/tests/prefetch-cache.test.ts b/tests/prefetch-cache.test.ts new file mode 100644 index 00000000..a808e6b6 --- /dev/null +++ b/tests/prefetch-cache.test.ts @@ -0,0 +1,130 @@ +/** + * Prefetch cache eviction tests. + * + * Verifies that storePrefetchResponse() sweeps expired entries before + * falling back to FIFO eviction, preventing expired entries from wasting + * cache slots on link-heavy pages. + * + * The navigation module computes `isServer = typeof window === "undefined"` + * at load time, so we must set globalThis.window BEFORE importing it via + * vi.resetModules() + dynamic import(). + */ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; + +type Navigation = typeof import("../packages/vinext/src/shims/navigation.js"); +let storePrefetchResponse: Navigation["storePrefetchResponse"]; +let getPrefetchCache: Navigation["getPrefetchCache"]; +let MAX_PREFETCH_CACHE_SIZE: Navigation["MAX_PREFETCH_CACHE_SIZE"]; +let PREFETCH_CACHE_TTL: Navigation["PREFETCH_CACHE_TTL"]; + +beforeEach(async () => { + // Set window BEFORE importing so isServer evaluates to false + (globalThis as any).window = { + __VINEXT_RSC_PREFETCH_CACHE__: new Map(), + __VINEXT_RSC_PREFETCHED_URLS__: new Set(), + location: { pathname: "/", search: "", hash: "", href: "http://localhost/" }, + addEventListener: () => {}, + history: { pushState: () => {}, replaceState: () => {}, state: null }, + dispatchEvent: () => {}, + }; + vi.resetModules(); + const nav = await import("../packages/vinext/src/shims/navigation.js"); + storePrefetchResponse = nav.storePrefetchResponse; + getPrefetchCache = nav.getPrefetchCache; + MAX_PREFETCH_CACHE_SIZE = nav.MAX_PREFETCH_CACHE_SIZE; + PREFETCH_CACHE_TTL = nav.PREFETCH_CACHE_TTL; +}); + +afterEach(() => { + vi.restoreAllMocks(); + delete (globalThis as any).window; +}); + +/** Helper: fill cache with `count` entries at a given timestamp. */ +function fillCache(count: number, timestamp: number, keyPrefix = "/page-"): void { + const cache = getPrefetchCache(); + for (let i = 0; i < count; i++) { + cache.set(`${keyPrefix}${i}.rsc`, { + response: new Response(`body-${i}`), + timestamp, + }); + } +} + +describe("prefetch cache eviction", () => { + it("sweeps all expired entries before FIFO", () => { + const now = Date.now(); + const expired = now - PREFETCH_CACHE_TTL - 1_000; // 31s ago + + fillCache(MAX_PREFETCH_CACHE_SIZE, expired); + expect(getPrefetchCache().size).toBe(MAX_PREFETCH_CACHE_SIZE); + + vi.spyOn(Date, "now").mockReturnValue(now); + storePrefetchResponse("/new.rsc", new Response("new")); + + const cache = getPrefetchCache(); + expect(cache.size).toBe(1); + expect(cache.has("/new.rsc")).toBe(true); + }); + + it("falls back to FIFO when all entries are fresh", () => { + const now = Date.now(); + + fillCache(MAX_PREFETCH_CACHE_SIZE, now); + expect(getPrefetchCache().size).toBe(MAX_PREFETCH_CACHE_SIZE); + + vi.spyOn(Date, "now").mockReturnValue(now); + storePrefetchResponse("/new.rsc", new Response("new")); + + const cache = getPrefetchCache(); + // FIFO evicted one, new one added → still at capacity + expect(cache.size).toBe(MAX_PREFETCH_CACHE_SIZE); + expect(cache.has("/new.rsc")).toBe(true); + // First inserted entry should be evicted + expect(cache.has("/page-0.rsc")).toBe(false); + // Second entry should survive + expect(cache.has("/page-1.rsc")).toBe(true); + }); + + it("sweeps only expired entries when cache has a mix", () => { + const now = Date.now(); + const expired = now - PREFETCH_CACHE_TTL - 1_000; + + // 25 expired + 25 fresh = at capacity + fillCache(25, expired, "/expired-"); + fillCache(25, now, "/fresh-"); + expect(getPrefetchCache().size).toBe(MAX_PREFETCH_CACHE_SIZE); + + vi.spyOn(Date, "now").mockReturnValue(now); + storePrefetchResponse("/new.rsc", new Response("new")); + + const cache = getPrefetchCache(); + // 25 expired swept, 25 fresh kept, 1 new added + expect(cache.size).toBe(26); + expect(cache.has("/new.rsc")).toBe(true); + + // All expired entries should be gone + for (let i = 0; i < 25; i++) { + expect(cache.has(`/expired-${i}.rsc`)).toBe(false); + } + // All fresh entries should survive + for (let i = 0; i < 25; i++) { + expect(cache.has(`/fresh-${i}.rsc`)).toBe(true); + } + }); + + it("does not sweep when cache is below capacity", () => { + const now = Date.now(); + const expired = now - PREFETCH_CACHE_TTL - 1_000; + + // Below capacity — even expired entries should not be swept + fillCache(10, expired); + + vi.spyOn(Date, "now").mockReturnValue(now); + storePrefetchResponse("/new.rsc", new Response("new")); + + const cache = getPrefetchCache(); + // 10 expired still there + 1 new = 11 + expect(cache.size).toBe(11); + }); +}); From 968f13319519cd945ee775cc7724d5570d04a040 Mon Sep 17 00:00:00 2001 From: Divanshu Chauhan Date: Tue, 10 Mar 2026 22:11:56 -0700 Subject: [PATCH 2/3] fix: sync prefetched URL set on cache eviction Evicted entries were not removed from getPrefetchedUrls(), causing Link's prefetched.has() check to return true for stale URLs and skip re-prefetch. Clean up the prefetched set in both sweep and FIFO paths. Also derive test counts from MAX_PREFETCH_CACHE_SIZE constant and assert prefetched URL set consistency after eviction. --- packages/vinext/src/shims/navigation.ts | 7 +++- tests/prefetch-cache.test.ts | 46 +++++++++++++++++-------- 2 files changed, 37 insertions(+), 16 deletions(-) diff --git a/packages/vinext/src/shims/navigation.ts b/packages/vinext/src/shims/navigation.ts index 667093a5..d92459cd 100644 --- a/packages/vinext/src/shims/navigation.ts +++ b/packages/vinext/src/shims/navigation.ts @@ -224,9 +224,11 @@ export function storePrefetchResponse(rscUrl: string, response: Response): void // Sweep expired entries before resorting to FIFO eviction if (cache.size >= MAX_PREFETCH_CACHE_SIZE) { const now = Date.now(); + const prefetched = getPrefetchedUrls(); for (const [key, entry] of cache) { if (now - entry.timestamp >= PREFETCH_CACHE_TTL) { cache.delete(key); + prefetched.delete(key); } } } @@ -234,7 +236,10 @@ export function storePrefetchResponse(rscUrl: string, response: Response): void // FIFO fallback if still at capacity after sweep if (cache.size >= MAX_PREFETCH_CACHE_SIZE) { const oldest = cache.keys().next().value; - if (oldest !== undefined) cache.delete(oldest); + if (oldest !== undefined) { + cache.delete(oldest); + getPrefetchedUrls().delete(oldest); + } } cache.set(rscUrl, { response, timestamp: Date.now() }); diff --git a/tests/prefetch-cache.test.ts b/tests/prefetch-cache.test.ts index a808e6b6..78afff75 100644 --- a/tests/prefetch-cache.test.ts +++ b/tests/prefetch-cache.test.ts @@ -14,6 +14,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; type Navigation = typeof import("../packages/vinext/src/shims/navigation.js"); let storePrefetchResponse: Navigation["storePrefetchResponse"]; let getPrefetchCache: Navigation["getPrefetchCache"]; +let getPrefetchedUrls: Navigation["getPrefetchedUrls"]; let MAX_PREFETCH_CACHE_SIZE: Navigation["MAX_PREFETCH_CACHE_SIZE"]; let PREFETCH_CACHE_TTL: Navigation["PREFETCH_CACHE_TTL"]; @@ -31,6 +32,7 @@ beforeEach(async () => { const nav = await import("../packages/vinext/src/shims/navigation.js"); storePrefetchResponse = nav.storePrefetchResponse; getPrefetchCache = nav.getPrefetchCache; + getPrefetchedUrls = nav.getPrefetchedUrls; MAX_PREFETCH_CACHE_SIZE = nav.MAX_PREFETCH_CACHE_SIZE; PREFETCH_CACHE_TTL = nav.PREFETCH_CACHE_TTL; }); @@ -43,11 +45,11 @@ afterEach(() => { /** Helper: fill cache with `count` entries at a given timestamp. */ function fillCache(count: number, timestamp: number, keyPrefix = "/page-"): void { const cache = getPrefetchCache(); + const prefetched = getPrefetchedUrls(); for (let i = 0; i < count; i++) { - cache.set(`${keyPrefix}${i}.rsc`, { - response: new Response(`body-${i}`), - timestamp, - }); + const key = `${keyPrefix}${i}.rsc`; + cache.set(key, { response: new Response(`body-${i}`), timestamp }); + prefetched.add(key); } } @@ -58,6 +60,7 @@ describe("prefetch cache eviction", () => { fillCache(MAX_PREFETCH_CACHE_SIZE, expired); expect(getPrefetchCache().size).toBe(MAX_PREFETCH_CACHE_SIZE); + expect(getPrefetchedUrls().size).toBe(MAX_PREFETCH_CACHE_SIZE); vi.spyOn(Date, "now").mockReturnValue(now); storePrefetchResponse("/new.rsc", new Response("new")); @@ -65,6 +68,8 @@ describe("prefetch cache eviction", () => { const cache = getPrefetchCache(); expect(cache.size).toBe(1); expect(cache.has("/new.rsc")).toBe(true); + // All evicted entries should be removed from prefetched URL set + expect(getPrefetchedUrls().size).toBe(0); }); it("falls back to FIFO when all entries are fresh", () => { @@ -72,6 +77,7 @@ describe("prefetch cache eviction", () => { fillCache(MAX_PREFETCH_CACHE_SIZE, now); expect(getPrefetchCache().size).toBe(MAX_PREFETCH_CACHE_SIZE); + expect(getPrefetchedUrls().size).toBe(MAX_PREFETCH_CACHE_SIZE); vi.spyOn(Date, "now").mockReturnValue(now); storePrefetchResponse("/new.rsc", new Response("new")); @@ -84,47 +90,57 @@ describe("prefetch cache eviction", () => { expect(cache.has("/page-0.rsc")).toBe(false); // Second entry should survive expect(cache.has("/page-1.rsc")).toBe(true); + // FIFO-evicted entry should be removed from prefetched URL set + expect(getPrefetchedUrls().size).toBe(MAX_PREFETCH_CACHE_SIZE - 1); + expect(getPrefetchedUrls().has("/page-0.rsc")).toBe(false); }); it("sweeps only expired entries when cache has a mix", () => { const now = Date.now(); const expired = now - PREFETCH_CACHE_TTL - 1_000; - // 25 expired + 25 fresh = at capacity - fillCache(25, expired, "/expired-"); - fillCache(25, now, "/fresh-"); + const half = Math.floor(MAX_PREFETCH_CACHE_SIZE / 2); + const rest = MAX_PREFETCH_CACHE_SIZE - half; + + fillCache(half, expired, "/expired-"); + fillCache(rest, now, "/fresh-"); expect(getPrefetchCache().size).toBe(MAX_PREFETCH_CACHE_SIZE); + expect(getPrefetchedUrls().size).toBe(MAX_PREFETCH_CACHE_SIZE); vi.spyOn(Date, "now").mockReturnValue(now); storePrefetchResponse("/new.rsc", new Response("new")); const cache = getPrefetchCache(); - // 25 expired swept, 25 fresh kept, 1 new added - expect(cache.size).toBe(26); + // expired swept, fresh kept, 1 new added + expect(cache.size).toBe(rest + 1); expect(cache.has("/new.rsc")).toBe(true); // All expired entries should be gone - for (let i = 0; i < 25; i++) { + for (let i = 0; i < half; i++) { expect(cache.has(`/expired-${i}.rsc`)).toBe(false); } // All fresh entries should survive - for (let i = 0; i < 25; i++) { + for (let i = 0; i < rest; i++) { expect(cache.has(`/fresh-${i}.rsc`)).toBe(true); } + // Only fresh entries remain in prefetched URL set + expect(getPrefetchedUrls().size).toBe(rest); }); it("does not sweep when cache is below capacity", () => { const now = Date.now(); const expired = now - PREFETCH_CACHE_TTL - 1_000; - // Below capacity — even expired entries should not be swept - fillCache(10, expired); + const belowCapacity = MAX_PREFETCH_CACHE_SIZE - 1; + fillCache(belowCapacity, expired); vi.spyOn(Date, "now").mockReturnValue(now); storePrefetchResponse("/new.rsc", new Response("new")); const cache = getPrefetchCache(); - // 10 expired still there + 1 new = 11 - expect(cache.size).toBe(11); + // Below capacity — no eviction, all entries kept + 1 new + expect(cache.size).toBe(belowCapacity + 1); + // Prefetched URL set unchanged (no eviction triggered) + expect(getPrefetchedUrls().size).toBe(belowCapacity); }); }); From 59b4064d2a606b2a8fbc47fefa34932e4bb5e73e Mon Sep 17 00:00:00 2001 From: James Date: Wed, 11 Mar 2026 12:35:24 +0000 Subject: [PATCH 3/3] fix: address review comments on prefetch cache TTL sweep - Hoist Date.now() to top of storePrefetchResponse so sweep and new entry timestamp use the same instant - Export MAX_PREFETCH_CACHE_SIZE in next-shims.d.ts to match runtime export - Use fixed arbitrary time values in tests instead of real wall clock - Clarify why prefetchedUrls count excludes the new entry in the below-capacity test --- packages/vinext/src/shims/navigation.ts | 4 ++-- packages/vinext/src/shims/next-shims.d.ts | 1 + tests/prefetch-cache.test.ts | 19 +++++++++++++------ 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/packages/vinext/src/shims/navigation.ts b/packages/vinext/src/shims/navigation.ts index d92459cd..412dd7ed 100644 --- a/packages/vinext/src/shims/navigation.ts +++ b/packages/vinext/src/shims/navigation.ts @@ -220,10 +220,10 @@ export function getPrefetchedUrls(): Set { */ export function storePrefetchResponse(rscUrl: string, response: Response): void { const cache = getPrefetchCache(); + const now = Date.now(); // Sweep expired entries before resorting to FIFO eviction if (cache.size >= MAX_PREFETCH_CACHE_SIZE) { - const now = Date.now(); const prefetched = getPrefetchedUrls(); for (const [key, entry] of cache) { if (now - entry.timestamp >= PREFETCH_CACHE_TTL) { @@ -242,7 +242,7 @@ export function storePrefetchResponse(rscUrl: string, response: Response): void } } - cache.set(rscUrl, { response, timestamp: Date.now() }); + cache.set(rscUrl, { response, timestamp: now }); } // Client navigation listeners diff --git a/packages/vinext/src/shims/next-shims.d.ts b/packages/vinext/src/shims/next-shims.d.ts index 3f4c092a..ec851467 100644 --- a/packages/vinext/src/shims/next-shims.d.ts +++ b/packages/vinext/src/shims/next-shims.d.ts @@ -115,6 +115,7 @@ declare module "next/navigation" { response: Response; timestamp: number; } + export const MAX_PREFETCH_CACHE_SIZE: number; export const PREFETCH_CACHE_TTL: number; export function toRscUrl(href: string): string; export function getPrefetchCache(): Map; diff --git a/tests/prefetch-cache.test.ts b/tests/prefetch-cache.test.ts index 78afff75..075ec4f5 100644 --- a/tests/prefetch-cache.test.ts +++ b/tests/prefetch-cache.test.ts @@ -55,8 +55,9 @@ function fillCache(count: number, timestamp: number, keyPrefix = "/page-"): void describe("prefetch cache eviction", () => { it("sweeps all expired entries before FIFO", () => { - const now = Date.now(); - const expired = now - PREFETCH_CACHE_TTL - 1_000; // 31s ago + // Use fixed arbitrary values to avoid any dependency on the real wall clock + const now = 1_000_000; + const expired = now - PREFETCH_CACHE_TTL - 1_000; // 31s before `now` fillCache(MAX_PREFETCH_CACHE_SIZE, expired); expect(getPrefetchCache().size).toBe(MAX_PREFETCH_CACHE_SIZE); @@ -73,7 +74,8 @@ describe("prefetch cache eviction", () => { }); it("falls back to FIFO when all entries are fresh", () => { - const now = Date.now(); + // Use fixed arbitrary values to avoid any dependency on the real wall clock + const now = 1_000_000; fillCache(MAX_PREFETCH_CACHE_SIZE, now); expect(getPrefetchCache().size).toBe(MAX_PREFETCH_CACHE_SIZE); @@ -96,7 +98,8 @@ describe("prefetch cache eviction", () => { }); it("sweeps only expired entries when cache has a mix", () => { - const now = Date.now(); + // Use fixed arbitrary values to avoid any dependency on the real wall clock + const now = 1_000_000; const expired = now - PREFETCH_CACHE_TTL - 1_000; const half = Math.floor(MAX_PREFETCH_CACHE_SIZE / 2); @@ -128,7 +131,8 @@ describe("prefetch cache eviction", () => { }); it("does not sweep when cache is below capacity", () => { - const now = Date.now(); + // Use fixed arbitrary values to avoid any dependency on the real wall clock + const now = 1_000_000; const expired = now - PREFETCH_CACHE_TTL - 1_000; const belowCapacity = MAX_PREFETCH_CACHE_SIZE - 1; @@ -140,7 +144,10 @@ describe("prefetch cache eviction", () => { const cache = getPrefetchCache(); // Below capacity — no eviction, all entries kept + 1 new expect(cache.size).toBe(belowCapacity + 1); - // Prefetched URL set unchanged (no eviction triggered) + // storePrefetchResponse only manages the prefetch cache — the caller + // (router.prefetch()) is responsible for adding to prefetchedUrls. So + // the new entry (/new.rsc) is NOT in prefetchedUrls here, and the count + // stays at belowCapacity (no evictions triggered). expect(getPrefetchedUrls().size).toBe(belowCapacity); }); });