mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
b5619c19f9
* refactor(navigation): route prefetch reuse through planner Navigation cache reuse decisions were still split across the browser executor, which made visited-response reuse, prefetch consumption, and optimistic shell attempts hard to verify at the control-plane boundary. Move the reuse decision table into navigationPlanner with typed candidate facts, keep cache reads and React effects in the executor, and preserve silent stale-prefetch eviction for navigation probes. * refactor(navigation): exercise visited cache miss planning Visited response cache misses were represented in the planner API, but the browser executor still returned before invoking that classifier when the entry was absent or intentionally bypassed. That made the missing-candidate branch planner-owned in tests but executor-local in production. The executor now builds candidate facts for both present and missing states, asks the planner for the decision, and keeps cache mutation in the executor as the effect boundary. * fix(navigation): avoid prefetch probes on visited cache hits Visited-response reuse should stay on the soft-navigation fast path without touching the prefetch cache. The planner extraction accidentally computed prefetch availability before deciding to reuse a visited response, so stale prefetch entries could be silently evicted on a path that previously left them untouched. Add a planner prefetch-probe decision and have the browser executor consult it before reading the prefetch cache. This keeps cache mutation in the executor while making the no-probe condition explicit and tested. * fix(navigation): preserve prefetch fallback behavior * fix(navigation): keep slot mismatch probe behavior --------- Co-authored-by: James <james@eli.cx>
779 lines
29 KiB
TypeScript
779 lines
29 KiB
TypeScript
/**
|
|
* 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 "vite-plus/test";
|
|
import { AppElementsWire } from "../packages/vinext/src/server/app-elements.js";
|
|
import { VINEXT_RSC_COMPATIBILITY_ID_HEADER } from "../packages/vinext/src/server/app-rsc-cache-busting.js";
|
|
import {
|
|
VINEXT_DYNAMIC_STALE_TIME_HEADER,
|
|
VINEXT_MOUNTED_SLOTS_HEADER,
|
|
} from "../packages/vinext/src/server/headers.js";
|
|
|
|
type Navigation = typeof import("../packages/vinext/src/shims/navigation.js");
|
|
let storePrefetchResponse: Navigation["storePrefetchResponse"];
|
|
let consumePrefetchResponse: Navigation["consumePrefetchResponse"];
|
|
let getPrefetchCache: Navigation["getPrefetchCache"];
|
|
let getPrefetchedUrls: Navigation["getPrefetchedUrls"];
|
|
let getCurrentInterceptionContext: Navigation["getCurrentInterceptionContext"];
|
|
let MAX_PREFETCH_CACHE_SIZE: Navigation["MAX_PREFETCH_CACHE_SIZE"];
|
|
let PREFETCH_CACHE_TTL: Navigation["PREFETCH_CACHE_TTL"];
|
|
let snapshotRscResponse: Navigation["snapshotRscResponse"];
|
|
let restoreRscResponse: Navigation["restoreRscResponse"];
|
|
let prefetchRscResponse: Navigation["prefetchRscResponse"];
|
|
let invalidatePrefetchCache: Navigation["invalidatePrefetchCache"];
|
|
let hasPrefetchCacheEntryForNavigation: Navigation["hasPrefetchCacheEntryForNavigation"];
|
|
let appRouterInstance: Navigation["appRouterInstance"];
|
|
let consumePrefetchResponseForNavigation: Navigation["consumePrefetchResponseForNavigation"];
|
|
|
|
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: {
|
|
origin: "http://localhost",
|
|
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;
|
|
consumePrefetchResponse = nav.consumePrefetchResponse;
|
|
getPrefetchCache = nav.getPrefetchCache;
|
|
getPrefetchedUrls = nav.getPrefetchedUrls;
|
|
getCurrentInterceptionContext = nav.getCurrentInterceptionContext;
|
|
MAX_PREFETCH_CACHE_SIZE = nav.MAX_PREFETCH_CACHE_SIZE;
|
|
PREFETCH_CACHE_TTL = nav.PREFETCH_CACHE_TTL;
|
|
snapshotRscResponse = nav.snapshotRscResponse;
|
|
restoreRscResponse = nav.restoreRscResponse;
|
|
prefetchRscResponse = nav.prefetchRscResponse;
|
|
invalidatePrefetchCache = nav.invalidatePrefetchCache;
|
|
hasPrefetchCacheEntryForNavigation = nav.hasPrefetchCacheEntryForNavigation;
|
|
appRouterInstance = nav.appRouterInstance;
|
|
consumePrefetchResponseForNavigation = nav.consumePrefetchResponseForNavigation;
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
delete (globalThis as any).window;
|
|
delete (globalThis as any).fetch;
|
|
});
|
|
|
|
/** 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`;
|
|
const body = `body-${i}`;
|
|
const buffer = new TextEncoder().encode(body).buffer;
|
|
cache.set(key, {
|
|
snapshot: {
|
|
buffer,
|
|
contentType: "text/x-component",
|
|
paramsHeader: null,
|
|
url: key,
|
|
},
|
|
outcome: "cache-seeded",
|
|
timestamp,
|
|
});
|
|
prefetched.add(key);
|
|
}
|
|
}
|
|
|
|
function createDeferredResponse(): {
|
|
promise: Promise<Response>;
|
|
resolve: (response: Response) => void;
|
|
} {
|
|
let resolve: (response: Response) => void = () => {
|
|
throw new Error("Deferred response was not initialized");
|
|
};
|
|
const promise = new Promise<Response>((resolveInner) => {
|
|
resolve = resolveInner;
|
|
});
|
|
return { promise, resolve };
|
|
}
|
|
|
|
async function waitForPrefetchSetup(isReady: () => boolean = () => true): Promise<void> {
|
|
const deadline = Date.now() + 1_000;
|
|
|
|
do {
|
|
await Promise.resolve();
|
|
if (isReady()) return;
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
} while (Date.now() < deadline);
|
|
}
|
|
|
|
describe("prefetch cache eviction", () => {
|
|
it("router.prefetch ignores external absolute URLs", async () => {
|
|
const fetch = vi.fn();
|
|
(globalThis as any).fetch = fetch;
|
|
|
|
appRouterInstance.prefetch("https://external.example/dashboard");
|
|
await waitForPrefetchSetup();
|
|
|
|
expect(fetch).not.toHaveBeenCalled();
|
|
expect(getPrefetchedUrls().size).toBe(0);
|
|
});
|
|
|
|
it("router.prefetch normalizes same-origin absolute URLs before caching", async () => {
|
|
let fetchedUrl: unknown;
|
|
const fetch = vi.fn(async (input: RequestInfo | URL) => {
|
|
fetchedUrl = input;
|
|
return new Response("flight", { headers: { "content-type": "text/x-component" } });
|
|
});
|
|
(globalThis as any).fetch = fetch;
|
|
|
|
appRouterInstance.prefetch("http://localhost/dashboard?tab=1");
|
|
await waitForPrefetchSetup(() => fetch.mock.calls.length > 0);
|
|
|
|
expect(fetch).toHaveBeenCalledTimes(1);
|
|
expect(fetchedUrl).toMatch(/^\/dashboard\?tab=1&_rsc(?:=.+)?$/);
|
|
expect(getPrefetchedUrls().has(AppElementsWire.encodeCacheKey(String(fetchedUrl), null))).toBe(
|
|
true,
|
|
);
|
|
});
|
|
|
|
it("router.prefetch calls onInvalidate once when the prefetched response is invalidated", async () => {
|
|
let fetchedUrl: unknown;
|
|
const fetch = vi.fn(async (input: RequestInfo | URL) => {
|
|
fetchedUrl = input;
|
|
return new Response("flight", { headers: { "content-type": "text/x-component" } });
|
|
});
|
|
const onInvalidate = vi.fn();
|
|
(globalThis as any).fetch = fetch;
|
|
|
|
appRouterInstance.prefetch("/dashboard", { onInvalidate });
|
|
await waitForPrefetchSetup(() => getPrefetchCache().size > 0);
|
|
|
|
const cacheKey = AppElementsWire.encodeCacheKey(String(fetchedUrl), null);
|
|
expect(getPrefetchedUrls().has(cacheKey)).toBe(true);
|
|
|
|
invalidatePrefetchCache();
|
|
|
|
expect(onInvalidate).toHaveBeenCalledTimes(1);
|
|
expect(getPrefetchedUrls().has(cacheKey)).toBe(false);
|
|
expect(getPrefetchCache().has(cacheKey)).toBe(false);
|
|
|
|
invalidatePrefetchCache();
|
|
expect(onInvalidate).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("router.prefetch preserves onInvalidate callbacks attached to an already-prefetched URL", async () => {
|
|
const fetch = vi.fn(
|
|
async () => new Response("flight", { headers: { "content-type": "text/x-component" } }),
|
|
);
|
|
const firstInvalidate = vi.fn();
|
|
const secondInvalidate = vi.fn();
|
|
(globalThis as any).fetch = fetch;
|
|
|
|
appRouterInstance.prefetch("/dashboard", { onInvalidate: firstInvalidate });
|
|
await waitForPrefetchSetup(() => getPrefetchCache().size > 0);
|
|
appRouterInstance.prefetch("/dashboard", { onInvalidate: secondInvalidate });
|
|
await waitForPrefetchSetup(() => {
|
|
const entry = getPrefetchCache().values().next().value;
|
|
return entry?.onInvalidateCallbacks?.size === 2;
|
|
});
|
|
|
|
expect(fetch).toHaveBeenCalledTimes(1);
|
|
|
|
invalidatePrefetchCache();
|
|
|
|
expect(firstInvalidate).toHaveBeenCalledTimes(1);
|
|
expect(secondInvalidate).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("reuses a prefetched response only when mounted-slot context matches", () => {
|
|
const cache = getPrefetchCache();
|
|
const prefetched = getPrefetchedUrls();
|
|
const rscUrl = "/dashboard.rsc";
|
|
const snapshot = {
|
|
buffer: new TextEncoder().encode("flight").buffer,
|
|
contentType: "text/x-component",
|
|
mountedSlotsHeader: "slot:auth:/",
|
|
paramsHeader: null,
|
|
url: rscUrl,
|
|
};
|
|
|
|
cache.set(rscUrl, { outcome: "cache-seeded", snapshot, timestamp: Date.now() });
|
|
prefetched.add(rscUrl);
|
|
|
|
expect(consumePrefetchResponse(rscUrl, null, "slot:auth:/")).toEqual(snapshot);
|
|
expect(cache.has(rscUrl)).toBe(false);
|
|
expect(prefetched.has(rscUrl)).toBe(false);
|
|
});
|
|
|
|
it("rejects a prefetched response when mounted-slot context differs", () => {
|
|
const cache = getPrefetchCache();
|
|
const prefetched = getPrefetchedUrls();
|
|
const rscUrl = "/dashboard.rsc";
|
|
|
|
cache.set(rscUrl, {
|
|
outcome: "cache-seeded",
|
|
snapshot: {
|
|
buffer: new TextEncoder().encode("flight").buffer,
|
|
contentType: "text/x-component",
|
|
mountedSlotsHeader: "slot:auth:/",
|
|
paramsHeader: null,
|
|
url: rscUrl,
|
|
},
|
|
timestamp: Date.now(),
|
|
});
|
|
prefetched.add(rscUrl);
|
|
|
|
expect(consumePrefetchResponse(rscUrl, null, "slot:nav:/")).toBeNull();
|
|
expect(cache.has(rscUrl)).toBe(false);
|
|
expect(prefetched.has(rscUrl)).toBe(false);
|
|
});
|
|
|
|
it("preserves server mounted-slot metadata separately from prefetch request context", async () => {
|
|
const rscUrl = "/parallel-slots.rsc";
|
|
const responseMountedSlotsHeader = "slot:slotA:/parallel-slots slot:slotB:/parallel-slots";
|
|
|
|
prefetchRscResponse(
|
|
rscUrl,
|
|
Promise.resolve(
|
|
new Response("flight", {
|
|
headers: {
|
|
"content-type": "text/x-component",
|
|
[VINEXT_MOUNTED_SLOTS_HEADER]: responseMountedSlotsHeader,
|
|
},
|
|
}),
|
|
),
|
|
null,
|
|
null,
|
|
);
|
|
|
|
await waitForPrefetchSetup(() => getPrefetchCache().get(rscUrl)?.outcome === "cache-seeded");
|
|
|
|
const entry = getPrefetchCache().get(rscUrl);
|
|
expect(entry?.mountedSlotsHeader).toBeNull();
|
|
|
|
const consumed = consumePrefetchResponse(rscUrl, null, null);
|
|
expect(consumed?.mountedSlotsHeader).toBe(responseMountedSlotsHeader);
|
|
});
|
|
|
|
it("matches equivalent RSC cache variants by server-declared mounted slots", () => {
|
|
const cache = getPrefetchCache();
|
|
const prefetched = getPrefetchedUrls();
|
|
const originalRscUrl = "/parallel-slots.rsc?_rsc";
|
|
const mountedVariantRscUrl = "/parallel-slots.rsc?_rsc=mounted-slot-hash";
|
|
const mountedSlotsHeader = "slot:slotA:/parallel-slots slot:slotB:/parallel-slots";
|
|
const snapshot = {
|
|
buffer: new TextEncoder().encode("parallel-flight").buffer,
|
|
contentType: "text/x-component",
|
|
mountedSlotsHeader,
|
|
paramsHeader: null,
|
|
url: originalRscUrl,
|
|
};
|
|
|
|
cache.set(originalRscUrl, {
|
|
mountedSlotsHeader: null,
|
|
outcome: "cache-seeded",
|
|
snapshot,
|
|
timestamp: Date.now(),
|
|
});
|
|
prefetched.add(originalRscUrl);
|
|
|
|
expect(hasPrefetchCacheEntryForNavigation(mountedVariantRscUrl, null, mountedSlotsHeader)).toBe(
|
|
true,
|
|
);
|
|
expect(consumePrefetchResponse(mountedVariantRscUrl, null, mountedSlotsHeader)).toEqual(
|
|
snapshot,
|
|
);
|
|
expect(cache.has(originalRscUrl)).toBe(false);
|
|
expect(prefetched.has(originalRscUrl)).toBe(false);
|
|
});
|
|
|
|
it("keeps learning-only prefetch responses out of navigation consumption", () => {
|
|
const cache = getPrefetchCache();
|
|
const prefetched = getPrefetchedUrls();
|
|
const rscUrl = "/blog/hello.rsc";
|
|
|
|
cache.set(rscUrl, {
|
|
cacheForNavigation: false,
|
|
outcome: "cache-seeded",
|
|
snapshot: {
|
|
buffer: new TextEncoder().encode("flight").buffer,
|
|
contentType: "text/x-component",
|
|
mountedSlotsHeader: null,
|
|
paramsHeader: null,
|
|
url: rscUrl,
|
|
},
|
|
timestamp: Date.now(),
|
|
});
|
|
prefetched.add(rscUrl);
|
|
|
|
expect(consumePrefetchResponse(rscUrl, null, null)).toBeNull();
|
|
expect(cache.has(rscUrl)).toBe(true);
|
|
expect(prefetched.has(rscUrl)).toBe(true);
|
|
});
|
|
|
|
it("derives the interception context from the current pathname", () => {
|
|
(globalThis as any).window.location.pathname = "/feed";
|
|
|
|
expect(getCurrentInterceptionContext()).toBe("/feed");
|
|
});
|
|
|
|
it("allows separate interception-context entries for the same RSC URL", () => {
|
|
storePrefetchResponse("/photos/42.rsc", new Response("feed"), "/feed");
|
|
storePrefetchResponse("/photos/42.rsc", new Response("gallery"), "/gallery");
|
|
|
|
const feedKey = AppElementsWire.encodeCacheKey("/photos/42.rsc", "/feed");
|
|
const galleryKey = AppElementsWire.encodeCacheKey("/photos/42.rsc", "/gallery");
|
|
expect(feedKey).not.toBe(galleryKey);
|
|
expect(getPrefetchCache().has(feedKey)).toBe(true);
|
|
expect(getPrefetchCache().has(galleryKey)).toBe(true);
|
|
});
|
|
|
|
it("preserves RSC metadata when replaying cached responses", async () => {
|
|
const response = new Response("flight", {
|
|
headers: {
|
|
"content-type": "text/x-component",
|
|
[VINEXT_RSC_COMPATIBILITY_ID_HEADER]: "compat-a",
|
|
[VINEXT_DYNAMIC_STALE_TIME_HEADER]: "60",
|
|
"x-vinext-params": encodeURIComponent('{"id":"2"}'),
|
|
},
|
|
});
|
|
|
|
const snapshot = await snapshotRscResponse(response);
|
|
const restored = restoreRscResponse(snapshot);
|
|
|
|
expect(snapshot.dynamicStaleTimeSeconds).toBe(60);
|
|
expect(restored.headers.get("content-type")).toBe("text/x-component");
|
|
expect(restored.headers.get(VINEXT_RSC_COMPATIBILITY_ID_HEADER)).toBe("compat-a");
|
|
expect(restored.headers.get(VINEXT_DYNAMIC_STALE_TIME_HEADER)).toBe("60");
|
|
expect(restored.headers.get("x-vinext-params")).toBe(encodeURIComponent('{"id":"2"}'));
|
|
await expect(restored.text()).resolves.toBe("flight");
|
|
});
|
|
|
|
it("settles router.prefetch as a consumable cache-seeded response without visible navigation", async () => {
|
|
let resolveResponse!: (response: Response) => void;
|
|
const fetchPromise = new Promise<Response>((resolve) => {
|
|
resolveResponse = resolve;
|
|
});
|
|
let fetchedUrl: RequestInfo | URL | undefined;
|
|
const fetch = vi.fn((input: RequestInfo | URL) => {
|
|
fetchedUrl = input;
|
|
return fetchPromise;
|
|
});
|
|
const navigate = vi.fn();
|
|
(globalThis as any).fetch = fetch;
|
|
(globalThis as any).window[Symbol.for("vinext.navigationRuntime")] = {
|
|
bootstrap: {
|
|
routeManifest: null,
|
|
rsc: undefined,
|
|
},
|
|
functions: {
|
|
navigate,
|
|
},
|
|
};
|
|
|
|
appRouterInstance.prefetch("/dashboard");
|
|
await waitForPrefetchSetup(() => fetch.mock.calls.length > 0);
|
|
|
|
if (fetchedUrl === undefined) {
|
|
throw new Error("Expected router.prefetch to fetch an RSC URL");
|
|
}
|
|
|
|
const rscUrl =
|
|
typeof fetchedUrl === "string"
|
|
? fetchedUrl
|
|
: fetchedUrl instanceof URL
|
|
? fetchedUrl.href
|
|
: fetchedUrl.url;
|
|
const cacheKey = AppElementsWire.encodeCacheKey(rscUrl, null);
|
|
|
|
expect(getPrefetchCache().get(cacheKey)?.outcome).toBe("pending");
|
|
|
|
resolveResponse(new Response("flight", { headers: { "content-type": "text/x-component" } }));
|
|
await waitForPrefetchSetup(
|
|
() =>
|
|
getPrefetchCache().get(cacheKey)?.outcome === "cache-seeded" &&
|
|
getPrefetchCache().get(cacheKey)?.pending === undefined,
|
|
);
|
|
|
|
const entry = getPrefetchCache().get(cacheKey);
|
|
expect(entry?.outcome).toBe("cache-seeded");
|
|
expect(entry?.pending).toBeUndefined();
|
|
|
|
const consumed = consumePrefetchResponse(rscUrl, null, null);
|
|
expect(consumed?.mountedSlotsHeader).toBeNull();
|
|
expect(getPrefetchCache().has(cacheKey)).toBe(false);
|
|
expect(getPrefetchedUrls().has(cacheKey)).toBe(false);
|
|
expect(navigate).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("awaits an in-flight prefetch instead of missing the navigation cache", async () => {
|
|
const rscUrl = "/dashboard.rsc";
|
|
const deferred = createDeferredResponse();
|
|
let settled = false;
|
|
|
|
prefetchRscResponse(rscUrl, deferred.promise, null, null);
|
|
|
|
const consumedPromise = consumePrefetchResponseForNavigation(rscUrl, null, null).then(
|
|
(snapshot) => {
|
|
settled = true;
|
|
return snapshot;
|
|
},
|
|
);
|
|
|
|
await Promise.resolve();
|
|
expect(settled).toBe(false);
|
|
expect(getPrefetchCache().get(rscUrl)?.outcome).toBe("pending");
|
|
|
|
deferred.resolve(new Response("flight", { headers: { "content-type": "text/x-component" } }));
|
|
|
|
const consumed = await consumedPromise;
|
|
expect(settled).toBe(true);
|
|
expect(consumed).not.toBeNull();
|
|
if (consumed === null) return;
|
|
await expect(restoreRscResponse(consumed).text()).resolves.toBe("flight");
|
|
expect(getPrefetchCache().has(rscUrl)).toBe(false);
|
|
expect(getPrefetchedUrls().has(rscUrl)).toBe(false);
|
|
});
|
|
|
|
it("leaves a resolved in-flight prefetch for a newer navigation when the old navigation is stale", async () => {
|
|
const rscUrl = "/dashboard.rsc";
|
|
const deferred = createDeferredResponse();
|
|
let currentNavigation = true;
|
|
|
|
prefetchRscResponse(rscUrl, deferred.promise, null, null);
|
|
|
|
const staleNavigationConsume = consumePrefetchResponseForNavigation(rscUrl, null, null, {
|
|
shouldConsume: () => currentNavigation,
|
|
});
|
|
|
|
await Promise.resolve();
|
|
currentNavigation = false;
|
|
deferred.resolve(new Response("flight", { headers: { "content-type": "text/x-component" } }));
|
|
|
|
await expect(staleNavigationConsume).resolves.toBeNull();
|
|
expect(getPrefetchCache().get(rscUrl)?.outcome).toBe("cache-seeded");
|
|
|
|
const consumed = await consumePrefetchResponseForNavigation(rscUrl, null, null);
|
|
expect(consumed).not.toBeNull();
|
|
if (consumed === null) return;
|
|
await expect(restoreRscResponse(consumed).text()).resolves.toBe("flight");
|
|
expect(getPrefetchCache().has(rscUrl)).toBe(false);
|
|
expect(getPrefetchedUrls().has(rscUrl)).toBe(false);
|
|
});
|
|
|
|
it("sweeps all expired entries before FIFO", () => {
|
|
// 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);
|
|
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);
|
|
});
|
|
|
|
it("falls back to FIFO when all entries are fresh", () => {
|
|
// 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);
|
|
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", () => {
|
|
// 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);
|
|
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);
|
|
});
|
|
|
|
// Regression for issue #1490: experimental.staleTimes.static should be
|
|
// honored as the prefetch cache freshness window. The plugin injects
|
|
// `process.env.__NEXT_CLIENT_ROUTER_STATIC_STALETIME` (in seconds) at
|
|
// build time; navigation.ts reads it when computing PREFETCH_CACHE_TTL.
|
|
describe("staleTimes (#1490)", () => {
|
|
const ORIGINAL_TTL_ENV = process.env.__NEXT_CLIENT_ROUTER_STATIC_STALETIME;
|
|
|
|
afterEach(() => {
|
|
if (ORIGINAL_TTL_ENV === undefined) {
|
|
delete process.env.__NEXT_CLIENT_ROUTER_STATIC_STALETIME;
|
|
} else {
|
|
process.env.__NEXT_CLIENT_ROUTER_STATIC_STALETIME = ORIGINAL_TTL_ENV;
|
|
}
|
|
});
|
|
|
|
it("uses 30s when __NEXT_CLIENT_ROUTER_STATIC_STALETIME is unset", async () => {
|
|
delete process.env.__NEXT_CLIENT_ROUTER_STATIC_STALETIME;
|
|
vi.resetModules();
|
|
const nav = await import("../packages/vinext/src/shims/navigation.js");
|
|
expect(nav.PREFETCH_CACHE_TTL).toBe(30_000);
|
|
});
|
|
|
|
it("converts seconds from __NEXT_CLIENT_ROUTER_STATIC_STALETIME into ms", async () => {
|
|
process.env.__NEXT_CLIENT_ROUTER_STATIC_STALETIME = "180";
|
|
vi.resetModules();
|
|
const nav = await import("../packages/vinext/src/shims/navigation.js");
|
|
expect(nav.PREFETCH_CACHE_TTL).toBe(180_000);
|
|
});
|
|
|
|
it("treats a freshly prefetched entry as reusable up to the configured TTL", async () => {
|
|
process.env.__NEXT_CLIENT_ROUTER_STATIC_STALETIME = "180";
|
|
vi.resetModules();
|
|
const nav = await import("../packages/vinext/src/shims/navigation.js");
|
|
|
|
const cache = nav.getPrefetchCache();
|
|
const prefetched = nav.getPrefetchedUrls();
|
|
const rscUrl = "/dashboard.rsc";
|
|
const now = 1_000_000;
|
|
const snapshot = {
|
|
buffer: new TextEncoder().encode("flight").buffer,
|
|
contentType: "text/x-component",
|
|
mountedSlotsHeader: null,
|
|
paramsHeader: null,
|
|
url: rscUrl,
|
|
};
|
|
|
|
cache.set(rscUrl, { outcome: "cache-seeded", snapshot, timestamp: now });
|
|
prefetched.add(rscUrl);
|
|
|
|
// 150 seconds later — within the configured 180s window, must reuse
|
|
vi.spyOn(Date, "now").mockReturnValue(now + 150_000);
|
|
expect(nav.consumePrefetchResponse(rscUrl, null, null)).toEqual(snapshot);
|
|
});
|
|
|
|
it("treats a stale entry as expired once the configured TTL elapses", async () => {
|
|
process.env.__NEXT_CLIENT_ROUTER_STATIC_STALETIME = "180";
|
|
vi.resetModules();
|
|
const nav = await import("../packages/vinext/src/shims/navigation.js");
|
|
|
|
const cache = nav.getPrefetchCache();
|
|
const prefetched = nav.getPrefetchedUrls();
|
|
const rscUrl = "/dashboard.rsc";
|
|
const now = 1_000_000;
|
|
const snapshot = {
|
|
buffer: new TextEncoder().encode("flight").buffer,
|
|
contentType: "text/x-component",
|
|
mountedSlotsHeader: null,
|
|
paramsHeader: null,
|
|
url: rscUrl,
|
|
};
|
|
|
|
cache.set(rscUrl, { outcome: "cache-seeded", snapshot, timestamp: now });
|
|
prefetched.add(rscUrl);
|
|
|
|
// 200 seconds later — past the 180s window, must NOT reuse
|
|
vi.spyOn(Date, "now").mockReturnValue(now + 200_000);
|
|
expect(nav.consumePrefetchResponse(rscUrl, null, null)).toBeNull();
|
|
});
|
|
});
|
|
|
|
it("uses per-response dynamic stale windows when consuming prefetched responses", () => {
|
|
// Ported from Next.js: test/e2e/app-dir/segment-cache/staleness/segment-cache-per-page-dynamic-stale-time.test.ts
|
|
const cache = getPrefetchCache();
|
|
const prefetched = getPrefetchedUrls();
|
|
const now = 1_000_000;
|
|
const snapshot60 = {
|
|
buffer: new TextEncoder().encode("dynamic-60").buffer,
|
|
contentType: "text/x-component",
|
|
dynamicStaleTimeSeconds: 60,
|
|
mountedSlotsHeader: null,
|
|
paramsHeader: null,
|
|
url: "/dynamic-stale-60.rsc",
|
|
};
|
|
const snapshot10 = {
|
|
buffer: new TextEncoder().encode("dynamic-10").buffer,
|
|
contentType: "text/x-component",
|
|
dynamicStaleTimeSeconds: 10,
|
|
mountedSlotsHeader: null,
|
|
paramsHeader: null,
|
|
url: "/dynamic-stale-10.rsc",
|
|
};
|
|
|
|
cache.set(snapshot60.url, { outcome: "cache-seeded", snapshot: snapshot60, timestamp: now });
|
|
cache.set(snapshot10.url, { outcome: "cache-seeded", snapshot: snapshot10, timestamp: now });
|
|
prefetched.add(snapshot60.url);
|
|
prefetched.add(snapshot10.url);
|
|
|
|
vi.spyOn(Date, "now").mockReturnValue(now + 15_000);
|
|
|
|
expect(consumePrefetchResponse(snapshot60.url, null, null)).toEqual(snapshot60);
|
|
expect(consumePrefetchResponse(snapshot10.url, null, null)).toBeNull();
|
|
});
|
|
|
|
it("does not report stale entries as available for Link prefetch dedupe", () => {
|
|
const cache = getPrefetchCache();
|
|
const prefetched = getPrefetchedUrls();
|
|
const now = 1_000_000;
|
|
const rscUrl = "/dynamic-stale-10.rsc";
|
|
cache.set(rscUrl, {
|
|
outcome: "cache-seeded",
|
|
snapshot: {
|
|
buffer: new TextEncoder().encode("dynamic-10").buffer,
|
|
contentType: "text/x-component",
|
|
dynamicStaleTimeSeconds: 10,
|
|
mountedSlotsHeader: null,
|
|
paramsHeader: null,
|
|
url: rscUrl,
|
|
},
|
|
timestamp: now,
|
|
});
|
|
prefetched.add(rscUrl);
|
|
|
|
vi.spyOn(Date, "now").mockReturnValue(now + 10_000);
|
|
|
|
expect(hasPrefetchCacheEntryForNavigation(rscUrl, null, null)).toBe(false);
|
|
expect(getPrefetchCache().has(rscUrl)).toBe(false);
|
|
expect(getPrefetchedUrls().has(rscUrl)).toBe(false);
|
|
});
|
|
|
|
it("can probe stale navigation candidates without notifying invalidation callbacks", () => {
|
|
const cache = getPrefetchCache();
|
|
const prefetched = getPrefetchedUrls();
|
|
const onInvalidate = vi.fn();
|
|
const now = 1_000_000;
|
|
const rscUrl = "/dynamic-stale-navigation.rsc";
|
|
cache.set(rscUrl, {
|
|
onInvalidateCallbacks: new Set([onInvalidate]),
|
|
outcome: "cache-seeded",
|
|
snapshot: {
|
|
buffer: new TextEncoder().encode("dynamic-navigation").buffer,
|
|
contentType: "text/x-component",
|
|
dynamicStaleTimeSeconds: 10,
|
|
mountedSlotsHeader: null,
|
|
paramsHeader: null,
|
|
url: rscUrl,
|
|
},
|
|
timestamp: now,
|
|
});
|
|
prefetched.add(rscUrl);
|
|
|
|
vi.spyOn(Date, "now").mockReturnValue(now + 10_000);
|
|
|
|
expect(
|
|
hasPrefetchCacheEntryForNavigation(rscUrl, null, null, { notifyInvalidation: false }),
|
|
).toBe(false);
|
|
expect(onInvalidate).not.toHaveBeenCalled();
|
|
expect(getPrefetchCache().has(rscUrl)).toBe(false);
|
|
expect(getPrefetchedUrls().has(rscUrl)).toBe(false);
|
|
});
|
|
|
|
it("preserves the original expiry when consuming a prefetched response", () => {
|
|
const cache = getPrefetchCache();
|
|
const prefetched = getPrefetchedUrls();
|
|
const rscUrl = "/parallel-slots.rsc";
|
|
const now = 1_000_000;
|
|
const expiresAt = now + 15_000;
|
|
const snapshot = {
|
|
buffer: new TextEncoder().encode("parallel-flight").buffer,
|
|
contentType: "text/x-component",
|
|
dynamicStaleTimeSeconds: 15,
|
|
mountedSlotsHeader: "slot:slotA:/parallel-slots slot:slotB:/parallel-slots",
|
|
paramsHeader: null,
|
|
url: rscUrl,
|
|
};
|
|
|
|
cache.set(rscUrl, {
|
|
expiresAt,
|
|
mountedSlotsHeader: snapshot.mountedSlotsHeader,
|
|
outcome: "cache-seeded",
|
|
snapshot,
|
|
timestamp: now,
|
|
});
|
|
prefetched.add(rscUrl);
|
|
|
|
vi.spyOn(Date, "now").mockReturnValue(now + 14_000);
|
|
const consumed = consumePrefetchResponse(rscUrl, null, snapshot.mountedSlotsHeader);
|
|
expect(consumed).not.toBeNull();
|
|
if (consumed === null) {
|
|
throw new Error("Expected prefetched response to be reusable before its expiry");
|
|
}
|
|
expect(consumed?.expiresAt).toBe(expiresAt);
|
|
|
|
vi.spyOn(Date, "now").mockReturnValue(now + 16_000);
|
|
expect(consumePrefetchResponse(rscUrl, null, snapshot.mountedSlotsHeader)).toBeNull();
|
|
});
|
|
|
|
it("does not sweep when cache is below capacity", () => {
|
|
// 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;
|
|
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);
|
|
// 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);
|
|
});
|
|
});
|