[PR #405] [MERGED] feat: ISR caching for App Router (production-only, stale-while-revalidate) #547

Closed
opened 2026-05-06 13:08:42 +02:00 by BreizhHardware · 0 comments

📋 Pull Request Information

Original PR: https://github.com/cloudflare/vinext/pull/405
Author: @james-elicx
Created: 3/10/2026
Status: Merged
Merged: 3/10/2026
Merged by: @james-elicx

Base: mainHead: opencode/happy-knight


📝 Commits (10+)

  • fe46ae6 feat: add ISR caching for App Router (production-only, stale-while-revalidate)
  • 975da3b fix: remove unused isrCachePath variable and fix formatting
  • daf73b3 fix: cache RSC wire format (rscData) in ISR entries; serve RSC requests from cache
  • 695c49a fix(isr): fix RSC cache miss and early-exit performance for App Router ISR
  • 73a44c0 feat(isr): cache RSC prefetch responses immediately as partial entries
  • ff8e08a fix(isr): use ALS to thread ExecutionContext into KVCacheHandler without constructor arg
  • b933772 fix(isr): store activeHandler on globalThis to fix cross-environment cache isolation
  • f3a5005 fix(isr): allow force-static/error pages to be served from ISR cache, prevent partial RSC write from clobbering complete entry
  • 46fbef1 fix(isr): split HTML and RSC into separate cache keys, eliminating write races
  • 2d1259c fix(kv-cache): always use 30-day KV TTL regardless of revalidate frequency

📊 Changes

10 files changed (+3068 additions, -135 deletions)

View changed files

📝 packages/vinext/src/cloudflare/kv-cache-handler.ts (+21 -12)
📝 packages/vinext/src/deploy.ts (+21 -5)
📝 packages/vinext/src/entries/app-rsc-entry.ts (+391 -15)
📝 packages/vinext/src/index.ts (+2 -0)
📝 packages/vinext/src/shims/cache.ts (+31 -8)
📝 tests/__snapshots__/entry-templates.test.ts.snap (+2328 -90)
📝 tests/app-router.test.ts (+173 -1)
📝 tests/deploy.test.ts (+17 -1)
📝 tests/features.test.ts (+78 -1)
📝 tests/shims.test.ts (+6 -2)

📄 Description

Summary

Adds ISR (Incremental Static Regeneration) caching to the App Router, matching Next.js stale-while-revalidate semantics on Cloudflare Workers.

  • Cache reads happen before `buildPageElement` — a HIT skips all component loading, `generateStaticParams`, layout/page probing, and SSR entirely
  • HTML and RSC are stored under separate KV keys (`:html` / `:rsc` suffix), mirroring Next.js's `.html`/`.rsc` file layout — no write races, no partial-entry sentinels
  • ISR cache reads/writes are production-only (`process.env.NODE_ENV === "production"`); dev mode emits correct `Cache-Control` headers but never touches the cache store
  • KV TTL is always 30 days regardless of `revalidate` frequency — staleness is tracked via `revalidateAt` in stored JSON, not KV eviction (prevents premature eviction of high-frequency pages like `revalidate=5`)
  • Cache keys include the build ID (`process.env.__VINEXT_BUILD_ID`) so each deployment gets its own effective KV namespace — no stale entries from previous deploys ever served as hits
  • Background regeneration uses an empty headers context (no user cookies/auth headers baked into cached content served to all users)
  • `revalidate = Infinity` (i.e. `force-static`) emits `s-maxage=31536000` and `X-Vinext-Cache: STATIC` rather than the invalid `s-maxage=Infinity`
  • `force-static` and `dynamic = "error"` pages are fully compatible with ISR — they control dynamic API behaviour during render, not whether results are cached. Only `force-dynamic` bypasses the cache
  • Concurrent requests for the same stale pathname are deduplicated via `__pendingRegenerations` — keyed by pathname (not per-type key) so an HTML and RSC request arriving simultaneously trigger one re-render, not two
  • `ExecutionContext` (`ctx`) is threaded through the RSC entry via the canonical `runWithExecutionContext` ALS from `shims/request-context.ts` — `KVCacheHandler` reads it from the ALS without needing it at construction time

Cache behaviour

Request Condition Result
First request MISS (cold) Renders, writes to cache, `X-Vinext-Cache: MISS`
Subsequent (fresh) Within revalidate window Returns cached HTML/RSC, `X-Vinext-Cache: HIT`
Subsequent (stale) Past revalidate window Returns stale immediately, triggers background regen via `ctx.waitUntil`, `X-Vinext-Cache: STALE`
Background regen fails Any error Continues serving stale; next request triggers another attempt
RSC navigation/prefetch `Accept: text/x-component` Served from RSC key (`rscData`); written as partial entry on first RSC MISS
HTML request after RSC-only cold start `html: ""` partial entry Falls through to render (treats partial as MISS for HTML); upgrades entry to full
`force-dynamic` page `revalidate = 0` Bypasses ISR entirely
`revalidate = Infinity` force-static `s-maxage=31536000`, `X-Vinext-Cache: STATIC`, no cache read/write
Dev mode Any No cache reads/writes; `Cache-Control` still emitted

Implementation

ISR helpers are inlined as generated JS inside the virtual RSC entry (same pattern as Pages Router in `pages-server-entry.ts`):

  • `__isrFnv1a64(s)` — FNV-1a 64-bit hash (two 32-bit rounds) for cache key generation
  • `__isrHtmlKey(pathname)` / `__isrRscKey(pathname)` — `app:__hash:/:html` / `:rsc`
  • `__isrGet(key)` / `__isrSet(key, data, ttl)` — read/write via the pluggable `CacheHandler`
  • `__pendingRegenerations` — `Map<string, Promise>` keyed by pathname for dedup across HTML+RSC
  • `__triggerBackgroundRegeneration(pathname, renderFn)` — runs regen once, cleans up on completion/failure
  • `__isrRscDataPromise` — captured via `.tee()` before the `isRscRequest` branch so the HTML write path can also populate the RSC key

Tests

`tests/features.test.ts` — `ISR (App Router)` suite:

  • Dev: renders ISR page with correct `Cache-Control: s-maxage=1, stale-while-revalidate`
  • Dev: no `X-Vinext-Cache` header (prod guard prevents reads/writes)
  • Dev: RSC requests return RSC stream with `Cache-Control` but no `X-Vinext-Cache`
  • Dev: prefetch RSC requests (`Next-Router-Prefetch: 1`) also get `Cache-Control`
  • Dev: pages without `export const revalidate` emit no ISR headers

`tests/app-router.test.ts` — `generateRscEntry ISR code generation` suite (13 tests):

  • `process.env.NODE_ENV === "production"` guard present
  • All inline helpers present (`__isrGet`, `__isrSet`, `__pendingRegenerations`, `__triggerBackgroundRegeneration`, `__isrHtmlKey`, `__isrRscKey`, `__isrFnv1a64`)
  • `X-Vinext-Cache` MISS/HIT/STALE/STATIC headers
  • `ctx.waitUntil` present
  • `__hasRsc` / `__hasHtml` guards in the cache HIT block (`if (isRscRequest && __hasRsc)`, `if (!isRscRequest && __hasHtml)`)
  • RSC-first partial write (`html: ""`, `__rscDataForCache`, `__isrKeyRsc`)
  • ISR read before `buildPageElement` and before `generateStaticParams`

`tests/kv-cache-handler.test.ts`: TTL is always 30 days (documented rationale for flat TTL vs 10× revalidate)

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


🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.

## 📋 Pull Request Information **Original PR:** https://github.com/cloudflare/vinext/pull/405 **Author:** [@james-elicx](https://github.com/james-elicx) **Created:** 3/10/2026 **Status:** ✅ Merged **Merged:** 3/10/2026 **Merged by:** [@james-elicx](https://github.com/james-elicx) **Base:** `main` ← **Head:** `opencode/happy-knight` --- ### 📝 Commits (10+) - [`fe46ae6`](https://github.com/cloudflare/vinext/commit/fe46ae679a3bd7b8cb62b1885f1ad46a7fc151d7) feat: add ISR caching for App Router (production-only, stale-while-revalidate) - [`975da3b`](https://github.com/cloudflare/vinext/commit/975da3b3d81263e7c07962cdb455fa0a54b47259) fix: remove unused isrCachePath variable and fix formatting - [`daf73b3`](https://github.com/cloudflare/vinext/commit/daf73b3f2cc830c6bc214b64b3bdbbd8f8c4f389) fix: cache RSC wire format (rscData) in ISR entries; serve RSC requests from cache - [`695c49a`](https://github.com/cloudflare/vinext/commit/695c49a78fec151e89d0ae107bd70233aaedf28c) fix(isr): fix RSC cache miss and early-exit performance for App Router ISR - [`73a44c0`](https://github.com/cloudflare/vinext/commit/73a44c03392b629ca0389c65c6ba448f5932c5d2) feat(isr): cache RSC prefetch responses immediately as partial entries - [`ff8e08a`](https://github.com/cloudflare/vinext/commit/ff8e08a6da0347db6a5c649700726a95218996d3) fix(isr): use ALS to thread ExecutionContext into KVCacheHandler without constructor arg - [`b933772`](https://github.com/cloudflare/vinext/commit/b933772a8eccea22d4a6c38bbf51c7149eb4682b) fix(isr): store activeHandler on globalThis to fix cross-environment cache isolation - [`f3a5005`](https://github.com/cloudflare/vinext/commit/f3a50054f7cc68c0f29c0f143f72ee598c62d056) fix(isr): allow force-static/error pages to be served from ISR cache, prevent partial RSC write from clobbering complete entry - [`46fbef1`](https://github.com/cloudflare/vinext/commit/46fbef1de120e00a7ce34f727a4eab95e3b2be67) fix(isr): split HTML and RSC into separate cache keys, eliminating write races - [`2d1259c`](https://github.com/cloudflare/vinext/commit/2d1259cbe8e41b034b9e5f50938dcaba4f0bb146) fix(kv-cache): always use 30-day KV TTL regardless of revalidate frequency ### 📊 Changes **10 files changed** (+3068 additions, -135 deletions) <details> <summary>View changed files</summary> 📝 `packages/vinext/src/cloudflare/kv-cache-handler.ts` (+21 -12) 📝 `packages/vinext/src/deploy.ts` (+21 -5) 📝 `packages/vinext/src/entries/app-rsc-entry.ts` (+391 -15) 📝 `packages/vinext/src/index.ts` (+2 -0) 📝 `packages/vinext/src/shims/cache.ts` (+31 -8) 📝 `tests/__snapshots__/entry-templates.test.ts.snap` (+2328 -90) 📝 `tests/app-router.test.ts` (+173 -1) 📝 `tests/deploy.test.ts` (+17 -1) 📝 `tests/features.test.ts` (+78 -1) 📝 `tests/shims.test.ts` (+6 -2) </details> ### 📄 Description ## Summary Adds ISR (Incremental Static Regeneration) caching to the App Router, matching Next.js stale-while-revalidate semantics on Cloudflare Workers. - Cache reads happen **before** \`buildPageElement\` — a HIT skips all component loading, \`generateStaticParams\`, layout/page probing, and SSR entirely - HTML and RSC are stored under **separate KV keys** (\`:html\` / \`:rsc\` suffix), mirroring Next.js's \`.html\`/\`.rsc\` file layout — no write races, no partial-entry sentinels - ISR cache reads/writes are **production-only** (\`process.env.NODE_ENV === "production"\`); dev mode emits correct \`Cache-Control\` headers but never touches the cache store - KV TTL is always **30 days** regardless of \`revalidate\` frequency — staleness is tracked via \`revalidateAt\` in stored JSON, not KV eviction (prevents premature eviction of high-frequency pages like \`revalidate=5\`) - Cache keys include the **build ID** (\`process.env.__VINEXT_BUILD_ID\`) so each deployment gets its own effective KV namespace — no stale entries from previous deploys ever served as hits - Background regeneration uses an **empty headers context** (no user cookies/auth headers baked into cached content served to all users) - \`revalidate = Infinity\` (i.e. \`force-static\`) emits \`s-maxage=31536000\` and \`X-Vinext-Cache: STATIC\` rather than the invalid \`s-maxage=Infinity\` - \`force-static\` and \`dynamic = "error"\` pages are fully **compatible with ISR** — they control dynamic API behaviour during render, not whether results are cached. Only \`force-dynamic\` bypasses the cache - Concurrent requests for the same stale pathname are **deduplicated** via \`__pendingRegenerations\` — keyed by pathname (not per-type key) so an HTML and RSC request arriving simultaneously trigger one re-render, not two - \`ExecutionContext\` (\`ctx\`) is threaded through the RSC entry via the canonical \`runWithExecutionContext\` ALS from \`shims/request-context.ts\` — \`KVCacheHandler\` reads it from the ALS without needing it at construction time ## Cache behaviour | Request | Condition | Result | |---------|-----------|--------| | First request | MISS (cold) | Renders, writes to cache, \`X-Vinext-Cache: MISS\` | | Subsequent (fresh) | Within revalidate window | Returns cached HTML/RSC, \`X-Vinext-Cache: HIT\` | | Subsequent (stale) | Past revalidate window | Returns stale immediately, triggers background regen via \`ctx.waitUntil\`, \`X-Vinext-Cache: STALE\` | | Background regen fails | Any error | Continues serving stale; next request triggers another attempt | | RSC navigation/prefetch | \`Accept: text/x-component\` | Served from RSC key (\`rscData\`); written as partial entry on first RSC MISS | | HTML request after RSC-only cold start | \`html: ""\` partial entry | Falls through to render (treats partial as MISS for HTML); upgrades entry to full | | \`force-dynamic\` page | \`revalidate = 0\` | Bypasses ISR entirely | | \`revalidate = Infinity\` | force-static | \`s-maxage=31536000\`, \`X-Vinext-Cache: STATIC\`, no cache read/write | | Dev mode | Any | No cache reads/writes; \`Cache-Control\` still emitted | ## Implementation ISR helpers are **inlined as generated JS** inside the virtual RSC entry (same pattern as Pages Router in \`pages-server-entry.ts\`): - \`__isrFnv1a64(s)\` — FNV-1a 64-bit hash (two 32-bit rounds) for cache key generation - \`__isrHtmlKey(pathname)\` / \`__isrRscKey(pathname)\` — \`app:__hash:<buildId>/<hash>:html\` / \`:rsc\` - \`__isrGet(key)\` / \`__isrSet(key, data, ttl)\` — read/write via the pluggable \`CacheHandler\` - \`__pendingRegenerations\` — \`Map<string, Promise>\` keyed by **pathname** for dedup across HTML+RSC - \`__triggerBackgroundRegeneration(pathname, renderFn)\` — runs regen once, cleans up on completion/failure - \`__isrRscDataPromise\` — captured via \`.tee()\` before the \`isRscRequest\` branch so the HTML write path can also populate the RSC key ## Tests **\`tests/features.test.ts\` — \`ISR (App Router)\` suite:** - Dev: renders ISR page with correct \`Cache-Control: s-maxage=1, stale-while-revalidate\` - Dev: no \`X-Vinext-Cache\` header (prod guard prevents reads/writes) - Dev: RSC requests return RSC stream with \`Cache-Control\` but no \`X-Vinext-Cache\` - Dev: prefetch RSC requests (\`Next-Router-Prefetch: 1\`) also get \`Cache-Control\` - Dev: pages without \`export const revalidate\` emit no ISR headers **\`tests/app-router.test.ts\` — \`generateRscEntry ISR code generation\` suite (13 tests):** - \`process.env.NODE_ENV === "production"\` guard present - All inline helpers present (\`__isrGet\`, \`__isrSet\`, \`__pendingRegenerations\`, \`__triggerBackgroundRegeneration\`, \`__isrHtmlKey\`, \`__isrRscKey\`, \`__isrFnv1a64\`) - \`X-Vinext-Cache\` MISS/HIT/STALE/STATIC headers - \`ctx.waitUntil\` present - \`__hasRsc\` / \`__hasHtml\` guards in the cache HIT block (\`if (isRscRequest && __hasRsc)\`, \`if (!isRscRequest && __hasHtml)\`) - RSC-first partial write (\`html: ""\`, \`__rscDataForCache\`, \`__isrKeyRsc\`) - ISR read before \`buildPageElement\` and before \`generateStaticParams\` **\`tests/kv-cache-handler.test.ts\`:** TTL is always 30 days (documented rationale for flat TTL vs 10× revalidate) > **Note on production behaviour testing:** \`process.env.NODE_ENV\` is statically replaced by Vite's \`define\` at transform time — integration tests always run under \`NODE_ENV=test\` so the production cache path is never active in Vitest. Production ISR behaviour (MISS → HIT → STALE → regen) is covered by Playwright E2E tests against a real Workers build. --- <sub>🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.</sub>
BreizhHardware 2026-05-06 13:08:42 +02:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
starred/vinext#547
No description provided.