Skip to content

fix(app-router): cache default static pages#2537

Draft
james-elicx wants to merge 5 commits into
mainfrom
codex/fix-default-static-page-cacheability
Draft

fix(app-router): cache default static pages#2537
james-elicx wants to merge 5 commits into
mainfrom
codex/fix-default-static-page-cacheability

Conversation

@james-elicx

Copy link
Copy Markdown
Member

Summary

  • treat App Router pages with no explicit dynamic or revalidate config as indefinitely cacheable by default, matching Next.js static-generation semantics
  • preserve render-time dynamic bailouts and prevent CDN cache headers from escaping before late request API usage is known
  • store indefinite revalidation using Next.js's JSON-safe sentinel so KV and other serialized cache adapters preserve the policy
  • add unit and production-server regressions for config-less static pages, dynamic bailouts, CDN-managed responses, and KV serialization

Next.js parity

Next.js initializes App Router prerender work with INFINITE_CACHE and only reduces it through explicit revalidation, uncached fetches, or dynamic request API usage. Vinext instead converted an unspecified route revalidation value to 0 on cache reads/writes, causing otherwise fully static pages to rerender and never produce reusable artifacts.

Validation

  • vp check on all changed source and test files
  • vp test run tests/app-page-*.test.ts tests/isr-cache.test.ts tests/isr-decision.test.ts tests/cache-control.test.ts tests/cdn-cache.test.ts tests/cloudflare-cdn-cache.test.ts tests/kv-cache-handler.test.ts (671 tests)
  • vp test run tests/app-router-production-server.test.ts (68 tests)
  • git diff --check

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@pkg-pr-new

pkg-pr-new Bot commented Jul 4, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@vinext/cloudflare@2537
npm i https://pkg.pr.new/create-vinext-app@2537
npm i https://pkg.pr.new/vinext@2537

commit: 4e04326

@github-actions

github-actions Bot commented Jul 4, 2026

Copy link
Copy Markdown
Contributor
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
workers-cache preview production
web preview production

@github-actions

github-actions Bot commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Performance benchmarks

Compared 4e04326 against base fd1cc3d using alternating same-runner rounds. Next.js was unchanged and skipped.

0 improved · 0 regressed · 6 within ±1.5%

Scenario Framework Baseline Current Change
Client bundle size (gzip) vinext 126.3 KB 126.3 KB ⚫ -0.0%
Client entry size (gzip) vinext 120.3 KB 120.3 KB ⚫ -0.0%
Dev server cold start vinext 2.62 s 2.60 s ⚫ -0.7%
Production build time vinext 3.12 s 3.10 s ⚫ -0.6%
RSC entry closure size (gzip) vinext 97.5 KB 97.7 KB ⚫ +0.2%
Server bundle size (gzip) vinext 164.0 KB 164.2 KB ⚫ +0.2%

View detailed results and traces

🟢 improvement · 🔴 regression · ⚫ change below 1.5% · paired base/head

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review — fix(app-router): cache default static pages

I reviewed the diff against main, traced the read/write/response paths, verified Next.js parity for the sentinel, and ran the targeted suites (app-page-*, isr-cache, isr-decision, cache-control, cdn-cache, cloudflare-cdn-cache, kv-cache-handler, app-router-production-server). Everything passes and vp check is clean on all touched source files. The core fix is correct and well-tested.

What the change gets right

  • Sentinel parity. INFINITE_CACHE_SECONDS = 0xfffffffe matches Next.js's INFINITE_CACHE in packages/next/src/lib/constants.ts exactly (chosen because it is a V8 SMI and JSON-serializable). Good call routing indefinite revalidation through it so KV/serialized adapters preserve the policy (kv-cache-handler round-trip test confirms "revalidate":4294967294).
  • Consistent sentinel handling on read. Every place that builds Cache-Control from a stored revalidate now special-cases both Infinity and INFINITE_CACHE_SECONDS (cache-control.ts:98, isr-decision.ts:104, isr-decision.ts:172). The huge finite value also degrades gracefully in MemoryCacheHandlerrevalidateAt = now + 0xfffffffe*1000 (~136 years) is effectively never-stale, matching intent.
  • Response-policy ordering preserved. In resolveAppPageRscResponsePolicy/resolveAppPageHtmlResponsePolicy the revalidateSeconds === null branch correctly keeps force-static / dynamic-error as STATIC while promoting the default (neither) case to MISS in production. The dynamic-usage precedence check still runs first, so late dynamic bailouts stay no-store.
  • Late-dynamic CDN header safety. The new dynamicUsageCheckComplete plumbing is sound: on edge / non-origin-revalidating adapters the code settles the render (RSC path) or buffers HTML (HTML path) to prove non-dynamic before emitting cacheable CDN-Cache-Control. When dynamic is detected late, scheduleAppPageRscCacheWrite early-returns and the no-store policy is preserved — covered by does not expose cacheable CDN headers when default-config HTML becomes dynamic late.

Things worth confirming (non-blocking)

  1. HTML buffering trades away streaming for edge default-static pages. app-page-render.ts:1097 fully buffers the HTML via readStreamAsText and re-wraps it (new Response(bufferedHtml).body!) whenever isProduction && !dynamicUsedDuringRender && shouldCaptureRscForCacheMetadata && (isEdgeRuntime || !ownsBackgroundRevalidation). For the default Node adapter (ownsBackgroundRevalidation = true, non-edge) this block is skipped and the existing shouldSpeculativelyWriteCache path handles it without buffering, so common Node deploys keep streaming. But on Cloudflare Workers (edge) every config-less static page now buffers its whole body in memory before the first byte is sent. This is a defensible tradeoff (the CDN can't background-revalidate at the origin, so dynamic usage must be resolved before cacheable headers escape, and Next.js likewise fully renders during static generation), but it is a real change to first-byte latency / memory for those pages. Confirm this is intentional and consider a short code comment on the buffering block explaining why edge must buffer, since the guard condition is subtle.

  2. readStreamAsText decode/re-encode round-trip. Buffering goes bytes → UTF-8 text → bytes. readStreamAsText uses decoder.decode(value, { stream: true }) so multi-byte sequences split across chunks are handled correctly, and HTML is always UTF-8 here, so this is safe. Flagging only because it's easy to miss that the body is now decoded/re-encoded rather than passed through.

  3. Dev RSC default-static policy change. resolveAppPageRscResponsePolicy for revalidateSeconds === null in dev now returns { cacheControl: STATIC_CACHE_CONTROL, cacheState: "STATIC" } instead of the previous {}. This is harmless (dev responses aren't CDN-cached and nothing writes the cache in dev) and matches what the HTML policy already did, but there's no unit test pinning the dev branch. A one-line assertion would lock it in.

Verdict

The fix correctly restores Next.js static-generation semantics (default → indefinitely cacheable, demoted only by explicit config or dynamic usage) and closes a real bug where fully static pages never produced reusable artifacts. Test coverage is strong across unit + production-server layers. No correctness blockers found. The buffering tradeoff (#1) is the only item I'd want an explicit sign-off / comment on before merge.

@ask-bonk

ask-bonk Bot commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2537. Summary of my findings:

Review outcome: No correctness blockers

The fix correctly restores Next.js static-generation semantics — App Router pages with no explicit dynamic/revalidate config are now treated as indefinitely cacheable by default, demoted to no-store only by explicit config or dynamic usage. It closes a real bug where fully static pages were converted to revalidate: 0 and never produced reusable cache artifacts.

What's solid:

  • INFINITE_CACHE_SECONDS = 0xfffffffe exactly matches Next.js's INFINITE_CACHE sentinel (verified against Next.js source), and it's consistently special-cased in every read-path cache-control builder.
  • The sentinel round-trips through KV serialization and degrades gracefully in MemoryCacheHandler (≈136 years → effectively never stale).
  • The late-dynamic-bailout protection (dynamicUsageCheckComplete plumbing) correctly prevents cacheable CDN headers from escaping before dynamic usage is known.
  • Strong test coverage across unit + production-server layers; all targeted suites pass and vp check is clean.

Non-blocking items I flagged for author sign-off:

  1. HTML buffering on edge — default-static pages on Cloudflare Workers now buffer the full HTML body in memory before responding (skipped for the common Node adapter). Defensible tradeoff, but worth an explicit confirmation + a code comment explaining the subtle guard condition.
  2. readStreamAsText decode/re-encode round-trip is safe (correct chunk-boundary handling), just easy to overlook.
  3. Dev RSC default-static policy changed from {} to STATIC — harmless but untested; a one-line assertion would lock it in.

github run

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review — fix(app-router): cache default static pages

I traced the read/write/response/finalizer paths, checked dev/prod parity, verified the Next.js sentinel, and ran the targeted suites (app-page-dispatch, app-page-response, app-page-cache, app-page-render, isr-cache, isr-decision, cache-control, kv-cache-handler, cdn-cache, cloudflare-cdn-cache, and the app-router-production-server static tests). Everything passes and vp check is clean on all seven changed source files. The fix is correct and I found no correctness blockers.

What I verified

  • Sentinel parity. INFINITE_CACHE_SECONDS = 0xfffffffe matches Next.js's INFINITE_CACHE. It is consistently special-cased alongside Infinity in every read-path builder (cache-control.ts:98, isr-decision.ts:104, isr-decision.ts:172) and round-trips through KV serialization (kv-cache-handler test confirms "revalidate":4294967294).
  • cacheLife-only routes are unaffected by the read-seed change. resolveAppPageCacheReadRevalidateSeconds changed the null seed from 0Infinity. I confirmed this is behavior-preserving for regeneration: resolveRegeneratedAppPageCachePolicy (app-page-cache.ts:208-214) previously took renderRevalidateSeconds because 0 > 0 was false; now it takes Math.min(Infinity, renderRevalidateSeconds) = the same value. HIT cache-control still derives from the stored cacheControlMeta.revalidate ?? seed, so a cacheLife entry keeps its own duration.
  • Write policy default. resolveAppPageCacheWritePolicy now defaults nullINFINITE_CACHE_SECONDS and only returns null for NaN/≤0. Math.min(INFINITE_CACHE_SECONDS, requestCacheLife.revalidate) correctly narrows to the explicit cacheLife value. This is the core fix — config-less static pages now produce reusable artifacts instead of being demoted to revalidate: 0.
  • Late-dynamic CDN safety. The dynamicUsageCheckComplete plumbing is sound. On edge / non-origin-revalidating adapters, the RSC path settles the captured render and the HTML path buffers via readStreamAsText to prove non-dynamic before pendingDynamicCheck flips to false and cacheable CDN-Cache-Control escapes. settleCapturedRscRenderForCacheMetadata handles the null promise gracefully, and in the RSC branch capturedRscDataRef.value is guaranteed set whenever shouldCaptureRscForCacheMetadata is true (sideStream is created under the same condition), so the settle is never a silent no-op there.
  • readStreamAsText round-trip decodes with { stream: true } and a final flush, so multi-byte UTF-8 sequences split across chunks are handled — the bytes→text→bytes buffering is safe.

Non-blocking observations

  1. Example home page switched to force-dynamic (examples/app-router-cloudflare/app/page.tsx:3). This is required by the fix, not incidental: the home page renders new Date().toISOString() under data-testid="timestamp", and two E2E tests assert the timestamp differs across requests (tests/e2e/cloudflare-workers/ssr.spec.ts:36, tests/e2e/cloudflare-workers/interactive.spec.ts:69). Without force-dynamic, default-static caching would serve identical timestamps and break both. The change keeps those tests valid. Trade-off: the flagship example no longer demonstrates the new default-static behavior — worth a one-line comment on the export so a future reader doesn't "clean it up" and silently break the E2E suite.

  2. Edge default-static pages now buffer the full HTML body in memory before the first byte (app-page-render.ts:1100). This is skipped for the default Node adapter (ownsBackgroundRevalidation), so common Node deploys keep streaming, but Cloudflare Workers now pay first-byte-latency/memory for every config-less static page. The e6d243b commit already added a code comment explaining why edge must buffer — good. Just confirm the latency trade-off is intentional for the edge target.

  3. Redundant-but-harmless write guard. app-page-render.ts:1157 adds (dynamicUsageCheckComplete && htmlResponsePolicy.cacheState === "MISS"). For the default-null production branch, resolveAppPageHtmlResponsePolicy already returns shouldWriteToCache: true whenever cacheState === "MISS", so the added disjunct never independently triggers a write today. Not a bug — just defensive. Fine to keep.

  4. Dev RSC default-static branch is untested. resolveAppPageRscResponsePolicy for revalidateSeconds === null in dev now returns { cacheControl: STATIC, cacheState: "STATIC" } for force-static/dynamic-error and {} for the plain default. Harmless (dev isn't CDN-cached), but a one-line assertion pinning the dev branch would lock it in.

Verdict

Correct restoration of Next.js static-generation semantics (default → indefinitely cacheable, demoted only by explicit config or dynamic usage), with strong unit + production-server coverage. No blockers. The only items worth an explicit author sign-off are the edge-buffering latency trade-off (#2) and a comment on the example's force-dynamic (#1) so it isn't accidentally reverted.

@ask-bonk

ask-bonk Bot commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2537.

Summary

I reviewed the fix that makes App Router pages with no explicit dynamic/revalidate config indefinitely cacheable by default (matching Next.js static-generation semantics). No correctness blockers found — the fix is correct and well-tested.

What I verified beyond the prior review:

  • cacheLife-only routes are unaffected by the read-seed change (nullInfinity instead of 0). Traced through resolveRegeneratedAppPageCachePolicyMath.min(Infinity, renderRevalidate) yields the same value the old > 0 ? min : render branch did.
  • Write policy default correctly narrows INFINITE_CACHE_SECONDS down to any explicit cacheLife.revalidate via Math.min.
  • Late-dynamic CDN safety plumbing is sound; the settleCapturedRscRenderForCacheMetadata(null) case is handled and the RSC-branch capturedRscDataRef.value is guaranteed set under the same guard.
  • Ran targeted suites (10 files) + prod-server static tests — all pass; vp check clean.

Notable finding I added: The example home page's switch to force-dynamic is required, not incidental — two E2E tests (ssr.spec.ts:36, interactive.spec.ts:69) assert the home page timestamp differs across requests, which default-static caching would break. Flagged that it deserves a comment so it isn't accidentally reverted.

Non-blocking items: edge HTML buffering trade-off (already documented in a follow-up commit), a redundant-but-harmless write guard at app-page-render.ts:1157, and an untested dev RSC default-static branch.

github run

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review — fix(app-router): cache default static pages

I independently traced the read/write/response/finalizer paths, verified the Next.js sentinel and the null vs Infinity distinction, checked dev/prod parity, and re-ran the targeted suites (app-page-render, app-page-response, app-page-cache, app-page-dispatch = 188 tests; isr-cache, isr-decision, cache-control, kv-cache-handler, cdn-cache, cloudflare-cdn-cache = 206 tests). All pass and vp check is clean on all seven changed source files. The fix is correct — no correctness blockers found.

What I verified beyond the prior reviews

  • Infinity branches are still live, not dead code. The new revalidateSeconds === null branch handles the default-unspecified case; the existing revalidateSeconds === Infinity branch (app-page-response.ts:172, :266) still handles explicit revalidate = false, which normalizes to Infinity upstream (app-page-dispatch.ts:395, app-route-handler-policy.ts:69). The two cases are genuinely distinct and both covered.

  • Write path stays guarded for dynamic pages. resolveAppPageCacheWritePolicy now seeds null → INFINITE_CACHE_SECONDS, but the HTML write still bails on capturedDynamicUsageBeforeContextCleanup()/consumeDynamicUsage() (app-page-cache-finalizer.ts:157-163) and the RSC write bails on dynamicUsedDuringBuild (:263). Default-static pages that turn out dynamic are not written.

  • Edge buffering ordering is sound. readStreamAsText(safeHtmlStream) drains the deferUntilStreamConsumed wrapper, whose flush() fires before the read reports done, so dynamicUsedBeforeContextCleanup / context-clear have already run when line 1102 re-reads dynamic usage. The re-wrapped new Response(bufferedHtml).body! is a plain buffered stream, so there is no double-flush or duplicate clearRequestContext() downstream.

  • pendingDynamicCheck inversion is correct. dynamicUsageCheckComplete === true sets pendingDynamicCheck: false, which lets the real cacheable Cache-Control escape only after dynamic usage is definitively resolved. The becomes dynamic late test confirms cache-control: no-store + no cdn-cache-control + no isrSet when late dynamic usage is detected on the Cloudflare adapter.

  • Dev default-static branch is now tested. The prior review flagged the dev RSC null branch as untested; the new keeps default-config pages uncached in development case (app-page-response.test.ts) pins both the RSC ({}) and HTML ({ shouldWriteToCache: false }) dev outcomes.

  • Sentinel round-trips + degrades gracefully. INFINITE_CACHE_SECONDS = 0xfffffffe serializes as 4294967294 (kv-cache test) and yields revalidateAt = now + ~4.29e9 * 1000 (~136 yrs) in MemoryCacheHandler (cache-handler.ts:299-302) — effectively never-stale, matching intent.

Non-blocking (already acknowledged by the author's follow-up commits)

  1. Edge HTML/RSC buffering trade-off. Config-less static pages on Cloudflare Workers now fully buffer the body (HTML via readStreamAsText, RSC via settling the captured render) before the first byte, to prove non-dynamic before cacheable headers escape. This is skipped for the default Node adapter (ownsBackgroundRevalidation), so common Node deploys keep streaming. Commit e6d243b already added the explaining comment — the first-byte-latency trade-off for edge is the only thing worth an explicit sign-off.

  2. Redundant-but-harmless write guard at app-page-render.ts:1158 (dynamicUsageCheckComplete && cacheState === "MISS") never independently triggers a write today because the null/MISS policy already sets shouldWriteToCache: true. Defensive; fine to keep.

Verdict

Correct restoration of Next.js static-generation semantics (default → indefinitely cacheable via the JSON-safe sentinel, demoted only by explicit config or dynamic usage), with strong unit + production-server coverage and no dev/prod parity regressions. Approve on correctness. Only the edge-buffering latency trade-off (#1) warrants an explicit author sign-off before merge.

@ask-bonk

ask-bonk Bot commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2537.

Summary

I independently reviewed the fix that makes App Router pages with no explicit dynamic/revalidate config indefinitely cacheable by default. No correctness blockers found — the fix is correct and well-tested.

Key things I verified beyond the prior reviews:

  • The Infinity response-policy branches are not dead code. The new revalidateSeconds === null branch handles the default-unspecified case, while the existing === Infinity branch still handles explicit revalidate = false (which normalizes to Infinity upstream at app-page-dispatch.ts:395 / app-route-handler-policy.ts:69). Both paths are distinct and covered.

  • The write path stays guarded for dynamic pages despite the new null → INFINITE_CACHE_SECONDS seed — HTML/RSC writes still bail on dynamic usage.

  • Edge buffering ordering is soundreadStreamAsText drains the deferUntilStreamConsumed wrapper whose flush() fires before the read completes, so dynamic usage and context-clear run before the re-check. The re-wrapped buffered stream causes no double-flush.

  • pendingDynamicCheck inversion is correct — cacheable headers only escape after dynamic usage is definitively resolved.

  • The dev default-static branch is now tested (the prior review flagged it as untested; the new keeps default-config pages uncached in development case pins it).

  • Sentinel round-trips through KV (4294967294) and degrades to ~136 years in MemoryCacheHandler — effectively never-stale.

Ran targeted suites (394 tests across 10 files) — all pass; vp check clean on all 7 source files. The only item warranting explicit author sign-off is the edge HTML/RSC buffering latency trade-off, which commit e6d243b already documents.

github run

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