mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
5f6bccbd03
* docs: refresh stale app browser entry comments * feat: track previousNextUrl for intercepted App Router entries * refactor: bind getBrowserRouterState() once in refresh case Avoid calling getBrowserRouterState() twice in the refresh branch of getRequestState by binding previousNextUrl to a local. Behavior-preserving nit raised in PR review. * refactor: drop dead history-state interception-context read path After previousNextUrl became the source of truth for interception context in history state, nothing in production writes the legacy __vinext_interceptionContext key anymore. The fall-through read in getCurrentInterceptionContext was vestigial and getCurrentInterceptionContext now derives strictly from the current pathname. - Removes the unused VINEXT_INTERCEPTION_CONTEXT_HISTORY_STATE_KEY export - Removes readHistoryStateInterceptionContext - Updates the prefetch-cache unit test to assert the new pathname-derived contract instead of the legacy history-state override * test(e2e): cover back/forward restoration of intercepted modal view Adds the back-then-forward case for App Router intercepting routes: after intercepted navigation from /feed to /photos/42, going back to /feed and then forward should restore the modal (rather than rendering the full /photos/42 page). Directly exercises the previousNextUrl read in the traverse branch of getRequestState. * docs: reword stale 'belongs to PR 5' comments Two inline comments in app-browser-entry.ts referenced "PR 5" as a future PR, but this is PR 5. Reworded to describe the deferred scope as-is: interception context on server-action POSTs and HMR re-renders is intentionally not propagated. * fix: distinguish explicit null from undefined in previousNextUrl fallback --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: James <james@eli.cx>
248 lines
9.5 KiB
TypeScript
248 lines
9.5 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 { createAppPayloadCacheKey } from "../packages/vinext/src/server/app-elements.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"];
|
|
|
|
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;
|
|
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;
|
|
});
|
|
|
|
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`;
|
|
const body = `body-${i}`;
|
|
const buffer = new TextEncoder().encode(body).buffer;
|
|
cache.set(key, {
|
|
snapshot: {
|
|
buffer,
|
|
contentType: "text/x-component",
|
|
paramsHeader: null,
|
|
url: key,
|
|
},
|
|
timestamp,
|
|
});
|
|
prefetched.add(key);
|
|
}
|
|
}
|
|
|
|
describe("prefetch cache eviction", () => {
|
|
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, { 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, {
|
|
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("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 = createAppPayloadCacheKey("/photos/42.rsc", "/feed");
|
|
const galleryKey = createAppPayloadCacheKey("/photos/42.rsc", "/gallery");
|
|
expect(feedKey).not.toBe(galleryKey);
|
|
expect(getPrefetchCache().has(feedKey)).toBe(true);
|
|
expect(getPrefetchCache().has(galleryKey)).toBe(true);
|
|
});
|
|
|
|
it("preserves X-Vinext-Params when replaying cached RSC responses", async () => {
|
|
const response = new Response("flight", {
|
|
headers: {
|
|
"content-type": "text/x-component; charset=utf-8",
|
|
"x-vinext-params": encodeURIComponent('{"id":"2"}'),
|
|
},
|
|
});
|
|
|
|
const snapshot = await snapshotRscResponse(response);
|
|
const restored = restoreRscResponse(snapshot);
|
|
|
|
expect(restored.headers.get("content-type")).toBe("text/x-component; charset=utf-8");
|
|
expect(restored.headers.get("x-vinext-params")).toBe(encodeURIComponent('{"id":"2"}'));
|
|
await expect(restored.text()).resolves.toBe("flight");
|
|
});
|
|
|
|
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);
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|