Skip to content

feat: ISR caching for App Router (production-only, stale-while-revalidate)#405

Draft
james-elicx wants to merge 13 commits intomainfrom
opencode/happy-knight
Draft

feat: ISR caching for App Router (production-only, stale-while-revalidate)#405
james-elicx wants to merge 13 commits intomainfrom
opencode/happy-knight

Conversation

@james-elicx
Copy link
Collaborator

Summary

  • Adds ISR (Incremental Static Regeneration) caching to the App Router, matching Next.js runtime semantics
  • Cache reads happen before buildPageElement (no component modules loaded on HIT/STALE), reducing cold-start CPU cost
  • All cache handler reads/writes are production-only (process.env.NODE_ENV === "production"); dev mode emits correct Cache-Control headers but never touches the cache store

Behaviour

Request Condition Result
First request MISS (cold) Renders, writes to cache, responds with X-Vinext-Cache: MISS
Subsequent (fresh) HIT within TTL Returns cached HTML/headers, X-Vinext-Cache: HIT
Subsequent (stale) Past TTL Returns stale cached response (X-Vinext-Cache: STALE), triggers background regen via ctx.waitUntil
Background regen fails Any error Continues serving stale; next request triggers another attempt
RSC stream request _rsc param set Bypasses ISR entirely (always renders fresh)
`force-dynamic` page revalidate = 0 Bypasses ISR entirely
Dev mode Any No cache reads/writes; Cache-Control header still emitted

Implementation

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 pluggable CacheHandler
  • __isrSet(key, data, ttl) — writes to CacheHandler
  • __pendingRegenerationsMap<string, Promise> for request dedup
  • __triggerBackgroundRegeneration(key, renderFn, ctx) — runs regen once, cleans up map entry on completion/failure

The ExecutionContext (ctx) is now threaded from app-router-entry.tshandler(request, ctx)_handleRequest(request, __reqCtx, _mwCtx, ctx) so ctx.waitUntil is available for background tasks.

Tests

tests/features.test.tsISR (App Router) suite (4 tests):

  • Dev: renders ISR page with correct Cache-Control: s-maxage=1, stale-while-revalidate header
  • Dev: no X-Vinext-Cache header (production guard prevents reads/writes)
  • Dev: RSC stream requests bypass ISR (no cache headers)
  • Dev: pages without export const revalidate emit no ISR headers

tests/app-router.test.tsgenerateRscEntry ISR code generation suite (10 tests):

  • process.env.NODE_ENV === "production" guard present
  • All inline helpers present (__isrGet, __isrSet, __pendingRegenerations, __triggerBackgroundRegeneration, __isrCacheKey)
  • handler(request, ctx) signature
  • getCacheHandler imported from "next/cache"
  • X-Vinext-Cache MISS/HIT/STALE headers in generated code
  • ctx.waitUntil present
  • .tee() stream split present
  • ISR cache read (__isrGet) appears before buildPageElement

Note on production behaviour testing: process.env.NODE_ENV is statically replaced by Vite's define config at transform time. Integration tests always run under NODE_ENV=test, so the production cache path ("test" === "production"false) can never be exercised in Vitest. Production ISR behaviour (MISS → HIT → STALE → regen) is covered by Playwright E2E tests against a real Workers build.

@pkg-pr-new
Copy link

pkg-pr-new bot commented Mar 10, 2026

Open in StackBlitz

npm i https://pkg.pr.new/vinext@405

commit: 4595d11

@github-actions
Copy link

Example Preview Production Original
app-router-cloudflare preview production
pages-router-cloudflare preview production
app-router-playground preview production original
realworld-api-rest preview production
nextra-docs-template preview production
benchmarks preview production
hackernews preview production original

…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
@james-elicx james-elicx force-pushed the opencode/happy-knight branch from b128327 to 24a607e Compare March 10, 2026 14:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant