Fetch cache entries without explicit next.tags could survive revalidatePath(). The page or route cache entry was invalidated, but the regenerated render could still read the old fetch response and reproduce stale data.
The missing boundary was Next.js soft tag semantics: path-derived implicit tags are read-time context for fetch cache lookups, not durable tags stored on the fetch entry.
Thread route-derived soft tags through App Router renders, pass them to fetch cache reads, and teach the memory and KV handlers to treat revalidated soft tags as cache misses without deleting shared fetch entries.
Regression coverage verifies revalidatePath invalidates untagged fetch cache reads and that KV soft-tag misses do not delete the shared entry.
* feat: implement revalidateByPathPrefix on KVCacheHandler
Uses KV list metadata to discover entry tags without extra get() calls.
set() now stores tags in KV metadata, and revalidateByPathPrefix reads
them back via kv.list() — making prefix invalidation O(list_pages)
instead of O(entries × get).
Entries written before metadata support are gracefully skipped.
* fix: guard against KV metadata 1024-byte limit
Cloudflare KV limits metadata to 1024 bytes per key. If an entry's
tags exceed this budget when JSON-serialized, omit metadata entirely
and fall back gracefully — prefix invalidation skips entries without
metadata, and exact-path invalidation via revalidateTag still works.
* test: add regression test for KV metadata 1024-byte overflow
* chore: migrate to vite plus
* Disable typeAware and typeCheck
* Update CI
* Fix CI
* Fix test
* Clean
* Run test with vp
* Try revert
* react: false In test
* Fix test
* Revert "Try revert"
This reverts commit 009da10473.
* Update
* Update
* Try revert ci changes
* revert
* Run vp migrate
* Disable typeAware and typeCheck for now
* Better resolve for test
* Use vp dev instead of vite
* Update expect
* Fix NormalizeManifestModuleId
* Try increase timeout
* Update to use vp
* Try new check
* Bring back npx vp
* Migrate CI
* Make next-intl resolvable
* Update
* Update
* Update
* fix: MemoryCacheHandler revalidate:0 creates immediately-stale entries
MemoryCacheHandler.set had an inconsistent guard on data.revalidate:
the ctx path checked `revalidate > 0` but the data path only checked
`typeof data.revalidate === "number"`, allowing revalidate:0 to set
revalidateAt = Date.now() — making entries immediately stale.
This was inconsistent with KVCacheHandler which already had the > 0
guard on both paths. The two handlers would produce different staleness
behavior for the same input: Memory → immediately stale, KV → no TTL.
Add the missing `> 0` guard to align both code paths and both handlers.
* fix: revalidate:0 should skip cache storage entirely
Both MemoryCacheHandler and KVCacheHandler had inconsistent handling of
revalidate:0. In Next.js, revalidate:0 means "don't cache." But the
handlers would still store entries — Memory made them immediately stale
(data path lacked > 0 guard), KV cached them forever (both paths
rejected 0, so revalidateAt=null meant no expiry).
Resolve effective revalidate from both ctx and data paths (data
overrides ctx), and early-return without storing when it's 0. Both
handlers now use identical logic.
Fix existing test data that incidentally used data.revalidate:0 in
tests meant to exercise stale-while-revalidate and tag invalidation.
* perf: cache startup filesystem scans, optimize base64 and path normalization
- Cache hasMdxFiles() result per root directory to avoid redundant
recursive filesystem walks when config() fires per Vite environment
- Cache resolvePostcssStringPlugins() result per project root to skip
repeated existsSync checks across 17 PostCSS config file candidates
- Replace byte-by-byte base64 encode/decode in KV cache handler with
Buffer APIs for significantly faster serialization
- Pass pre-normalized URL to nodeToWebRequest() in App Router prod
server to eliminate redundant path normalization in the RSC handler
* fix: validate base64 structural length and improve test spy cleanup
Add length % 4 check to base64ToArrayBuffer to reject structurally
invalid base64 strings that pass the character-set regex but produce
empty/truncated buffers via Buffer.from(). Replace manual mockRestore()
calls with afterEach(vi.restoreAllMocks) to prevent spy leaks on
assertion failure.
* fix: address bonk review comments — composite mdx cache key, postcss comment order, hoist test import
* fix: cache Promise in resolvePostcssStringPlugins to prevent concurrent scan race; add POST urlOverride test
* fmt
---------
Co-authored-by: James <james@eli.cx>
* perf(kv): add local in-memory tag cache to reduce KV round-trips
When KVCacheHandler.get() validates tags, it was issuing one kv.get()
per tag in parallel. For entries with N tags, that's N KV round-trips
per cache hit. Same pattern in revalidateTag() — N parallel PUTs.
Add a local Map<string, { timestamp, fetchedAt }> with a 5-second TTL
that caches tag invalidation timestamps. Within the TTL window, tag
checks are served from memory with zero I/O. After TTL expiry, the
next request re-fetches from KV.
Key behaviors:
- revalidateTag() updates the local cache immediately so invalidations
are reflected without waiting for TTL expiry
- resetRequestCache() clears the local cache for per-request isolation
- NaN tag timestamps are cached and correctly treated as invalidation
- Only uncached/expired tags trigger KV reads (partial cache hits work)
* fix(test): guard fake timers with try/finally to prevent leaks
Wrap vi.useFakeTimers() in try/finally so vi.useRealTimers() runs even
if an assertion fails mid-test, preventing timer leaks into subsequent
tests.
* fix(kv): address bonk review comments on local tag cache
- Fix early return bug: split KV tag fetch loop into populate-cache pass
then invalidation-check pass, so already-fetched tag results are always
cached even when an earlier tag triggers an early return
- Fix unbounded _tagCache growth: delete stale entries when encountered
during the TTL check (cheap eviction instead of accumulation forever)
- Make tag cache TTL configurable via tagCacheTtlMs constructor option
(default 5000ms); eliminates fake timers in TTL test
- Clarify resetRequestCache() docstring: it is not called per-request by
vinext, it is an opt-in escape hatch for callers that need explicit isolation
- Add resetRequestCache() test: verifies tags are re-fetched from KV after
calling resetRequestCache()
* refactor(kv): reuse cached timestamps in invalidation check loop
The second pass over uncachedTags was re-calling Number(tagTime) and
re-reading tagResults[i]. Now it reads directly from _tagCache (which was
just populated in the first pass), eliminating the duplicate number
conversion and making the intent clearer.
---------
Co-authored-by: James <james@eli.cx>
* fix(isr): KVCacheHandler.set() now awaits KV put to prevent perpetual STALE
Background ISR regen was calling ctx.waitUntil(renderFn()) where renderFn()
resolved before the KV put network operation completed. set() returned
Promise.resolve() immediately, so await __isrSet() in the regen resolved early,
the waitUntil expired, and the Workers runtime killed the in-flight KV write —
leaving the cache entry perpetually STALE on every request after the TTL.
Fix: replace the fire-and-forget _putInBackground() with _put() which returns
the actual kv.put() promise. set() now returns that promise, so await __isrSet()
only resolves after the write is fully persisted. ctx.waitUntil() is also still
registered as belt-and-suspenders.
Adds three regression tests covering the STALE → regen → HIT lifecycle with a
delayed KV put mock to prove the set() promise is not an immediately-resolved stub.
* fix: remove unused variable flagged by lint
* fix: register background KV ops and ISR regen with ctx.waitUntil on Workers
On Cloudflare Workers, any async work started after a Response is returned
is killed immediately. This caused three silent failures:
1. ISR background regeneration (stale-while-revalidate) was never
registered with ctx.waitUntil, so every revalidation on Workers was
silently dropped.
2. KV deletes for corrupt/invalid/tag-invalidated cache entries were
awaited on the critical path, blocking the response for pure cleanup work.
3. KV writes in KVCacheHandler.set() were awaited on the critical path;
the client already has the rendered content so the write can happen
after the response is sent.
Fixes:
- Thread ExecutionContext through triggerBackgroundRegeneration and call
ctx.waitUntil(regenPromise) when ctx is present (isr-cache.ts and the
inlined copy in pages-server-entry.ts).
- Add _deleteInBackground / _putInBackground helpers to KVCacheHandler
that use ctx.waitUntil when ctx is present and fall back to
fire-and-forget on Node.js.
- Convert all three delete callsites (corrupt JSON, invalid shape,
tag-invalidation) and the set() KV write to use the new helpers.
- All changes are backward-compatible: ctx is optional everywhere;
Node.js dev/prod behaviour is unchanged.
* regen snaps
* add oxfmt formatter: config, scripts, CI, editor setup, docs
* rebuild lockfile
* fix: add Format to required checks list, remove dead ignore pattern
* run fmt
* add format to agents.md again