diff --git a/packages/vinext/src/cloudflare/kv-cache-handler.ts b/packages/vinext/src/cloudflare/kv-cache-handler.ts index cd1907d83..0995f274f 100644 --- a/packages/vinext/src/cloudflare/kv-cache-handler.ts +++ b/packages/vinext/src/cloudflare/kv-cache-handler.ts @@ -118,6 +118,24 @@ function validateTag(tag: string): string | null { return tag; } +function readStringArrayField(ctx: Record | undefined, field: string): string[] { + const value = ctx?.[field]; + if (!Array.isArray(value)) return []; + return value.filter((item): item is string => typeof item === "string"); +} + +function validUniqueTags(tags: string[]): string[] { + const result: string[] = []; + const seen = new Set(); + for (const tag of tags) { + const validTag = validateTag(tag); + if (!validTag || seen.has(validTag)) continue; + seen.add(validTag); + result.push(validTag); + } + return result; +} + /** * Segment-aware path prefix check. Returns true if `path` is equal to * `prefix` or is a child route (next char after prefix is `/`). @@ -192,59 +210,14 @@ export class KVCacheHandler implements CacheHandler { } } - // Check tag-based invalidation. - // Uses a local in-memory cache to avoid redundant KV reads for recently-seen tags. - if (entry.tags.length > 0) { - const now = Date.now(); - const uncachedTags: string[] = []; - - // First pass: check local cache for each tag. - // Delete expired entries to prevent unbounded Map growth in long-lived isolates. - for (const tag of entry.tags) { - const cached = this._tagCache.get(tag); - if (cached && now - cached.fetchedAt < this._tagCacheTtl) { - // Local cache hit — check invalidation inline - if (Number.isNaN(cached.timestamp) || cached.timestamp >= entry.lastModified) { - this._deleteInBackground(kvKey); - return null; - } - } else { - // Expired or absent — evict stale entry and re-fetch from KV - if (cached) this._tagCache.delete(tag); - uncachedTags.push(tag); - } - } - - // Second pass: fetch uncached tags from KV in parallel. - // Populate the local cache for ALL fetched tags before checking invalidation, - // so that KV round-trips are not wasted when an earlier tag triggers an - // early return — subsequent get() calls benefit from the already-fetched results. - if (uncachedTags.length > 0) { - const tagResults = await Promise.all( - uncachedTags.map((tag) => this.kv.get(this.prefix + TAG_PREFIX + tag)), - ); - - // Populate cache for all results first, then check for invalidation. - // Two-loop structure ensures all tag results are cached even when an - // earlier tag would cause an early return — so subsequent get() calls - // for entries sharing those tags don't redundantly re-fetch from KV. - for (let i = 0; i < uncachedTags.length; i++) { - const tagTime = tagResults[i]; - const tagTimestamp = tagTime ? Number(tagTime) : 0; - this._tagCache.set(uncachedTags[i], { timestamp: tagTimestamp, fetchedAt: now }); - } + if (await this._hasRevalidatedTag(validUniqueTags(entry.tags), entry.lastModified)) { + this._deleteInBackground(kvKey); + return null; + } - // Then check for invalidation using the now-cached timestamps - for (const tag of uncachedTags) { - const cached = this._tagCache.get(tag)!; - if (cached.timestamp !== 0) { - if (Number.isNaN(cached.timestamp) || cached.timestamp >= entry.lastModified) { - this._deleteInBackground(kvKey); - return null; - } - } - } - } + const softTags = validUniqueTags(readStringArrayField(_ctx, "softTags")); + if (await this._hasRevalidatedTag(softTags, entry.lastModified)) { + return null; } // Check time-based expiry — return stale with cacheState @@ -262,6 +235,58 @@ export class KVCacheHandler implements CacheHandler { }; } + /** + * Check tag invalidation markers for stored tags or read-time soft tags. + * Uses a local in-memory cache to avoid redundant KV reads for recently-seen tags. + */ + private async _hasRevalidatedTag(tags: string[], lastModified: number): Promise { + if (tags.length === 0) return false; + + const now = Date.now(); + const uncachedTags: string[] = []; + + // First pass: check local cache for each tag. + // Delete expired entries to prevent unbounded Map growth in long-lived isolates. + for (const tag of tags) { + const cached = this._tagCache.get(tag); + if (cached && now - cached.fetchedAt < this._tagCacheTtl) { + // Local cache hit — check invalidation inline + if (Number.isNaN(cached.timestamp) || cached.timestamp >= lastModified) { + return true; + } + } else { + // Expired or absent — evict stale entry and re-fetch from KV + if (cached) this._tagCache.delete(tag); + uncachedTags.push(tag); + } + } + + // Second pass: fetch uncached tags from KV in parallel. + // Populate the local cache for ALL fetched tags before checking invalidation, + // so subsequent get() calls benefit from the already-fetched results. + if (uncachedTags.length > 0) { + const tagResults = await Promise.all( + uncachedTags.map((tag) => this.kv.get(this.prefix + TAG_PREFIX + tag)), + ); + + for (let i = 0; i < uncachedTags.length; i++) { + const tagTime = tagResults[i]; + const tagTimestamp = tagTime ? Number(tagTime) : 0; + this._tagCache.set(uncachedTags[i], { timestamp: tagTimestamp, fetchedAt: now }); + } + + for (const tag of uncachedTags) { + const cached = this._tagCache.get(tag); + if (!cached || cached.timestamp === 0) continue; + if (Number.isNaN(cached.timestamp) || cached.timestamp >= lastModified) { + return true; + } + } + } + + return false; + } + set( key: string, data: IncrementalCacheValue | null, diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts index 55788f3ea..44f2673ef 100644 --- a/packages/vinext/src/entries/app-rsc-entry.ts +++ b/packages/vinext/src/entries/app-rsc-entry.ts @@ -425,7 +425,7 @@ import { } from ${JSON.stringify(appRouteHandlerResponsePath)}; import { _consumeRequestScopedCacheLife, getCacheHandler } from "next/cache"; import { getRequestExecutionContext as _getRequestExecutionContext } from ${JSON.stringify(requestContextShimPath)}; -import { ensureFetchPatch as _ensureFetchPatch, getCollectedFetchTags } from "vinext/fetch-cache"; +import { ensureFetchPatch as _ensureFetchPatch, getCollectedFetchTags, setCurrentFetchSoftTags } from "vinext/fetch-cache"; import { buildRouteTrie as _buildRouteTrie, trieMatch as _trieMatch } from ${JSON.stringify(routeTriePath)}; // Import server-only state module to register ALS-backed accessors. import "vinext/navigation-state"; @@ -2092,6 +2092,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } const { route, params } = match; + setCurrentFetchSoftTags(__pageCacheTags(cleanPathname, [])); // Update navigation context with matched params setNavigationContext({ @@ -2178,6 +2179,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { }); await _runWithUnifiedCtx(__revalUCtx, async () => { _ensureFetchPatch(); + setCurrentFetchSoftTags(__pageCacheTags(cleanPathname, [])); await renderFn(); }); }, @@ -2328,6 +2330,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { }); return _runWithUnifiedCtx(__revalUCtx, async () => { _ensureFetchPatch(); + setCurrentFetchSoftTags(__pageCacheTags(cleanPathname, [])); setNavigationContext({ pathname: cleanPathname, searchParams: new URLSearchParams(), params }); // Slot context (X-Vinext-Mounted-Slots) is inherited from the // triggering request so the regen result is cached under the diff --git a/packages/vinext/src/shims/cache.ts b/packages/vinext/src/shims/cache.ts index c05b9839f..eb6dd5b42 100644 --- a/packages/vinext/src/shims/cache.ts +++ b/packages/vinext/src/shims/cache.ts @@ -176,6 +176,12 @@ type MemoryEntry = { revalidateAt: number | null; }; +function readStringArrayField(ctx: Record | undefined, field: string): string[] { + const value = ctx?.[field]; + if (!Array.isArray(value)) return []; + return value.filter((item): item is string => typeof item === "string"); +} + /** * Shape of the optional `ctx` argument passed to `CacheHandler.set()`. * Covers both the older `{ revalidate: number }` shape and the newer @@ -208,6 +214,13 @@ export class MemoryCacheHandler implements CacheHandler { } } + for (const tag of readStringArrayField(_ctx, "softTags")) { + const revalidatedAt = this.tagRevalidatedAt.get(tag); + if (revalidatedAt && revalidatedAt >= entry.lastModified) { + return null; + } + } + // Check time-based expiry — return stale entry with cacheState="stale" // instead of deleting, so ISR can serve stale-while-revalidate if (entry.revalidateAt !== null && Date.now() > entry.revalidateAt) { diff --git a/packages/vinext/src/shims/fetch-cache.ts b/packages/vinext/src/shims/fetch-cache.ts index 6ccf73c38..65935fa6b 100644 --- a/packages/vinext/src/shims/fetch-cache.ts +++ b/packages/vinext/src/shims/fetch-cache.ts @@ -468,6 +468,7 @@ const originalFetch: typeof globalThis.fetch = (_gFetch[_ORIG_FETCH_KEY] ??= // --------------------------------------------------------------------------- export type FetchCacheState = { currentRequestTags: string[]; + currentFetchSoftTags: string[]; }; const _ALS_KEY = Symbol.for("vinext.fetchCache.als"); @@ -478,6 +479,7 @@ const _als = (_g[_ALS_KEY] ??= const _fallbackState = (_g[_FALLBACK_KEY] ??= { currentRequestTags: [], + currentFetchSoftTags: [], } satisfies FetchCacheState) as FetchCacheState; function _getState(): FetchCacheState { @@ -493,6 +495,7 @@ function _getState(): FetchCacheState { */ function _resetFallbackState(): void { _fallbackState.currentRequestTags = []; + _fallbackState.currentFetchSoftTags = []; } /** @@ -504,6 +507,17 @@ export function getCollectedFetchTags(): string[] { return [..._getState().currentRequestTags]; } +/** + * Set path-derived implicit tags for fetch cache reads in the current render. + * + * These are intentionally not persisted on fetch entries. They mirror Next.js + * `softTags`: `revalidatePath()` should make a fetch miss while rendering the + * affected route, without permanently coupling a shared fetch entry to one path. + */ +export function setCurrentFetchSoftTags(tags: string[]): void { + _getState().currentFetchSoftTags = [...tags]; +} + /** * Create a patched fetch function with Next.js caching semantics. * @@ -586,6 +600,7 @@ function createPatchedFetch(): typeof globalThis.fetch { } const tags = nextOpts?.tags ?? []; + const softTags = _getState().currentFetchSoftTags; let cacheKey: string; try { cacheKey = await buildFetchCacheKey(input, init); @@ -613,7 +628,7 @@ function createPatchedFetch(): typeof globalThis.fetch { // Try cache first try { - const cached = await handler.get(cacheKey, { kind: "FETCH", tags }); + const cached = await handler.get(cacheKey, { kind: "FETCH", tags, softTags }); if (cached?.value && cached.value.kind === "FETCH" && cached.cacheState !== "stale") { const cachedData = cached.value.data; // Reconstruct a Response from the cached data @@ -826,9 +841,10 @@ export async function runWithFetchCache(fn: () => Promise): Promise { if (isInsideUnifiedScope()) { return await runWithUnifiedStateMutation((uCtx) => { uCtx.currentRequestTags = []; + uCtx.currentFetchSoftTags = []; }, fn); } - return _als.run({ currentRequestTags: [] }, fn); + return _als.run({ currentRequestTags: [], currentFetchSoftTags: [] }, fn); } /** diff --git a/packages/vinext/src/shims/unified-request-context.ts b/packages/vinext/src/shims/unified-request-context.ts index 42b4dda7f..223c3d112 100644 --- a/packages/vinext/src/shims/unified-request-context.ts +++ b/packages/vinext/src/shims/unified-request-context.ts @@ -94,6 +94,7 @@ export function createRequestContext(opts?: Partial): Uni requestScopedCacheLife: null, _privateCache: null, currentRequestTags: [], + currentFetchSoftTags: [], executionContext: _getInheritedExecutionContext(), // inherits from standalone ALS if present requestCache: new WeakMap(), ssrContext: null, diff --git a/tests/__snapshots__/entry-templates.test.ts.snap b/tests/__snapshots__/entry-templates.test.ts.snap index b8090264d..3455873e3 100644 --- a/tests/__snapshots__/entry-templates.test.ts.snap +++ b/tests/__snapshots__/entry-templates.test.ts.snap @@ -105,7 +105,7 @@ import { } from "/packages/vinext/src/server/app-route-handler-response.js"; import { _consumeRequestScopedCacheLife, getCacheHandler } from "next/cache"; import { getRequestExecutionContext as _getRequestExecutionContext } from "/packages/vinext/src/shims/request-context.js"; -import { ensureFetchPatch as _ensureFetchPatch, getCollectedFetchTags } from "vinext/fetch-cache"; +import { ensureFetchPatch as _ensureFetchPatch, getCollectedFetchTags, setCurrentFetchSoftTags } from "vinext/fetch-cache"; import { buildRouteTrie as _buildRouteTrie, trieMatch as _trieMatch } from "/packages/vinext/src/routing/route-trie.js"; // Import server-only state module to register ALS-backed accessors. import "vinext/navigation-state"; @@ -1770,6 +1770,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } const { route, params } = match; + setCurrentFetchSoftTags(__pageCacheTags(cleanPathname, [])); // Update navigation context with matched params setNavigationContext({ @@ -1856,6 +1857,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { }); await _runWithUnifiedCtx(__revalUCtx, async () => { _ensureFetchPatch(); + setCurrentFetchSoftTags(__pageCacheTags(cleanPathname, [])); await renderFn(); }); }, @@ -2006,6 +2008,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { }); return _runWithUnifiedCtx(__revalUCtx, async () => { _ensureFetchPatch(); + setCurrentFetchSoftTags(__pageCacheTags(cleanPathname, [])); setNavigationContext({ pathname: cleanPathname, searchParams: new URLSearchParams(), params }); // Slot context (X-Vinext-Mounted-Slots) is inherited from the // triggering request so the regen result is cached under the @@ -2466,7 +2469,7 @@ import { } from "/packages/vinext/src/server/app-route-handler-response.js"; import { _consumeRequestScopedCacheLife, getCacheHandler } from "next/cache"; import { getRequestExecutionContext as _getRequestExecutionContext } from "/packages/vinext/src/shims/request-context.js"; -import { ensureFetchPatch as _ensureFetchPatch, getCollectedFetchTags } from "vinext/fetch-cache"; +import { ensureFetchPatch as _ensureFetchPatch, getCollectedFetchTags, setCurrentFetchSoftTags } from "vinext/fetch-cache"; import { buildRouteTrie as _buildRouteTrie, trieMatch as _trieMatch } from "/packages/vinext/src/routing/route-trie.js"; // Import server-only state module to register ALS-backed accessors. import "vinext/navigation-state"; @@ -4137,6 +4140,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } const { route, params } = match; + setCurrentFetchSoftTags(__pageCacheTags(cleanPathname, [])); // Update navigation context with matched params setNavigationContext({ @@ -4223,6 +4227,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { }); await _runWithUnifiedCtx(__revalUCtx, async () => { _ensureFetchPatch(); + setCurrentFetchSoftTags(__pageCacheTags(cleanPathname, [])); await renderFn(); }); }, @@ -4373,6 +4378,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { }); return _runWithUnifiedCtx(__revalUCtx, async () => { _ensureFetchPatch(); + setCurrentFetchSoftTags(__pageCacheTags(cleanPathname, [])); setNavigationContext({ pathname: cleanPathname, searchParams: new URLSearchParams(), params }); // Slot context (X-Vinext-Mounted-Slots) is inherited from the // triggering request so the regen result is cached under the @@ -4833,7 +4839,7 @@ import { } from "/packages/vinext/src/server/app-route-handler-response.js"; import { _consumeRequestScopedCacheLife, getCacheHandler } from "next/cache"; import { getRequestExecutionContext as _getRequestExecutionContext } from "/packages/vinext/src/shims/request-context.js"; -import { ensureFetchPatch as _ensureFetchPatch, getCollectedFetchTags } from "vinext/fetch-cache"; +import { ensureFetchPatch as _ensureFetchPatch, getCollectedFetchTags, setCurrentFetchSoftTags } from "vinext/fetch-cache"; import { buildRouteTrie as _buildRouteTrie, trieMatch as _trieMatch } from "/packages/vinext/src/routing/route-trie.js"; // Import server-only state module to register ALS-backed accessors. import "vinext/navigation-state"; @@ -6499,6 +6505,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } const { route, params } = match; + setCurrentFetchSoftTags(__pageCacheTags(cleanPathname, [])); // Update navigation context with matched params setNavigationContext({ @@ -6585,6 +6592,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { }); await _runWithUnifiedCtx(__revalUCtx, async () => { _ensureFetchPatch(); + setCurrentFetchSoftTags(__pageCacheTags(cleanPathname, [])); await renderFn(); }); }, @@ -6735,6 +6743,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { }); return _runWithUnifiedCtx(__revalUCtx, async () => { _ensureFetchPatch(); + setCurrentFetchSoftTags(__pageCacheTags(cleanPathname, [])); setNavigationContext({ pathname: cleanPathname, searchParams: new URLSearchParams(), params }); // Slot context (X-Vinext-Mounted-Slots) is inherited from the // triggering request so the regen result is cached under the @@ -7195,7 +7204,7 @@ import { } from "/packages/vinext/src/server/app-route-handler-response.js"; import { _consumeRequestScopedCacheLife, getCacheHandler } from "next/cache"; import { getRequestExecutionContext as _getRequestExecutionContext } from "/packages/vinext/src/shims/request-context.js"; -import { ensureFetchPatch as _ensureFetchPatch, getCollectedFetchTags } from "vinext/fetch-cache"; +import { ensureFetchPatch as _ensureFetchPatch, getCollectedFetchTags, setCurrentFetchSoftTags } from "vinext/fetch-cache"; import { buildRouteTrie as _buildRouteTrie, trieMatch as _trieMatch } from "/packages/vinext/src/routing/route-trie.js"; // Import server-only state module to register ALS-backed accessors. import "vinext/navigation-state"; @@ -8893,6 +8902,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } const { route, params } = match; + setCurrentFetchSoftTags(__pageCacheTags(cleanPathname, [])); // Update navigation context with matched params setNavigationContext({ @@ -8979,6 +8989,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { }); await _runWithUnifiedCtx(__revalUCtx, async () => { _ensureFetchPatch(); + setCurrentFetchSoftTags(__pageCacheTags(cleanPathname, [])); await renderFn(); }); }, @@ -9129,6 +9140,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { }); return _runWithUnifiedCtx(__revalUCtx, async () => { _ensureFetchPatch(); + setCurrentFetchSoftTags(__pageCacheTags(cleanPathname, [])); setNavigationContext({ pathname: cleanPathname, searchParams: new URLSearchParams(), params }); // Slot context (X-Vinext-Mounted-Slots) is inherited from the // triggering request so the regen result is cached under the @@ -9589,7 +9601,7 @@ import { } from "/packages/vinext/src/server/app-route-handler-response.js"; import { _consumeRequestScopedCacheLife, getCacheHandler } from "next/cache"; import { getRequestExecutionContext as _getRequestExecutionContext } from "/packages/vinext/src/shims/request-context.js"; -import { ensureFetchPatch as _ensureFetchPatch, getCollectedFetchTags } from "vinext/fetch-cache"; +import { ensureFetchPatch as _ensureFetchPatch, getCollectedFetchTags, setCurrentFetchSoftTags } from "vinext/fetch-cache"; import { buildRouteTrie as _buildRouteTrie, trieMatch as _trieMatch } from "/packages/vinext/src/routing/route-trie.js"; // Import server-only state module to register ALS-backed accessors. import "vinext/navigation-state"; @@ -11261,6 +11273,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } const { route, params } = match; + setCurrentFetchSoftTags(__pageCacheTags(cleanPathname, [])); // Update navigation context with matched params setNavigationContext({ @@ -11347,6 +11360,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { }); await _runWithUnifiedCtx(__revalUCtx, async () => { _ensureFetchPatch(); + setCurrentFetchSoftTags(__pageCacheTags(cleanPathname, [])); await renderFn(); }); }, @@ -11497,6 +11511,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { }); return _runWithUnifiedCtx(__revalUCtx, async () => { _ensureFetchPatch(); + setCurrentFetchSoftTags(__pageCacheTags(cleanPathname, [])); setNavigationContext({ pathname: cleanPathname, searchParams: new URLSearchParams(), params }); // Slot context (X-Vinext-Mounted-Slots) is inherited from the // triggering request so the regen result is cached under the @@ -11957,7 +11972,7 @@ import { } from "/packages/vinext/src/server/app-route-handler-response.js"; import { _consumeRequestScopedCacheLife, getCacheHandler } from "next/cache"; import { getRequestExecutionContext as _getRequestExecutionContext } from "/packages/vinext/src/shims/request-context.js"; -import { ensureFetchPatch as _ensureFetchPatch, getCollectedFetchTags } from "vinext/fetch-cache"; +import { ensureFetchPatch as _ensureFetchPatch, getCollectedFetchTags, setCurrentFetchSoftTags } from "vinext/fetch-cache"; import { buildRouteTrie as _buildRouteTrie, trieMatch as _trieMatch } from "/packages/vinext/src/routing/route-trie.js"; // Import server-only state module to register ALS-backed accessors. import "vinext/navigation-state"; @@ -13989,6 +14004,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } const { route, params } = match; + setCurrentFetchSoftTags(__pageCacheTags(cleanPathname, [])); // Update navigation context with matched params setNavigationContext({ @@ -14075,6 +14091,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { }); await _runWithUnifiedCtx(__revalUCtx, async () => { _ensureFetchPatch(); + setCurrentFetchSoftTags(__pageCacheTags(cleanPathname, [])); await renderFn(); }); }, @@ -14225,6 +14242,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { }); return _runWithUnifiedCtx(__revalUCtx, async () => { _ensureFetchPatch(); + setCurrentFetchSoftTags(__pageCacheTags(cleanPathname, [])); setNavigationContext({ pathname: cleanPathname, searchParams: new URLSearchParams(), params }); // Slot context (X-Vinext-Mounted-Slots) is inherited from the // triggering request so the regen result is cached under the diff --git a/tests/fetch-cache.test.ts b/tests/fetch-cache.test.ts index a77c06bc6..5e7a3da04 100644 --- a/tests/fetch-cache.test.ts +++ b/tests/fetch-cache.test.ts @@ -37,10 +37,11 @@ const { withFetchCache, runWithFetchCache, getCollectedFetchTags, + setCurrentFetchSoftTags, getOriginalFetch, _resetPendingRefetches, } = await import("../packages/vinext/src/shims/fetch-cache.js"); -const { getCacheHandler, revalidateTag, MemoryCacheHandler, setCacheHandler } = +const { getCacheHandler, revalidatePath, revalidateTag, MemoryCacheHandler, setCacheHandler } = await import("../packages/vinext/src/shims/cache.js"); const { runWithExecutionContext } = await import("../packages/vinext/src/shims/request-context.js"); const { createRequestContext, runWithRequestContext } = @@ -772,6 +773,26 @@ describe("fetch cache shim", () => { expect(tags.filter((t) => t === "data")).toHaveLength(1); }); + it("revalidatePath invalidates fetch cache through current render soft tags", async () => { + setCurrentFetchSoftTags(["_N_T_/posts/hello"]); + + const res1 = await fetch("https://api.example.com/path-soft-tag", { + next: { revalidate: 3600 }, + }); + const data1 = await res1.json(); + expect(data1.count).toBe(1); + + await revalidatePath("/posts/hello"); + + const res2 = await fetch("https://api.example.com/path-soft-tag", { + next: { revalidate: 3600 }, + }); + const data2 = await res2.json(); + + expect(data2.count).toBe(2); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + // ── Only caches successful responses ──────────────────────────────── it("does not cache non-2xx responses", async () => { diff --git a/tests/kv-cache-handler.test.ts b/tests/kv-cache-handler.test.ts index 2f7ff30f3..90e05f1b7 100644 --- a/tests/kv-cache-handler.test.ts +++ b/tests/kv-cache-handler.test.ts @@ -452,6 +452,62 @@ describe("KVCacheHandler", () => { expect(result).toBeNull(); expect(kv.delete).toHaveBeenCalledWith("cache:app-page"); }); + + it("softTags invalidate FETCH reads without deleting the shared entry", async () => { + store.set( + "cache:fetch-entry", + JSON.stringify({ + value: { + kind: "FETCH", + data: { headers: {}, body: "cached", url: "https://example.test/data" }, + revalidate: 3600, + }, + tags: [], + lastModified: 1000, + revalidateAt: null, + }), + ); + store.set("__tag:_N_T_/posts/hello", "2000"); + + const withoutSoftTags = await handler.get("fetch-entry", { kind: "FETCH", tags: [] }); + const withSoftTags = await handler.get("fetch-entry", { + kind: "FETCH", + tags: [], + softTags: ["_N_T_/posts/hello"], + }); + + expect(withoutSoftTags).not.toBeNull(); + expect(withSoftTags).toBeNull(); + expect(kv.delete).not.toHaveBeenCalledWith("cache:fetch-entry"); + }); + + it("validates and dedupes softTags before reading KV tag markers", async () => { + store.set( + "cache:fetch-entry", + JSON.stringify({ + value: { + kind: "FETCH", + data: { headers: {}, body: "cached", url: "https://example.test/data" }, + revalidate: 3600, + }, + tags: [], + lastModified: 1000, + revalidateAt: null, + }), + ); + + const result = await handler.get("fetch-entry", { + kind: "FETCH", + softTags: ["_N_T_/posts/hello", "_N_T_/posts/hello", "bad:tag", ""], + }); + + expect(result).not.toBeNull(); + expect(kv.get).toHaveBeenCalledWith("cache:fetch-entry"); + expect(kv.get).toHaveBeenCalledWith("__tag:_N_T_/posts/hello"); + expect(kv.get).not.toHaveBeenCalledWith("__tag:bad:tag"); + expect(kv.get).not.toHaveBeenCalledWith("__tag:"); + expect(kv.get).toHaveBeenCalledTimes(2); + }); }); // -------------------------------------------------------------------------