-
Notifications
You must be signed in to change notification settings - Fork 218
perf: TTL-based eviction sweep for prefetch cache #434
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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,28 @@ export function getPrefetchedUrls(): Set<string> { | |||||||||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||||||||||
| 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(); | ||||||||||||||||||||||||||||||||||||||||||||||||
| const prefetched = getPrefetchedUrls(); | ||||||||||||||||||||||||||||||||||||||||||||||||
| for (const [key, entry] of cache) { | ||||||||||||||||||||||||||||||||||||||||||||||||
| if (now - entry.timestamp >= PREFETCH_CACHE_TTL) { | ||||||||||||||||||||||||||||||||||||||||||||||||
| cache.delete(key); | ||||||||||||||||||||||||||||||||||||||||||||||||
| prefetched.delete(key); | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+224
to
+234
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Since
Suggested change
Then reuse |
||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| // 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); | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
Divkix marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| cache.set(rscUrl, { response, timestamp: Date.now() }); | ||||||||||||||||||||||||||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
This requires moving |
||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,146 @@ | ||
| /** | ||
| * 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 getPrefetchedUrls: Navigation["getPrefetchedUrls"]; | ||
| 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; | ||
| getPrefetchedUrls = nav.getPrefetchedUrls; | ||
| 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(); | ||
| const prefetched = getPrefetchedUrls(); | ||
| for (let i = 0; i < count; i++) { | ||
| const key = `${keyPrefix}${i}.rsc`; | ||
| cache.set(key, { response: new Response(`body-${i}`), timestamp }); | ||
| prefetched.add(key); | ||
| } | ||
| } | ||
|
|
||
| describe("prefetch cache eviction", () => { | ||
| it("sweeps all expired entries before FIFO", () => { | ||
| const now = Date.now(); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. All four tests use More robust: use a fixed arbitrary value throughout: const now = 1_000_000;
const expired = now - PREFETCH_CACHE_TTL - 1_000;This eliminates any dependency on wall-clock time and makes the tests fully deterministic. |
||
| const expired = now - PREFETCH_CACHE_TTL - 1_000; // 31s ago | ||
|
|
||
| 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")); | ||
|
|
||
| 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); | ||
| }); | ||
Divkix marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| 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); | ||
| expect(getPrefetchedUrls().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); | ||
| // 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; | ||
|
|
||
| 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(); | ||
| // 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 < half; i++) { | ||
| expect(cache.has(`/expired-${i}.rsc`)).toBe(false); | ||
| } | ||
| // All fresh entries should survive | ||
| 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; | ||
|
|
||
| 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(); | ||
| // 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); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This assertion is correct but potentially confusing to a reader: the new entry ( |
||
| }); | ||
| }); | ||
Uh oh!
There was an error while loading. Please reload this page.