feat: ISR caching for App Router (production-only, stale-while-revalidate)#405
Draft
james-elicx wants to merge 13 commits intomainfrom
Draft
feat: ISR caching for App Router (production-only, stale-while-revalidate)#405james-elicx wants to merge 13 commits intomainfrom
james-elicx wants to merge 13 commits intomainfrom
Conversation
commit: |
|
…validate) - Inline ISR helpers (__isrGet, __isrSet, __triggerBackgroundRegeneration, __isrCacheKey, __pendingRegenerations) in the generated RSC entry - Cache READ fires before buildPageElement for prod pages with revalidate > 0, returning cached HTML/headers without loading component modules on HIT/STALE - Cache WRITE (+ X-Vinext-Cache: MISS header) is guarded by process.env.NODE_ENV === 'production' so dev never reads/writes the cache - Background regenration is deduplicated via __pendingRegenerations Map and registered with ctx.waitUntil for Cloudflare Workers - Thread ExecutionContext (ctx) from app-router-entry.ts through to _handleRequest - Dev mode still emits correct Cache-Control headers based on export const revalidate - Add integration tests: Cache-Control in dev, no X-Vinext-Cache in dev, RSC requests bypass ISR, pages without revalidate emit no ISR headers - Add 10 code-generation tests verifying the prod guard, helpers, and structure
…ts from cache Previously the ISR cache only stored HTML and excluded all RSC requests from cache reads/writes. This caused two bugs: 1. RSC requests (client-side navigation, link prefetch) always triggered a full component tree render even when a fresh cache entry existed, wasting CPU on every soft-nav and prefetch. 2. rscData was never captured — the cache entry had rscData: undefined — so there was no way to serve RSC requests from cache even if we wanted to. Fixes: - Tee the RSC stream before SSR on ISR-eligible HTML requests (prod + revalidate > 0) to capture the RSC wire format as an ArrayBuffer stored in rscData - Background regen also tees its RSC stream to keep rscData fresh - Remove the !isRscRequest guard from the ISR READ block: both HTML requests and RSC requests now hit the cache. HTML requests get cached html; RSC requests get cached rscData (falling through to render if rscData is absent) - HIT and STALE paths branch on isRscRequest: RSC responses get Content-Type: text/x-component and the rscData bytes; HTML responses get Content-Type: text/html and the html string Tests updated: - Replace 'RSC requests return RSC stream without cache headers' test with two tests that assert Cache-Control IS emitted for RSC responses on ISR pages (dev mode), and that Next-Router-Prefetch requests also get it - Replace .tee() count test with assertion that >=2 tees exist (RSC + HTML) - Add rscData storage assertions for WRITE and background regen paths - Add assertion that ISR READ block has no !isRscRequest guard
…r ISR Two bugs fixed: 1. RSC requests (client-side nav / prefetch) were never cached because the RSC stream tee that captures rscData was placed AFTER the 'if (isRscRequest)' early-return, so the capture branch was never consumed. Move the tee to immediately after renderToReadableStream — before the isRscRequest branch — and register a waitUntil in the RSC path to write rscData to the cache. 2. The ISR cache read block fired after the generateStaticParams call, meaning a cache hit still did the expensive static-params validation. Move the ISR read to before generateStaticParams so a HIT skips all that work. Tests: add two new generateRscEntry ISR assertions: - ISR read index < generateStaticParams index in generated code - __rscForResponse tee assignment before 'return new Response(__rscForResponse'
RSC prefetch requests (Next-Router-Prefetch and client-side nav) now write a partial cache entry with rscData + html:'' on MISS. Subsequent RSC requests serve from cache (HIT) without waiting for an HTML request to populate the entry first. The ISR read block treats html:'' as a partial hit: RSC requests get a HIT, but HTML requests fall through to render and overwrite the entry with a complete html+rscData entry. This prevents blank HTML responses on first HTML request after a prefetch-first cold start. Also adds console.log logging for all ISR cache events (HIT/MISS/STALE/write) in production to aid debugging on deployed Workers.
…out constructor arg Add runWithExecutionContext/getRequestExecutionContext to next/cache shim backed by AsyncLocalStorage. The RSC entry handler now wraps each request in this ALS scope so KVCacheHandler._putInBackground and _deleteInBackground can call ctx.waitUntil() without needing ctx passed at construction time. - cache.ts: add ExecutionContextLike interface + ALS helpers - app-rsc-entry.ts: import runWithExecutionContext, wrap _run in ALS scope - kv-cache-handler.ts: check ALS ctx first, fall back to constructor ctx - deploy.ts: simplify generated template to new KVCacheHandler(env.VINEXT_CACHE) - tests/deploy.test.ts: update expectation to match simplified constructor
…cache isolation
Cloudflare Workers with Vite RSC loads separate module instances per
environment (worker / RSC / SSR). Previously, activeHandler was a
module-local variable, so setCacheHandler(new KVCacheHandler(...)) in
the worker environment set the worker copy while getCacheHandler() in
the RSC environment still returned a fresh MemoryCacheHandler from
its own copy — causing every ISR read/write to go to in-process
memory that is discarded after each request.
Fix: store the active handler on globalThis via Symbol.for('vinext.cacheHandler'),
the same pattern used by _cacheAls, _ctxAls, and _unstableCacheAls. All
Vite environments share the same globalThis on Cloudflare Workers, so
the KVCacheHandler set in worker/index.ts is now visible to the RSC
entry's getCacheHandler() call, enabling real KV-backed ISR cache
reads and writes.
… prevent partial RSC write from clobbering complete entry - Remove !isForceStatic and !isDynamicError from the ISR cache read guard. Both modes are compatible with ISR: they control dynamic API behaviour during render, not whether results can be cached. The asymmetry between the read guard (which excluded them) and the write guard (which did not) caused writes to succeed but reads to be skipped entirely, so every request returned MISS. Only isForceDynamic should bypass the ISR cache. - Add a read-before-write in the RSC partial-write path. When an RSC request writes a partial cache entry (html: "", rscData set) before the first HTML request has come in, a concurrent or later RSC request could race and overwrite a complete html+rscData entry that the HTML write had already landed. The fix reads the existing entry first and skips the partial write if a fresh, complete entry (non-empty html) is already present, so the HTML write always wins.
…ite races Previously both HTML and RSC data were stored under a single key with an html:"" partial-entry sentinel to signal 'RSC written, HTML pending'. This caused write races on eventually-consistent stores like KV: a concurrent RSC request could overwrite a complete html+rscData entry with a partial one, or the read-before-write guard added to prevent that would itself race. This mirrors how Next.js handles it in FileSystemCache: HTML is stored in <key>.html and RSC in <key>.rsc as independent files, so each request type reads and writes only its own key with no coordination needed. Changes: - Replace __isrCacheKey() with __isrHtmlKey() / __isrRscKey() which append :html and :rsc suffixes respectively - ISR read block selects the key based on isRscRequest before the get() call - RSC write path uses __isrRscKey and stores only rscData (html: "") - HTML write path uses __isrHtmlKey and stores only html (rscData: undefined) - Background regen writes both keys independently via Promise.all - Remove the read-before-write hack (no longer needed) - Remove partial-entry sentinel comments (concept no longer exists) - HTML write path no longer awaits __isrRscDataPromise
…uency Previously expirationTtl was 10x the revalidate period (clamped to 60s–30d). For pages with short revalidate windows (e.g. revalidate=5), this meant entries were evicted from KV after ~50 seconds of no traffic, causing the next request to block on a fresh render instead of serving stale content. KV eviction should never be the reason a stale entry disappears — staleness is tracked by the revalidateAt timestamp in the stored JSON, not by KV TTL. Always storing for 30 days means stale content is always available to serve while background regen runs, and entries only disappear after 30 days of zero traffic or explicit tag invalidation.
…econds option to KVCacheHandler - __isrCacheKey now embeds process.env.__VINEXT_BUILD_ID (statically replaced by Vite's define at build time) so each deployment gets its own effective cache namespace in KV — stale entries from previous deploys are never served as hits - vinextBuildId (crypto.randomUUID() at plugin scope) is passed to generateRscEntry and injected as the __VINEXT_BUILD_ID define - KVCacheHandler accepts an optional ttlSeconds constructor option (defaults to 30 days) so users can override KV entry lifetime
b128327 to
24a607e
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
buildPageElement(no component modules loaded on HIT/STALE), reducing cold-start CPU costprocess.env.NODE_ENV === "production"); dev mode emits correctCache-Controlheaders but never touches the cache storeBehaviour
X-Vinext-Cache: MISSX-Vinext-Cache: HITX-Vinext-Cache: STALE), triggers background regen viactx.waitUntil_rscparam setrevalidate = 0Cache-Controlheader still emittedImplementation
ISR helpers are inlined as generated JS code inside the virtual RSC entry (same pattern as Pages Router in
pages-server-entry.ts):__isrCacheKey(pathname)—"app:"prefix + SHA-256 hash for long paths__isrGet(key)— reads from the pluggableCacheHandler__isrSet(key, data, ttl)— writes toCacheHandler__pendingRegenerations—Map<string, Promise>for request dedup__triggerBackgroundRegeneration(key, renderFn, ctx)— runs regen once, cleans up map entry on completion/failureThe
ExecutionContext(ctx) is now threaded fromapp-router-entry.ts→handler(request, ctx)→_handleRequest(request, __reqCtx, _mwCtx, ctx)soctx.waitUntilis available for background tasks.Tests
tests/features.test.ts—ISR (App Router)suite (4 tests):Cache-Control: s-maxage=1, stale-while-revalidateheaderX-Vinext-Cacheheader (production guard prevents reads/writes)export const revalidateemit no ISR headerstests/app-router.test.ts—generateRscEntry ISR code generationsuite (10 tests):process.env.NODE_ENV === "production"guard present__isrGet,__isrSet,__pendingRegenerations,__triggerBackgroundRegeneration,__isrCacheKey)handler(request, ctx)signaturegetCacheHandlerimported from"next/cache"X-Vinext-CacheMISS/HIT/STALE headers in generated codectx.waitUntilpresent.tee()stream split present__isrGet) appears beforebuildPageElement