mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
852 lines
31 KiB
TypeScript
852 lines
31 KiB
TypeScript
/**
|
|
* ISR cache unit tests.
|
|
*
|
|
* Tests cache key generation, normalization, hash truncation,
|
|
* revalidate duration tracking with LRU eviction, background
|
|
* regeneration deduplication, and cache value builders.
|
|
*
|
|
* These complement the integration-level ISR tests in features.test.ts
|
|
* by testing the ISR cache layer in isolation.
|
|
*/
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vite-plus/test";
|
|
import {
|
|
isrCacheKey,
|
|
appIsrHtmlKey,
|
|
appIsrRscKey,
|
|
appIsrRouteKey,
|
|
isrGet,
|
|
isrSet,
|
|
buildPagesCacheValue,
|
|
buildAppPageCacheValue,
|
|
normalizeMountedSlotsHeader,
|
|
setRevalidateDuration,
|
|
getRevalidateDuration,
|
|
triggerBackgroundRegeneration,
|
|
} from "../packages/vinext/src/server/isr-cache.js";
|
|
import {
|
|
APP_RSC_RENDER_MODE_PREFETCH_LOADING_SHELL,
|
|
APP_RSC_RENDER_MODE_REFRESH_PRESERVE_UI,
|
|
} from "../packages/vinext/src/server/app-rsc-render-mode.js";
|
|
import { fnv1a64 } from "../packages/vinext/src/utils/hash.js";
|
|
import { buildPageCacheTags } from "../packages/vinext/src/server/implicit-tags.js";
|
|
import { runWithExecutionContext } from "../packages/vinext/src/shims/request-context.js";
|
|
import {
|
|
createRequestContext,
|
|
getRequestContext,
|
|
isInsideUnifiedScope,
|
|
runWithRequestContext,
|
|
} from "../packages/vinext/src/shims/unified-request-context.js";
|
|
import {
|
|
MemoryCacheHandler,
|
|
setCacheHandler,
|
|
revalidatePath,
|
|
type CachedFetchValue,
|
|
} from "../packages/vinext/src/shims/cache.js";
|
|
|
|
// ─── isrCacheKey ────────────────────────────────────────────────────────
|
|
|
|
describe("isrCacheKey", () => {
|
|
it("fnv1a64 uses fixed-width unambiguous output", () => {
|
|
const hash = fnv1a64("/" + "a".repeat(250));
|
|
expect(hash).toMatch(/^[0-9a-f]{16}$/);
|
|
});
|
|
|
|
it("generates pages: prefix for Pages Router", () => {
|
|
expect(isrCacheKey("pages", "/about")).toBe("pages:/about");
|
|
});
|
|
|
|
it("generates app: prefix for App Router", () => {
|
|
expect(isrCacheKey("app", "/dashboard")).toBe("app:/dashboard");
|
|
});
|
|
|
|
it("preserves root / without stripping", () => {
|
|
expect(isrCacheKey("pages", "/")).toBe("pages:/");
|
|
});
|
|
|
|
it("strips trailing slash from non-root paths", () => {
|
|
expect(isrCacheKey("pages", "/about/")).toBe("pages:/about");
|
|
});
|
|
|
|
it("does not strip trailing slash from root", () => {
|
|
expect(isrCacheKey("pages", "/")).toBe("pages:/");
|
|
});
|
|
|
|
it("handles deeply nested paths", () => {
|
|
expect(isrCacheKey("app", "/blog/2024/01/my-post")).toBe("app:/blog/2024/01/my-post");
|
|
});
|
|
|
|
it("hashes very long paths (> 200 chars)", () => {
|
|
const longPath = "/" + "a".repeat(250);
|
|
const key = isrCacheKey("pages", longPath);
|
|
expect(key).toMatch(/^pages:__hash:/);
|
|
// Hash should be deterministic
|
|
const key2 = isrCacheKey("pages", longPath);
|
|
expect(key).toBe(key2);
|
|
});
|
|
|
|
it("does not hash paths that produce keys <= 200 chars", () => {
|
|
const shortPath = "/about";
|
|
const key = isrCacheKey("pages", shortPath);
|
|
expect(key).toBe("pages:/about");
|
|
expect(key).not.toContain("__hash:");
|
|
});
|
|
|
|
it("different long paths produce different hashes", () => {
|
|
const path1 = "/" + "a".repeat(250);
|
|
const path2 = "/" + "b".repeat(250);
|
|
expect(isrCacheKey("pages", path1)).not.toBe(isrCacheKey("pages", path2));
|
|
});
|
|
|
|
it("includes buildId in key when provided", () => {
|
|
expect(isrCacheKey("pages", "/about", "abc123")).toBe("pages:abc123:/about");
|
|
});
|
|
|
|
it("includes buildId in app router key", () => {
|
|
expect(isrCacheKey("app", "/dashboard", "build-42")).toBe("app:build-42:/dashboard");
|
|
});
|
|
|
|
it("preserves root with buildId", () => {
|
|
expect(isrCacheKey("pages", "/", "v1")).toBe("pages:v1:/");
|
|
});
|
|
|
|
it("strips trailing slash with buildId", () => {
|
|
expect(isrCacheKey("pages", "/about/", "v1")).toBe("pages:v1:/about");
|
|
});
|
|
|
|
it("hashes long paths with buildId", () => {
|
|
const longPath = "/" + "a".repeat(250);
|
|
const key = isrCacheKey("pages", longPath, "build-99");
|
|
expect(key).toMatch(/^pages:build-99:__hash:/);
|
|
});
|
|
|
|
it("without buildId format is unchanged (backward compat)", () => {
|
|
expect(isrCacheKey("pages", "/about")).toBe("pages:/about");
|
|
expect(isrCacheKey("app", "/dashboard")).toBe("app:/dashboard");
|
|
});
|
|
});
|
|
|
|
describe("App Router ISR cache key primitives", () => {
|
|
const originalBuildId = process.env.__VINEXT_BUILD_ID;
|
|
|
|
afterEach(() => {
|
|
if (originalBuildId === undefined) {
|
|
delete process.env.__VINEXT_BUILD_ID;
|
|
return;
|
|
}
|
|
|
|
process.env.__VINEXT_BUILD_ID = originalBuildId;
|
|
});
|
|
|
|
it("builds separate html, rsc, and route keys from the normalized pathname", () => {
|
|
delete process.env.__VINEXT_BUILD_ID;
|
|
|
|
expect(appIsrHtmlKey("/about/")).toBe("app:/about:html");
|
|
expect(appIsrRscKey("/about/")).toBe("app:/about:rsc");
|
|
expect(appIsrRouteKey("/api/feed/")).toBe("app:/api/feed:route");
|
|
});
|
|
|
|
it("includes the build id when present", () => {
|
|
process.env.__VINEXT_BUILD_ID = "build-42";
|
|
|
|
expect(appIsrHtmlKey("/dashboard")).toBe("app:build-42:/dashboard:html");
|
|
});
|
|
|
|
it("hashes long pathname keys while preserving the cache entry suffix", () => {
|
|
delete process.env.__VINEXT_BUILD_ID;
|
|
|
|
const key = appIsrRscKey("/" + "a".repeat(250));
|
|
|
|
expect(key).toMatch(/^app:__hash:[a-z0-9]+:rsc$/);
|
|
});
|
|
|
|
it("keys mounted-slot RSC variants by normalized mounted-slot header", () => {
|
|
delete process.env.__VINEXT_BUILD_ID;
|
|
|
|
const first = appIsrRscKey("/feed", "slot:modal:/ slot:sidebar:/");
|
|
const second = appIsrRscKey("/feed", "slot:sidebar:/ slot:modal:/");
|
|
|
|
expect(first).toBe(second);
|
|
expect(first).toMatch(/^app:\/feed:rsc:slots:[a-z0-9]+$/);
|
|
});
|
|
|
|
it("bounds RSC cache-key cardinality against attacker-supplied mounted-slot values", () => {
|
|
// SECURITY-AUDIT-2026-05 F-PROD-1: an attacker who forges
|
|
// X-Vinext-Mounted-Slots: <unique-value> must not be able to fan out
|
|
// unbounded distinct cache keys (per-write KV billing / wallet attack).
|
|
delete process.env.__VINEXT_BUILD_ID;
|
|
|
|
const keys = new Set<string>();
|
|
for (let i = 0; i < 1000; i++) {
|
|
// Each of these violates the legitimate slot:<name>:<treePath> wire
|
|
// shape and must be dropped by normalization.
|
|
keys.add(appIsrRscKey("/feed", `attacker-${i}`));
|
|
}
|
|
// All 1000 distinct attacker values collapse to the same "no slots" key.
|
|
expect(keys.size).toBe(1);
|
|
expect(keys.values().next().value).toBe("app:/feed:rsc");
|
|
});
|
|
|
|
it("caps cache-key cardinality when an attacker pads the mounted-slots header", () => {
|
|
delete process.env.__VINEXT_BUILD_ID;
|
|
|
|
// Even when every token has the legitimate wire shape, the raw header
|
|
// length is capped (4096 bytes). When attacker payloads exceed the cap
|
|
// they are dropped to null, collapsing into the no-slots cache key.
|
|
const keys = new Set<string>();
|
|
for (let batch = 0; batch < 100; batch++) {
|
|
// 1000 legitimate tokens per batch easily exceeds the 4 KiB length cap.
|
|
const tokens = Array.from({ length: 1000 }, (_, i) => `slot:b${batch}_s${i}:/`).join(" ");
|
|
keys.add(appIsrRscKey("/feed", tokens));
|
|
}
|
|
expect(keys.size).toBe(1);
|
|
expect(keys.values().next().value).toBe("app:/feed:rsc");
|
|
});
|
|
|
|
it("keys intercepted RSC variants by source context", () => {
|
|
delete process.env.__VINEXT_BUILD_ID;
|
|
|
|
const fromFeed = appIsrRscKey("/photos/42", "slot:modal:/", undefined, "/feed");
|
|
const fromGallery = appIsrRscKey("/photos/42", "slot:modal:/", undefined, "/gallery");
|
|
const direct = appIsrRscKey("/photos/42", "slot:modal:/");
|
|
|
|
expect(fromFeed).not.toBe(fromGallery);
|
|
expect(fromFeed).not.toBe(direct);
|
|
expect(fromFeed).toMatch(/^app:\/photos\/42:rsc:source:[a-z0-9]+:slots:[a-z0-9]+$/);
|
|
});
|
|
|
|
it("normalizes source context before keying intercepted RSC variants", () => {
|
|
delete process.env.__VINEXT_BUILD_ID;
|
|
|
|
const encoded = appIsrRscKey("/photos/café", "modal", undefined, "/caf%C3%A9");
|
|
const decoded = appIsrRscKey("/photos/café", "modal", undefined, "/café");
|
|
const duplicateSlash = appIsrRscKey("/photos/café", "modal", undefined, "/café//");
|
|
|
|
expect(encoded).toBe(decoded);
|
|
expect(duplicateSlash).toBe(decoded);
|
|
});
|
|
|
|
it("ignores invalid source context before keying intercepted RSC variants", () => {
|
|
delete process.env.__VINEXT_BUILD_ID;
|
|
|
|
const invalidSource = appIsrRscKey("/photos/42", "modal", undefined, "/feed?tab=popular");
|
|
const direct = appIsrRscKey("/photos/42", "modal");
|
|
|
|
expect(invalidSource).toBe(direct);
|
|
});
|
|
|
|
it("keys RSC refresh variants separately from normal navigation variants", () => {
|
|
delete process.env.__VINEXT_BUILD_ID;
|
|
|
|
expect(appIsrRscKey("/feed", null, APP_RSC_RENDER_MODE_REFRESH_PRESERVE_UI)).toBe(
|
|
"app:/feed:rsc:preserve-ui",
|
|
);
|
|
expect(appIsrRscKey("/feed", "slot:modal:/", APP_RSC_RENDER_MODE_REFRESH_PRESERVE_UI)).toMatch(
|
|
/^app:\/feed:rsc:slots:[a-z0-9]+:preserve-ui$/,
|
|
);
|
|
});
|
|
|
|
it("keys RSC loading-shell prefetch variants separately from normal navigation variants", () => {
|
|
delete process.env.__VINEXT_BUILD_ID;
|
|
|
|
expect(appIsrRscKey("/feed", null, APP_RSC_RENDER_MODE_PREFETCH_LOADING_SHELL)).toBe(
|
|
"app:/feed:rsc:prefetch-loading-shell",
|
|
);
|
|
expect(
|
|
appIsrRscKey("/feed", "slot:modal:/", APP_RSC_RENDER_MODE_PREFETCH_LOADING_SHELL),
|
|
).toMatch(/^app:\/feed:rsc:slots:[a-z0-9]+:prefetch-loading-shell$/);
|
|
});
|
|
|
|
it("round-trips normal and preserve-current-UI RSC payloads under separate keys", async () => {
|
|
delete process.env.__VINEXT_BUILD_ID;
|
|
setCacheHandler(new MemoryCacheHandler());
|
|
|
|
const normalKey = appIsrRscKey("/feed");
|
|
const preserveUiKey = appIsrRscKey("/feed", null, APP_RSC_RENDER_MODE_REFRESH_PRESERVE_UI);
|
|
const normalPayload = new TextEncoder().encode("normal-rsc").buffer;
|
|
const preserveUiPayload = new TextEncoder().encode("preserve-ui-rsc").buffer;
|
|
|
|
await isrSet(normalKey, buildAppPageCacheValue("", normalPayload), 60, []);
|
|
await isrSet(preserveUiKey, buildAppPageCacheValue("", preserveUiPayload), 60, []);
|
|
|
|
const normalEntry = await isrGet(normalKey);
|
|
const preserveUiEntry = await isrGet(preserveUiKey);
|
|
|
|
expect(normalEntry?.value.value?.kind).toBe("APP_PAGE");
|
|
expect(preserveUiEntry?.value.value?.kind).toBe("APP_PAGE");
|
|
if (
|
|
normalEntry?.value.value?.kind !== "APP_PAGE" ||
|
|
preserveUiEntry?.value.value?.kind !== "APP_PAGE"
|
|
) {
|
|
throw new Error("Expected App Router page cache entries");
|
|
}
|
|
|
|
expect(new TextDecoder().decode(normalEntry.value.value.rscData)).toBe("normal-rsc");
|
|
expect(new TextDecoder().decode(preserveUiEntry.value.value.rscData)).toBe("preserve-ui-rsc");
|
|
});
|
|
});
|
|
|
|
describe("normalizeMountedSlotsHeader", () => {
|
|
it("returns null for missing or blank mounted-slot headers", () => {
|
|
expect(normalizeMountedSlotsHeader(null)).toBeNull();
|
|
expect(normalizeMountedSlotsHeader(" \t\n ")).toBeNull();
|
|
});
|
|
|
|
it("deduplicates and sorts whitespace-separated slot ids", () => {
|
|
expect(
|
|
normalizeMountedSlotsHeader(" slot:sidebar:/ slot:modal:/ slot:sidebar:/\tslot:cart:/ "),
|
|
).toBe("slot:cart:/ slot:modal:/ slot:sidebar:/");
|
|
});
|
|
});
|
|
|
|
// ─── buildPagesCacheValue ───────────────────────────────────────────────
|
|
|
|
describe("buildPagesCacheValue", () => {
|
|
it("builds correct structure", () => {
|
|
const value = buildPagesCacheValue("<html>test</html>", { title: "Test" });
|
|
expect(value.kind).toBe("PAGES");
|
|
expect(value.html).toBe("<html>test</html>");
|
|
expect(value.pageData).toEqual({ title: "Test" });
|
|
expect(value.headers).toBeUndefined();
|
|
expect(value.status).toBeUndefined();
|
|
});
|
|
|
|
it("includes status when provided", () => {
|
|
const value = buildPagesCacheValue("<html>404</html>", {}, 404);
|
|
expect(value.status).toBe(404);
|
|
});
|
|
});
|
|
|
|
// ─── buildAppPageCacheValue ─────────────────────────────────────────────
|
|
|
|
describe("buildAppPageCacheValue", () => {
|
|
it("builds correct structure", () => {
|
|
const value = buildAppPageCacheValue("<html>app</html>");
|
|
expect(value.kind).toBe("APP_PAGE");
|
|
expect(value.html).toBe("<html>app</html>");
|
|
expect(value.rscData).toBeUndefined();
|
|
expect(value.headers).toBeUndefined();
|
|
expect(value.postponed).toBeUndefined();
|
|
expect(value.status).toBeUndefined();
|
|
});
|
|
|
|
it("includes rscData when provided", () => {
|
|
const rscData = new ArrayBuffer(8);
|
|
const value = buildAppPageCacheValue("<html>app</html>", rscData);
|
|
expect(value.rscData).toBe(rscData);
|
|
});
|
|
|
|
it("includes status when provided", () => {
|
|
const value = buildAppPageCacheValue("<html>app</html>", undefined, 200);
|
|
expect(value.status).toBe(200);
|
|
});
|
|
});
|
|
|
|
// ─── Revalidate duration tracking ───────────────────────────────────────
|
|
|
|
describe("setRevalidateDuration / getRevalidateDuration", () => {
|
|
it("stores and retrieves a duration", () => {
|
|
setRevalidateDuration("test-key-1", 60);
|
|
expect(getRevalidateDuration("test-key-1")).toBe(60);
|
|
});
|
|
|
|
it("returns undefined for unknown keys", () => {
|
|
expect(getRevalidateDuration("nonexistent-key-xyz")).toBeUndefined();
|
|
});
|
|
|
|
it("overwrites previous values", () => {
|
|
setRevalidateDuration("test-key-2", 60);
|
|
setRevalidateDuration("test-key-2", 120);
|
|
expect(getRevalidateDuration("test-key-2")).toBe(120);
|
|
});
|
|
|
|
it("handles zero duration", () => {
|
|
setRevalidateDuration("test-key-3", 0);
|
|
expect(getRevalidateDuration("test-key-3")).toBe(0);
|
|
});
|
|
});
|
|
|
|
// ─── Expire ceiling handling ────────────────────────────────────────────
|
|
|
|
describe("ISR expire ceiling", () => {
|
|
beforeEach(() => {
|
|
vi.useRealTimers();
|
|
setCacheHandler(new MemoryCacheHandler());
|
|
});
|
|
|
|
it("serves stale within expire and treats entries beyond expire as hard misses", async () => {
|
|
vi.useFakeTimers({ toFake: ["Date"] });
|
|
vi.setSystemTime(1_000);
|
|
|
|
await isrSet("expire-test", buildPagesCacheValue("<html>cached</html>", {}), 1, [], 3);
|
|
|
|
vi.setSystemTime(2_500);
|
|
const stale = await isrGet("expire-test");
|
|
expect(stale?.isStale).toBe(true);
|
|
expect(stale?.value.value?.kind).toBe("PAGES");
|
|
|
|
vi.setSystemTime(4_500);
|
|
await expect(isrGet("expire-test")).resolves.toBeNull();
|
|
});
|
|
|
|
it("preserves legacy revalidate context while writing cache-control metadata", async () => {
|
|
let setContext: Record<string, unknown> | undefined;
|
|
setCacheHandler({
|
|
async get() {
|
|
return null;
|
|
},
|
|
async set(_key, _data, ctx) {
|
|
setContext = ctx;
|
|
},
|
|
async revalidateTag() {},
|
|
});
|
|
|
|
await isrSet("compat-test", buildPagesCacheValue("<html>cached</html>", {}), 60, ["tag"], 300);
|
|
|
|
expect(setContext).toEqual({
|
|
cacheControl: { revalidate: 60, expire: 300 },
|
|
revalidate: 60,
|
|
tags: ["tag"],
|
|
});
|
|
});
|
|
|
|
it("treats cache handlers that report expired entries as hard misses", async () => {
|
|
setCacheHandler({
|
|
async get() {
|
|
return {
|
|
lastModified: Date.now() - 10_000,
|
|
cacheState: "expired",
|
|
value: buildPagesCacheValue("<html>expired</html>", {}),
|
|
};
|
|
},
|
|
async set() {},
|
|
async revalidateTag() {},
|
|
});
|
|
|
|
await expect(isrGet("expired-handler-entry")).resolves.toBeNull();
|
|
});
|
|
});
|
|
|
|
// ─── triggerBackgroundRegeneration ───────────────────────────────────────
|
|
|
|
describe("triggerBackgroundRegeneration", () => {
|
|
it("calls the render function", async () => {
|
|
const renderFn = vi.fn().mockResolvedValue(undefined);
|
|
triggerBackgroundRegeneration("regen-test-1", renderFn);
|
|
// Wait for the async operation
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
expect(renderFn).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("deduplicates concurrent regeneration for same key", async () => {
|
|
let resolveFirst: () => void;
|
|
const firstPromise = new Promise<void>((r) => {
|
|
resolveFirst = r;
|
|
});
|
|
const renderFn1 = vi.fn().mockReturnValue(firstPromise);
|
|
const renderFn2 = vi.fn().mockResolvedValue(undefined);
|
|
|
|
triggerBackgroundRegeneration("regen-test-2", renderFn1);
|
|
triggerBackgroundRegeneration("regen-test-2", renderFn2);
|
|
|
|
// Only the first should have been called
|
|
expect(renderFn1).toHaveBeenCalledOnce();
|
|
expect(renderFn2).not.toHaveBeenCalled();
|
|
|
|
// Complete the first
|
|
resolveFirst!();
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
});
|
|
|
|
it("allows regeneration after previous completes", async () => {
|
|
const renderFn1 = vi.fn().mockResolvedValue(undefined);
|
|
triggerBackgroundRegeneration("regen-test-3", renderFn1);
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
expect(renderFn1).toHaveBeenCalledOnce();
|
|
|
|
// After completion, a new regeneration should be allowed
|
|
const renderFn2 = vi.fn().mockResolvedValue(undefined);
|
|
triggerBackgroundRegeneration("regen-test-3", renderFn2);
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
expect(renderFn2).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("handles render function errors gracefully", async () => {
|
|
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
const renderFn = vi.fn().mockRejectedValue(new Error("render failed"));
|
|
|
|
triggerBackgroundRegeneration("regen-test-4", renderFn);
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
|
|
expect(renderFn).toHaveBeenCalledOnce();
|
|
expect(consoleError).toHaveBeenCalled();
|
|
|
|
// After error, key should be cleared so new regeneration is possible
|
|
const renderFn2 = vi.fn().mockResolvedValue(undefined);
|
|
triggerBackgroundRegeneration("regen-test-4", renderFn2);
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
expect(renderFn2).toHaveBeenCalledOnce();
|
|
|
|
consoleError.mockRestore();
|
|
});
|
|
|
|
it("reports error via onRequestError handler when errorContext is provided", async () => {
|
|
const handler = vi.fn();
|
|
globalThis.__VINEXT_onRequestErrorHandler__ = handler;
|
|
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
|
|
try {
|
|
const renderFn = vi.fn().mockRejectedValue(new Error("regen failed"));
|
|
triggerBackgroundRegeneration("regen-report-error", renderFn, {
|
|
routerKind: "App Router",
|
|
routePath: "/blog/[slug]",
|
|
routeType: "render",
|
|
});
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
|
|
expect(handler).toHaveBeenCalledOnce();
|
|
const [error, request, context] = handler.mock.calls[0];
|
|
expect(error).toBeInstanceOf(Error);
|
|
expect(error.message).toBe("regen failed");
|
|
expect(request).toEqual({ path: "regen-report-error", method: "GET", headers: {} });
|
|
expect(context).toEqual({
|
|
routerKind: "App Router",
|
|
routePath: "/blog/[slug]",
|
|
routeType: "render",
|
|
revalidateReason: "stale",
|
|
});
|
|
} finally {
|
|
delete globalThis.__VINEXT_onRequestErrorHandler__;
|
|
consoleError.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("does NOT call onRequestError handler when errorContext is omitted", async () => {
|
|
const handler = vi.fn();
|
|
globalThis.__VINEXT_onRequestErrorHandler__ = handler;
|
|
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
|
|
try {
|
|
const renderFn = vi.fn().mockRejectedValue(new Error("regen failed"));
|
|
triggerBackgroundRegeneration("regen-no-ctx", renderFn);
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
|
|
expect(consoleError).toHaveBeenCalled();
|
|
expect(handler).not.toHaveBeenCalled();
|
|
} finally {
|
|
delete globalThis.__VINEXT_onRequestErrorHandler__;
|
|
consoleError.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("wraps non-Error throw values in Error before reporting", async () => {
|
|
const handler = vi.fn();
|
|
globalThis.__VINEXT_onRequestErrorHandler__ = handler;
|
|
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
|
|
try {
|
|
const renderFn = vi.fn().mockRejectedValue("string error");
|
|
triggerBackgroundRegeneration("regen-string-error", renderFn, {
|
|
routerKind: "Pages Router",
|
|
routePath: "/about",
|
|
routeType: "render",
|
|
});
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
|
|
expect(handler).toHaveBeenCalledOnce();
|
|
const [error] = handler.mock.calls[0];
|
|
expect(error).toBeInstanceOf(Error);
|
|
expect(error.message).toBe("string error");
|
|
} finally {
|
|
delete globalThis.__VINEXT_onRequestErrorHandler__;
|
|
consoleError.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("different keys run independently", async () => {
|
|
const renderFnA = vi.fn().mockResolvedValue(undefined);
|
|
const renderFnB = vi.fn().mockResolvedValue(undefined);
|
|
|
|
triggerBackgroundRegeneration("regen-test-5a", renderFnA);
|
|
triggerBackgroundRegeneration("regen-test-5b", renderFnB);
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
expect(renderFnA).toHaveBeenCalledOnce();
|
|
expect(renderFnB).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("calls ctx.waitUntil with the regen promise when ctx is in ALS", async () => {
|
|
const waitUntil = vi.fn();
|
|
const ctx = { waitUntil };
|
|
|
|
let resolveRender: () => void;
|
|
const renderPromise = new Promise<void>((r) => {
|
|
resolveRender = r;
|
|
});
|
|
const renderFn = vi.fn().mockReturnValue(renderPromise);
|
|
|
|
await runWithExecutionContext(ctx, async () => {
|
|
triggerBackgroundRegeneration("regen-ctx-1", renderFn);
|
|
});
|
|
|
|
expect(waitUntil).toHaveBeenCalledOnce();
|
|
expect(waitUntil).toHaveBeenCalledWith(expect.any(Promise));
|
|
|
|
resolveRender!();
|
|
await renderPromise;
|
|
});
|
|
|
|
it("preserves unified request context for async work started by regeneration", async () => {
|
|
let releaseRender!: () => void;
|
|
const resumeRender = new Promise<void>((resolve) => {
|
|
releaseRender = resolve;
|
|
});
|
|
|
|
let regenPromise: Promise<unknown> | null = null;
|
|
const executionContext = {
|
|
waitUntil(promise: Promise<unknown>) {
|
|
regenPromise = promise;
|
|
},
|
|
};
|
|
|
|
let sawUnifiedScope = false;
|
|
let collectedTags: string[] = [];
|
|
|
|
await runWithExecutionContext(executionContext, async () => {
|
|
await runWithRequestContext(
|
|
createRequestContext({ currentRequestTags: ["outer-tag"] }),
|
|
async () => {
|
|
triggerBackgroundRegeneration("regen-unified-scope", async () => {
|
|
await resumeRender;
|
|
sawUnifiedScope = isInsideUnifiedScope();
|
|
collectedTags = [...getRequestContext().currentRequestTags];
|
|
});
|
|
},
|
|
);
|
|
});
|
|
|
|
expect(isInsideUnifiedScope()).toBe(false);
|
|
if (!regenPromise) {
|
|
throw new Error("expected triggerBackgroundRegeneration to register waitUntil");
|
|
}
|
|
const pendingRegen = regenPromise;
|
|
|
|
releaseRender();
|
|
await Promise.resolve(pendingRegen);
|
|
|
|
expect(sawUnifiedScope).toBe(true);
|
|
expect(collectedTags).toEqual(["outer-tag"]);
|
|
});
|
|
|
|
it("does not require ctx — works without it", async () => {
|
|
const renderFn = vi.fn().mockResolvedValue(undefined);
|
|
// No ctx passed — should not throw
|
|
triggerBackgroundRegeneration("regen-no-ctx", renderFn);
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
expect(renderFn).toHaveBeenCalledOnce();
|
|
});
|
|
});
|
|
|
|
// ─── revalidatePath with type parameter ──────────────────────────────────
|
|
|
|
describe("revalidatePath type parameter", () => {
|
|
let handler: MemoryCacheHandler;
|
|
|
|
function staticRouteSegments(pathname: string): string[] {
|
|
return pathname.split("/").filter(Boolean);
|
|
}
|
|
|
|
/** Helper: store a FETCH cache entry with path + implicit hierarchy tags. */
|
|
async function seedEntry(
|
|
path: string,
|
|
body: string,
|
|
routeSegments = staticRouteSegments(path),
|
|
): Promise<void> {
|
|
const tags = buildPageCacheTags(path, [], routeSegments, "page");
|
|
const value: CachedFetchValue = {
|
|
kind: "FETCH",
|
|
data: { headers: {}, body, url: path },
|
|
tags,
|
|
revalidate: false,
|
|
};
|
|
await handler.set(`entry:${path}`, value, { tags });
|
|
}
|
|
|
|
beforeEach(() => {
|
|
handler = new MemoryCacheHandler();
|
|
setCacheHandler(handler);
|
|
});
|
|
|
|
it("invalidates the layout path AND all child paths when type is 'layout'", async () => {
|
|
await seedEntry("/dashboard", "dashboard-root");
|
|
await seedEntry("/dashboard/settings", "settings");
|
|
await seedEntry("/dashboard/profile", "profile");
|
|
await seedEntry("/about", "about-page");
|
|
|
|
// All four entries should be present before revalidation
|
|
expect(await handler.get("entry:/dashboard")).not.toBeNull();
|
|
expect(await handler.get("entry:/dashboard/settings")).not.toBeNull();
|
|
expect(await handler.get("entry:/dashboard/profile")).not.toBeNull();
|
|
expect(await handler.get("entry:/about")).not.toBeNull();
|
|
|
|
await revalidatePath("/dashboard", "layout");
|
|
|
|
// All three dashboard entries should be invalidated
|
|
expect(await handler.get("entry:/dashboard")).toBeNull();
|
|
expect(await handler.get("entry:/dashboard/settings")).toBeNull();
|
|
expect(await handler.get("entry:/dashboard/profile")).toBeNull();
|
|
|
|
// /about should NOT be invalidated
|
|
expect(await handler.get("entry:/about")).not.toBeNull();
|
|
});
|
|
|
|
it("invalidates only the exact path when type is 'page'", async () => {
|
|
await seedEntry("/about", "about-page");
|
|
await seedEntry("/about/team", "about-team");
|
|
|
|
await revalidatePath("/about", "page");
|
|
|
|
// Only /about should be invalidated
|
|
expect(await handler.get("entry:/about")).toBeNull();
|
|
// /about/team should remain
|
|
expect(await handler.get("entry:/about/team")).not.toBeNull();
|
|
});
|
|
|
|
it("invalidates the exact path when no type is specified", async () => {
|
|
await seedEntry("/about", "about-page");
|
|
await seedEntry("/about/team", "about-team");
|
|
|
|
await revalidatePath("/about");
|
|
|
|
// Only /about should be invalidated
|
|
expect(await handler.get("entry:/about")).toBeNull();
|
|
// /about/team should remain
|
|
expect(await handler.get("entry:/about/team")).not.toBeNull();
|
|
});
|
|
|
|
it("uses route pattern tags for typed dynamic route invalidation", async () => {
|
|
await seedEntry("/blog/hello", "hello", ["blog", "[slug]"]);
|
|
|
|
await revalidatePath("/blog/hello", "layout");
|
|
expect(await handler.get("entry:/blog/hello")).not.toBeNull();
|
|
|
|
await revalidatePath("/blog/[slug]", "layout");
|
|
expect(await handler.get("entry:/blog/hello")).toBeNull();
|
|
|
|
await seedEntry("/blog/hello", "hello", ["blog", "[slug]"]);
|
|
|
|
await revalidatePath("/blog/hello");
|
|
expect(await handler.get("entry:/blog/hello")).toBeNull();
|
|
});
|
|
|
|
it("handles deeply nested children under a layout prefix", async () => {
|
|
await seedEntry("/app", "app-root");
|
|
await seedEntry("/app/blog", "blog");
|
|
await seedEntry("/app/blog/2024", "blog-2024");
|
|
await seedEntry("/app/blog/2024/01/post", "blog-post");
|
|
|
|
await revalidatePath("/app", "layout");
|
|
|
|
// All entries under /app should be invalidated
|
|
expect(await handler.get("entry:/app")).toBeNull();
|
|
expect(await handler.get("entry:/app/blog")).toBeNull();
|
|
expect(await handler.get("entry:/app/blog/2024")).toBeNull();
|
|
expect(await handler.get("entry:/app/blog/2024/01/post")).toBeNull();
|
|
});
|
|
|
|
it("does not invalidate paths that merely share a string prefix", async () => {
|
|
// /dashboard-admin starts with "/dashboard" as a string, but it's NOT
|
|
// a child route of /dashboard — it's a sibling. The prefix match must
|
|
// be path-segment-aware (match "/dashboard/" or exact "/dashboard").
|
|
await seedEntry("/dashboard", "dashboard");
|
|
await seedEntry("/dashboard-admin", "dashboard-admin");
|
|
await seedEntry("/dashboard/settings", "settings");
|
|
|
|
await revalidatePath("/dashboard", "layout");
|
|
|
|
expect(await handler.get("entry:/dashboard")).toBeNull();
|
|
expect(await handler.get("entry:/dashboard/settings")).toBeNull();
|
|
// /dashboard-admin should NOT be invalidated — different route
|
|
expect(await handler.get("entry:/dashboard-admin")).not.toBeNull();
|
|
});
|
|
|
|
it("handles root path '/' with layout type — invalidates everything", async () => {
|
|
await seedEntry("/", "home");
|
|
await seedEntry("/about", "about");
|
|
await seedEntry("/dashboard", "dashboard");
|
|
await seedEntry("/dashboard/settings", "settings");
|
|
|
|
await revalidatePath("/", "layout");
|
|
|
|
// Root layout covers all routes
|
|
expect(await handler.get("entry:/")).toBeNull();
|
|
expect(await handler.get("entry:/about")).toBeNull();
|
|
expect(await handler.get("entry:/dashboard")).toBeNull();
|
|
expect(await handler.get("entry:/dashboard/settings")).toBeNull();
|
|
});
|
|
|
|
it("handles root path '/' with page type — invalidates only the root page", async () => {
|
|
await seedEntry("/", "home");
|
|
await seedEntry("/about", "about");
|
|
|
|
await revalidatePath("/", "page");
|
|
|
|
// Root page should be invalidated
|
|
expect(await handler.get("entry:/")).toBeNull();
|
|
// Other pages should remain — "page" type targets only the exact route
|
|
expect(await handler.get("entry:/about")).not.toBeNull();
|
|
});
|
|
|
|
it("trailing slash on layout path is normalized — same as without trailing slash", async () => {
|
|
await seedEntry("/dashboard", "dashboard-root");
|
|
await seedEntry("/dashboard/settings", "settings");
|
|
await seedEntry("/about", "about-page");
|
|
|
|
// revalidatePath("/dashboard/", "layout") must behave like ("/dashboard", "layout")
|
|
await revalidatePath("/dashboard/", "layout");
|
|
|
|
expect(await handler.get("entry:/dashboard")).toBeNull();
|
|
expect(await handler.get("entry:/dashboard/settings")).toBeNull();
|
|
// /about should NOT be invalidated
|
|
expect(await handler.get("entry:/about")).not.toBeNull();
|
|
});
|
|
|
|
it("type 'page' invalidates via /page tag, not the bare path tag", async () => {
|
|
// Seed two synthetic entries to prove the tag paths are distinct:
|
|
// Entry A: only the /page leaf tag — only revalidatePath(path, "page") should hit it
|
|
// Entry B: only the bare _N_T_ path tag — only revalidatePath(path) should hit it
|
|
const pageOnlyValue: CachedFetchValue = {
|
|
kind: "FETCH",
|
|
data: { headers: {}, body: "page-only", url: "/about" },
|
|
tags: ["_N_T_/about/page"],
|
|
revalidate: false,
|
|
};
|
|
const barePathValue: CachedFetchValue = {
|
|
kind: "FETCH",
|
|
data: { headers: {}, body: "bare-path", url: "/about" },
|
|
tags: ["/about", "_N_T_/about"],
|
|
revalidate: false,
|
|
};
|
|
await handler.set("entry:page-only", pageOnlyValue, { tags: ["_N_T_/about/page"] });
|
|
await handler.set("entry:bare-path", barePathValue, { tags: ["/about", "_N_T_/about"] });
|
|
|
|
await revalidatePath("/about", "page");
|
|
|
|
// "page" type targets the /page leaf tag only
|
|
expect(await handler.get("entry:page-only")).toBeNull();
|
|
// The bare path entry should NOT be touched
|
|
expect(await handler.get("entry:bare-path")).not.toBeNull();
|
|
});
|
|
|
|
it("trailing slash on page path is normalized — same as without trailing slash", async () => {
|
|
await seedEntry("/about", "about-page");
|
|
await seedEntry("/about/team", "about-team");
|
|
|
|
// revalidatePath("/about/", "page") must be equivalent to ("/about", "page")
|
|
await revalidatePath("/about/", "page");
|
|
|
|
expect(await handler.get("entry:/about")).toBeNull();
|
|
// /about/team should remain — only the exact path was invalidated
|
|
expect(await handler.get("entry:/about/team")).not.toBeNull();
|
|
});
|
|
});
|