Skip to content

fix(app-router): implement ISR caching gated behind production NODE_ENV#359

Draft
james-elicx wants to merge 13 commits intomainfrom
fix/app-router-isr-production-guard
Draft

fix(app-router): implement ISR caching gated behind production NODE_ENV#359
james-elicx wants to merge 13 commits intomainfrom
fix/app-router-isr-production-guard

Conversation

@james-elicx
Copy link
Collaborator

@james-elicx james-elicx commented Mar 8, 2026

Summary

  • App Router ISR now works in production: pages with export const revalidate = N read from/write to the pluggable cache handler (set via setCacheHandler), serving HIT/STALE/MISS responses — matching Pages Router behavior
  • RSC flight data cached alongside HTML: client-side navigations (RSC requests) are served from cache without a re-render, matching Next.js's rscData cache entry field
  • X-Vinext-Cache on every ISR response: HIT, STALE, and MISS all emit the header for observability parity
  • Cache-Control headers still emitted in all environments: dev and test still get s-maxage=N, stale-while-revalidate on ISR pages; only the cache handler interaction is gated behind NODE_ENV === "production"
  • activeHandler moved to globalThis: shims/cache.ts now stores the active cache handler on globalThis[Symbol.for("vinext.activeHandler")] so all module instances in the same process (host + Vite RSC environment) share the same reference

How it works

The generated RSC entry gains full ISR logic wrapped in if (process.env.NODE_ENV === "production"). Vite's define config statically replaces process.env.NODE_ENV at transform time ("development" or "test" in non-production), so the cache handler interaction is dead-code-eliminated in dev/test — every request re-renders fresh.

HTML requests:

  • HIT: returns cached HTML with X-Vinext-Cache: HIT
  • STALE: returns cached HTML with X-Vinext-Cache: STALE + triggers background regeneration (uses _frozenNavContext captured before request context is cleared, so usePathname() etc. work correctly in background renders)
  • MISS: tees rscStream before handleSsr, then collects HTML and RSC flight bytes concurrently (concurrent collection avoids a backpressure deadlock — SSR produces HTML by consuming the RSC stream, so draining them in series could stall), stores both in cache, returns with X-Vinext-Cache: MISS
  • Dev/test: falls through directly to htmlStream response with Cache-Control header still set

RSC requests (client-side navigation):

  • HIT/STALE: serves cached RSC flight bytes directly as a stream — no re-render; STALE triggers background regeneration of both HTML and RSC data
  • MISS: falls through to serve the live RSC stream; the HTML path is responsible for populating the cache with rscData

Fixes addressed from review

Issue Fix
renderFreshAndCache called _getNavigationContext() after context was already nulled Capture _frozenNavContext before nulling; use it in all background regen lambdas
Missing X-Vinext-Cache: MISS on cache miss Added to HTML MISS response
triggerBackgroundRegeneration type mismatch (Promise<string> vs () => Promise<void>) Wrapped in async () => { await renderFreshAndCache(); }
RSC requests bypass cache Full RSC HIT/STALE/MISS path added; rscStream teed on HTML MISS to populate rscData
collectStreamAsArrayBuffer used but not defined Added as a top-level helper in the generated entry
Cached RSC stream was never returned (structural bug) Fixed: HIT/STALE branch now has its own return new Response(cachedRscStream, ...)

App Router pages with `export const revalidate = N` now read from and write
to the pluggable cache handler (set via setCacheHandler) in production,
matching Pages Router behavior. ISR is skipped in dev/test so every request
re-renders fresh.

- shims/cache.ts: move activeHandler onto globalThis[Symbol.for('vinext.activeHandler')]
  so all module instances in the same process (host + Vite RSC environment) share
  the same handler reference
- index.ts: add 'vinext/isr-cache' alias so the generated RSC entry can import it
- app-dev-server.ts: implement HIT/STALE/MISS ISR logic in generated RSC entry,
  guarded by process.env.NODE_ENV === 'production' (statically replaced by Vite
  to 'development' in dev and 'test' in test mode, so ISR is dead-code-eliminated
  outside of production builds)
@pkg-pr-new
Copy link

pkg-pr-new bot commented Mar 8, 2026

Open in StackBlitz

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

commit: 46e9d9f

@github-actions
Copy link

github-actions bot commented Mar 8, 2026

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

@james-elicx
Copy link
Collaborator Author

/bigbonk review this pr please

Copy link
Contributor

@ask-bonk ask-bonk bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: App Router ISR caching gated behind production NODE_ENV

The approach is sound — using process.env.NODE_ENV === "production" so Vite dead-code-eliminates the cache interaction in dev/test is clean, and moving activeHandler to globalThis via Symbol.for() solves a real cross-environment module instance problem. The cache.ts changes are correct and well-motivated. That said, there are several issues in the generated ISR code in app-dev-server.ts that need to be addressed before merging.


Bug: renderFreshAndCache uses nulled navigation/headers context

At lines 2499-2500, the generated code runs:

setHeadersContext(null);
setNavigationContext(null);

This happens before the ISR block at lines 2598+. The renderFreshAndCache closure at line 2603-2617 calls:

const rscStream2 = renderToReadableStream(element, { onError: onRenderError });
const htmlStream2 = await ssrEntry2.handleSsr(rscStream2, _getNavigationContext(), fontData);

By this point, _getNavigationContext() returns null. This means:

  • For the STALE path (background regeneration): handleSsr receives null navigation context. Any "use client" component calling usePathname(), useSearchParams(), or useParams() during SSR will get null/throw.
  • For the MISS path: Same issue — renderFreshAndCache is not called, but the same _getNavigationContext() would be null if it were used there. The MISS path doesn't call renderFreshAndCache, but it shares the same surrounding context where headers/nav are already null.
  • Server Components that call headers() or cookies() during the background RSC re-render will also get null context since setHeadersContext(null) was already called.

Fix: The ISR block should either run before the context is nulled, or renderFreshAndCache should capture the nav context and headers context before the null-out and re-establish them for the background render. E.g.:

const _savedNavContext = _getNavigationContext();
const _savedHeadersContext = getHeadersContext();
// ... later, in renderFreshAndCache:
setNavigationContext(_savedNavContext);
setHeadersContext(_savedHeadersContext);

Or move the ISR cache-HIT/STALE check above the setHeadersContext(null) call (only null the context if we actually need to render fresh).


Missing: ISR caching for RSC requests (client-side navigations)

The ISR block only applies to HTML (full-page) requests. RSC requests (client-side navigations with RSC: 1 header) return at ~line 2445 with the raw RSC stream and bypass the ISR cache entirely. This means:

  • Client-side navigations to ISR pages always render fresh (no cache benefit).
  • The first request to an ISR page via client nav won't populate the cache for subsequent HTML requests either.

This isn't necessarily a blocker for this PR (the Pages Router ISR also only caches HTML), but it's worth noting as a follow-up. If intentional, consider adding a comment explaining the rationale — otherwise, RSC responses should also participate in the cache (with a separate APP_RSC cache value kind, since the wire format differs from HTML).


Missing: X-Vinext-Cache: MISS header on cache miss

The MISS path (lines 2651-2668) doesn't emit X-Vinext-Cache: MISS, while the HIT and STALE paths do emit their respective headers. For observability parity, the MISS response should include X-Vinext-Cache: MISS.


renderFreshAndCache return type mismatch with triggerBackgroundRegeneration

triggerBackgroundRegeneration in isr-cache.ts:76 expects renderFn: () => Promise<void>, but renderFreshAndCache returns Promise<string> (the fresh HTML). This works at runtime because the return value is ignored, but the type contract is misleading. Either change the type to () => Promise<unknown> or have renderFreshAndCache explicitly return void when used for background regen.


Snapshot churn

The snapshot file adds 450 lines because the ISR block is ~72 lines of generated code repeated across 6 snapshot variants. This is expected given how the snapshot tests work, but it significantly increases the review surface. Not actionable, just noting it.


Summary

The cache.ts globalThis change is clean and correct. The core ISR logic structure (HIT/STALE/MISS) is right and matches the existing Pages Router pattern. But the null context bug in renderFreshAndCache is a correctness issue that would cause background regeneration to produce broken HTML for any page using headers(), cookies(), or client-side navigation hooks during SSR. This needs to be fixed before merging.

@ask-bonk
Copy link
Contributor

ask-bonk bot commented Mar 8, 2026

Review posted on PR #359. Here's the summary:


Reviewed PR #359 — App Router ISR caching gated behind production NODE_ENV.

The cache.ts globalThis change is well-motivated and correct — it solves the real cross-environment module instance problem where setCacheHandler() called from outside the RSC environment wouldn't affect the RSC copy.

The core ISR structure (HIT/STALE/MISS with stale-while-revalidate) is sound and matches the Pages Router pattern. However, I flagged several issues:

  1. Bug (blocking): renderFreshAndCache uses _getNavigationContext() and relies on headers context, but both are nulled out at lines 2499-2500 before the ISR block runs. Background regeneration will produce broken HTML for pages using headers(), cookies(), usePathname(), etc.

  2. Missing X-Vinext-Cache: MISS header on cache miss for observability parity with HIT/STALE.

  3. No ISR caching for RSC requests — client-side navigations bypass the cache entirely. Not necessarily a blocker but worth a comment or follow-up.

  4. Type mismatchrenderFreshAndCache returns Promise<string> but triggerBackgroundRegeneration expects () => Promise<void>.

Requested changes on the null context bug before merge.

github run

@james-elicx james-elicx marked this pull request as draft March 8, 2026 22:33
…MISS

- Fix RSC isRscRequest block: return cachedRscStream (not live rscStream) on HIT/STALE
- Add collectStreamAsArrayBuffer helper to generated entry
- Tee rscStream before handleSsr to capture RSC flight data for HTML MISS path
- Collect HTML and RSC bytes concurrently in MISS path to avoid backpressure deadlock
- Fix renderFreshAndCache to use _frozenNavContext (not _getNavigationContext() after null)
- Fix renderFreshAndCache to capture rscData and store alongside HTML
- Fix STALE path: pass void wrapper lambda to triggerBackgroundRegeneration
- Add X-Vinext-Cache: MISS header on HTML cache miss
- Update entry template snapshots
@james-elicx
Copy link
Collaborator Author

/bigbonk re-review please

Copy link
Contributor

@ask-bonk ask-bonk bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review: App Router ISR caching gated behind production NODE_ENV

All four issues from the original review have been addressed. Tests pass, typecheck is clean.


Issue 1 (blocking bug): renderFreshAndCache uses nulled navigation context — FIXED

The fix is correct. _frozenNavContext is captured at line 2612 before the context is nulled at lines 2614-2615:

const _frozenNavContext = _getNavigationContext();
setHeadersContext(null);
setNavigationContext(null);

Both renderFreshAndCache (line 2727) and the RSC background regeneration lambda (line 2449) use the frozen reference. Pages using usePathname(), useSearchParams(), headers(), etc. during background ISR regeneration will get the correct values.

One nuance: setHeadersContext isn't re-established for background renders — only _frozenNavContext (navigation) is captured and passed to handleSsr. This is fine for the SSR environment (which only needs nav context), but Server Components in the RSC environment that call headers() or cookies() during background regen will get null since the ALS scope has unwound. This matches the Pages Router ISR behavior (background regen doesn't have a live request to read headers from), so it's consistent. Worth noting for future work if anyone adds headers() to an ISR page and expects it to work in background regen.

Issue 2: Missing X-Vinext-Cache: MISSFIXED

Added at line 2795 for HTML MISS and line 2499 for RSC MISS. Full observability parity.

Issue 3: No ISR caching for RSC requests — FIXED

Full RSC HIT/STALE/MISS path added at lines 2427-2500. Cache hits serve the stored rscData directly as a stream (no re-render). STALE triggers background regeneration of both HTML and RSC data. MISS falls through to the live stream, and the HTML path populates the cache with rscData via the teed stream. This is a significant improvement — client-side navigations to ISR pages now get cache benefits.

Issue 4: triggerBackgroundRegeneration type mismatch — FIXED

Background regen is now wrapped in async () => { await renderFreshAndCache(); } (line 2760 for HTML STALE) and inline async () => { ... } lambdas (line 2444 for RSC STALE), both returning Promise<void> matching the expected signature.


New code quality observations (non-blocking)

  1. Concurrent HTML + RSC collection on MISS (lines 2785-2788): Using Promise.all([collectHtmlChunks(), collectStreamAsArrayBuffer(_rscStreamForCache)]) is the right approach. The comment at line 2771-2774 explaining the backpressure deadlock risk is excellent documentation.

  2. collectStreamAsArrayBuffer helper (lines 476-494): Properly handles both string and Uint8Array chunks. The top-level placement in the generated entry makes it available to both HTML and RSC paths.

  3. RSC tee only in production ISR (lines 2573-2577): The conditional rscStream.tee() avoids unnecessary overhead in dev/non-ISR paths. Clean.

  4. fontData reference in RSC background regen (line 2449): The background regen lambda at line 2444-2460 captures fontData from the outer scope. Since fontData is collected at lines 2553-2557 (after the RSC request early return at line 2548), it's only captured in the HTML path, which is where the background regen runs. This is correct. However, the RSC STALE background regen (line 2444) also references fontData — which is collected after the RSC request block would have returned. Looking more carefully... the RSC STALE regen at line 2444-2460 is inside the if (isRscRequest) block at line 2404, and fontData is declared at line 2553 (outside that block). Since the RSC block returns at line 2548, the RSC STALE background regen closure captures fontData from the outer scope where it hasn't been assigned yet. This is a bug: fontData will be undefined when the RSC STALE background regen runs, because the closure captures the variable binding (which is undefined at declaration time on line 2553), not the eventual value. The background regen calls ssrEntry2.handleSsr(rscStream2b, _rscFrozenNavCtx, fontData)fontData will be undefined.

    Fix: Either move fontData collection above the RSC request block, or collect it inside the RSC STALE regen lambda. This should be a follow-up since it only affects the quality of background-regenerated HTML (missing font styles), not correctness of the cached RSC data that's served to the client.

  5. Module-scoped pendingRegenerations map in isr-cache.ts (line 68): This is process-scoped, which is correct for single-process deployments and Cloudflare Workers (one isolate per request batch). On multi-process deployments (e.g., PM2 cluster), two processes could regenerate the same key simultaneously. This is a known limitation of the pattern and acceptable — it's a performance optimization (dedup), not a correctness requirement.

Overall: clean implementation that addresses all the blocking issues. The fontData capture bug in RSC STALE regen is worth a follow-up but shouldn't block merge.

@ask-bonk
Copy link
Contributor

ask-bonk bot commented Mar 8, 2026

Re-review posted on PR #359 with approval. Here's the summary:

All 4 original issues are fixed:

  1. Null nav context bug — Fixed by capturing _frozenNavContext before context clearing and passing it to all background regen lambdas
  2. Missing X-Vinext-Cache: MISS — Added for both HTML and RSC MISS responses
  3. No RSC caching — Full HIT/STALE/MISS path added for RSC requests with background regeneration on STALE
  4. Type mismatch — Background regen properly wrapped in async () => void lambdas

One new issue found (non-blocking): The RSC STALE background regeneration closure (line 2444) captures fontData from the outer scope, but fontData is declared at line 2553 — after the RSC block returns at line 2548. This means fontData will be undefined during RSC STALE background regen, producing HTML without font styles. Flagged as a follow-up since it only affects background-regenerated HTML quality, not the cached RSC data served to clients.

github run

… lifecycle tests

Port the skipped MISS/HIT/STALE/regen, RSC caching, revalidateTag and
revalidatePath tests from tests/e2e/app-router/isr.spec.ts into a new
production project (port 4180) that runs against `vinext build` +
`vinext start` output.
…page cache

The patched fetch already collects next.tags into currentRequestTags via ALS
(getCollectedFetchTags). Pass those tags to all three isrSet call sites in
the App Router generated entry so revalidateTag() can invalidate ISR page
cache entries that were populated by tagged fetches.

Also fix the RSC MISS test to use /revalidate-test (cold cache, 60s TTL)
instead of /isr-test which is already warm from earlier tests in the suite.
…CodeQL ReDoS

- Early ISR cache check: consult isrGet before buildPageElement and
  renderToReadableStream. On HIT or STALE the entire render is skipped,
  cutting wall time from ~3 s to ~few ms for cached pages.

- Tee htmlStream on MISS: stream one branch to the client immediately
  (first-byte latency matches dynamic pages) while draining the other
  branch to isrSet in a background task via triggerBackgroundRegeneration.
  Eliminates the previous full-render buffering before responding.

- Remove redundant late HTML HIT/STALE check (now handled by early check)
  and the redundant RSC ISR block (HIT/STALE now handled by early check;
  MISS falls through to live rscStream as before).

- Fix CodeQL ReDoS alerts: replace nested-quantifier regex patterns in
  isr.spec.ts with extractRequestId() helper that strips HTML comments
  first (O(n)), then applies a simple non-backtracking pattern.
*/
function extractRequestId(html: string): string | undefined {
// Strip all HTML comments before matching — O(n) and safe from backtracking
const stripped = html.replace(/<!--[\s\S]*?-->/g, "");

Check failure

Code scanning / CodeQL

Incomplete multi-character sanitization High test

This string may still contain
<!--
, which may cause an HTML element injection vulnerability.

Copilot Autofix

AI 1 day ago

In general, to fix incomplete multi-character sanitization with regex replacements you either (1) repeatedly apply the replacement until the string stabilizes, or (2) switch to a pattern/approach that cannot reintroduce the unsafe sequence (for example, a parser or a library). Here, we just need a small, local change inside extractRequestId in tests/e2e/app-router-prod/isr.spec.ts so that all HTML comments are removed in a way that CodeQL recognizes as safe, without changing the function’s observable behavior in normal cases.

The best low-impact fix is to wrap the existing replacement in a small loop that re-applies the regex until no further matches occur. This directly addresses the CodeQL concern that “this string may still contain <!--” by guaranteeing that if any new <!-- ... --> sequence is formed as a side effect of a prior replacement, it will be removed on the next iteration. We keep all existing imports and functionality, only changing the body of extractRequestId where const stripped is assigned.

Concretely:

  • Replace the single html.replace(/<!--[\s\S]*?-->/g, "") call with a short do/while loop that:
    • Starts from the original html,
    • Repeatedly runs .replace(/<!--[\s\S]*?-->/g, ""),
    • Stops when a pass does not change the string.
  • Preserve the subsequent match calls exactly as they are.

No new imports or external libraries are needed; we only use built-in string and regex capabilities.


Suggested changeset 1
tests/e2e/app-router-prod/isr.spec.ts

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/tests/e2e/app-router-prod/isr.spec.ts b/tests/e2e/app-router-prod/isr.spec.ts
--- a/tests/e2e/app-router-prod/isr.spec.ts
+++ b/tests/e2e/app-router-prod/isr.spec.ts
@@ -25,9 +25,14 @@
  * quantifiers over comment-interleaved content.
  */
 function extractRequestId(html: string): string | undefined {
-  // Strip all HTML comments before matching — O(n) and safe from backtracking.
-  // codeql[js/incomplete-multi-character-sanitization] -- trusted SSR output, not user input
-  const stripped = html.replace(/<!--[\s\S]*?-->/g, "");
+  // Strip all HTML comments before matching — keep applying until stable to
+  // avoid incomplete multi-character sanitization issues.
+  let prev: string;
+  let stripped = html;
+  do {
+    prev = stripped;
+    stripped = stripped.replace(/<!--[\s\S]*?-->/g, "");
+  } while (stripped !== prev);
   return (
     stripped.match(/data-testid="request-id"[^>]*>RequestID:\s*([a-z0-9]+)/)?.[1] ??
     stripped.match(/request-id[^>]*>[^<]*?([a-z0-9]{6,})/)?.[1]
EOF
@@ -25,9 +25,14 @@
* quantifiers over comment-interleaved content.
*/
function extractRequestId(html: string): string | undefined {
// Strip all HTML comments before matching — O(n) and safe from backtracking.
// codeql[js/incomplete-multi-character-sanitization] -- trusted SSR output, not user input
const stripped = html.replace(/<!--[\s\S]*?-->/g, "");
// Strip all HTML comments before matching — keep applying until stable to
// avoid incomplete multi-character sanitization issues.
let prev: string;
let stripped = html;
do {
prev = stripped;
stripped = stripped.replace(/<!--[\s\S]*?-->/g, "");
} while (stripped !== prev);
return (
stripped.match(/data-testid="request-id"[^>]*>RequestID:\s*([a-z0-9]+)/)?.[1] ??
stripped.match(/request-id[^>]*>[^<]*?([a-z0-9]{6,})/)?.[1]
Copilot is powered by AI and may make mistakes. Always verify output.
…nt timing

- Pass [cleanPathname, "_N_T_" + cleanPathname, ...fetchTags] to all four
  isrSet call sites so revalidatePath() correctly invalidates page cache entries
  (previously only fetch tags were stored, making revalidatePath a no-op)
- On RSC MISS for an ISR page, tee the rscStream and fire a background task
  that collects the RSC bytes and performs a second SSR pass to produce HTML,
  then writes both to the cache — subsequent RSC prefetches now get HIT/STALE
  instead of rendering fresh on every request
- Capture font data inside the STALE regen lambdas after renderToReadableStream
  is called rather than before, fixing empty font arrays in regenerated pages
  (fonts are populated during RSC rendering, not before it starts)
- Add CodeQL suppression comment on isr.spec.ts regex that strips HTML comments
  from trusted SSR output (not user input)
…C overhead

Every incoming HTTP request previously called import.meta.viteRsc.loadModule()
which compiles to transport.invoke("fetchModule", ...) — an async IPC round-trip
to the Vite server even when the module is already cached and valid. This adds
measurable wall-clock latency (multiple ms) to every request, including cache
HITs that are otherwise microseconds of CPU work.

Fix: cache the loaded RSC and SSR module handles at module scope.
- _cachedRscModule: cached in the browser/SSR entry; populated on first request
- _ssrEntryCache / _loadSSREntry(): cached in the RSC entry; used at all 6 call sites
Both caches are reset to null in import.meta.hot.accept() so HMR (file changes
triggering invalidateRscEntryModule()) automatically reloads fresh modules.
…e handler

Adds structured console.log timing to the Cloudflare Workers production path
so wrangler tail shows per-phase wall-clock latency for every request:

  [vinext:timing] GET /path total=Xms mw=Xms route_match=Xms isr_check=Xms build_page=Xms compile=Xms render=Xms cache=HIT/MISS/STALE
  [vinext:kv] key=... result=HIT/MISS/STALE/TAG_INVALIDATED entry_get=Xms tag_check=Xms tags=N

This makes it possible to diagnose which phase is responsible for high wall
time in production Workers deployments via wrangler tail.
Without ctx.waitUntil, the floating background regen promise was blocking
the response from being flushed to the browser on STALE hits — causing the
1.5s 'waiting for server response' the browser was seeing even though the
cached content was ready immediately after the KV read.

- app-router-entry.ts: accept ExecutionContext ctx and pass it to rscHandler
- app-dev-server.ts: thread ctx from handler() → _handleRequest() → all four
  triggerBackgroundRegeneration() call sites
- isr-cache.ts: accept optional ctx in triggerBackgroundRegeneration() and
  call ctx.waitUntil(promise) so the runtime decouples the regen from the
  response stream
Adds console.log timing to all four background regen paths so wrangler tail
shows exactly where the ~1.2s regen time is spent:

  [vinext:regen] path=/en/download type=rsc-stale build=Xms ssr_load=Xms render=Xms html_drain=Xms kv_set=Xms total=Xms

Phases:
  build     — buildPageElement (route handler import + React tree construction)
  ssr_load  — _loadSSREntry (SSR module load, cached after first call)
  render    — renderToReadableStream + handleSsr Promise.all (RSC + HTML render)
  html_drain — reading the HTML stream to completion
  kv_set    — isrSet / KV write latency
  rsc_drain — (rsc-miss only) draining the RSC stream from the original request
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