[PR #865] [MERGED] fix: emit no-store Cache-Control for revalidate = 0 route handlers #907

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

📋 Pull Request Information

Original PR: https://github.com/cloudflare/vinext/pull/865
Author: @NathanDrake2406
Created: 4/21/2026
Status: Merged
Merged: 4/21/2026
Merged by: @james-elicx

Base: mainHead: fix/route-handler-revalidate-zero


📝 Commits (2)

  • 726c36a test: cover revalidate = 0 route handler never-cache semantics
  • 1b4faba fix: emit no-store Cache-Control for revalidate = 0 route handlers

📊 Changes

4 files changed (+90 additions, -7 deletions)

View changed files

📝 packages/vinext/src/server/app-route-handler-policy.ts (+19 -5)
📝 packages/vinext/src/server/app-route-handler-response.ts (+12 -0)
📝 tests/app-route-handler-policy.test.ts (+42 -2)
📝 tests/app-route-handler-response.test.ts (+17 -0)

📄 Description

What this changes

Route handlers exporting export const revalidate = 0 now emit the canonical no-store Cache-Control header (private, no-cache, no-store, max-age=0, must-revalidate) that Next.js emits in the same situation. Previously they emitted no Cache-Control at all.

Why

getAppRouteHandlerRevalidateSeconds collapsed revalidate = 0 into null by filtering on > 0. Every downstream gate (shouldReadAppRouteHandlerCache, shouldApplyAppRouteHandlerRevalidateHeader, shouldWriteAppRouteHandlerCache) short-circuits when revalidateSeconds === null, so the framework skipped the header write. CDNs and browsers then applied heuristic caching to a response the author had explicitly opted out of.

The page path already handles this correctly: resolveAppPageRscResponsePolicy and resolveAppPageHtmlResponsePolicy each branch on revalidateSeconds === 0 and return a no-store policy. The route handler path was the asymmetric one.

Approach

Preserve 0 as a distinct value in the policy helper. Use Number.isFinite so NaN and Infinity still fall to null alongside negatives.

Split the three downstream gates along semantic lines:

  • shouldReadAppRouteHandlerCache and shouldWriteAppRouteHandlerCache keep > 0. A never-cache handler must not serve from ISR and must not persist to ISR.
  • shouldApplyAppRouteHandlerRevalidateHeader keeps !== null. It must fire for 0 so the header is written.
  • buildRouteHandlerCacheControl branches on 0 first and returns the canonical Next.js no-store string (matches .nextjs-ref/packages/next/src/server/lib/cache-control.ts:28-29).

Non-goals: did not touch the App Router page paths (they were already correct), the Pages Router buildPagesCacheResponse helper (separate codepath, separate review), or the generated RSC entry (no change needed, the helper changes flow through).

Validation

  • vp test run tests/app-route-handler-policy.test.ts tests/app-route-handler-response.test.ts tests/app-route-handler-cache.test.ts tests/app-route-handler-execution.test.ts - 26 passing.
  • vp check on the four touched files - clean.
  • The tests were committed ahead of the fix so they exercised the bug. Against the previous implementation, tests/app-route-handler-policy.test.ts fails with expected 0, got null on the helper, expected true to be false on the read/write gates with revalidateSeconds: 0, and tests/app-route-handler-response.test.ts fails with expected "private, no-cache, no-store, max-age=0, must-revalidate" to be "s-maxage=0, stale-while-revalidate".

Risks / follow-ups

  • The Pages Router buildPagesCacheResponse (packages/vinext/src/server/pages-page-data.ts:163) uses an SWR template without a 0 branch. It is called with a revalidateSeconds derived from getStaticProps, which has different semantics, so it is out of scope here. Worth a follow-up audit.
  • resolveAppRouteHandlerSpecialError (packages/vinext/src/server/app-route-handler-policy.ts:147) and the equivalent helper in app-page-execution.ts currently parse NEXT_REDIRECT digests without validating the number of ;-delimited parts. A malformed digest will silently decode undefined or parse NaN as a status code. Not touched in this PR. Open issue / follow-up work.

🔄 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/865 **Author:** [@NathanDrake2406](https://github.com/NathanDrake2406) **Created:** 4/21/2026 **Status:** ✅ Merged **Merged:** 4/21/2026 **Merged by:** [@james-elicx](https://github.com/james-elicx) **Base:** `main` ← **Head:** `fix/route-handler-revalidate-zero` --- ### 📝 Commits (2) - [`726c36a`](https://github.com/cloudflare/vinext/commit/726c36a88393b06cbc10188dde7aa4c51c041391) test: cover revalidate = 0 route handler never-cache semantics - [`1b4faba`](https://github.com/cloudflare/vinext/commit/1b4faba9c879121fcc8a6ca548356485c8b3965e) fix: emit no-store Cache-Control for revalidate = 0 route handlers ### 📊 Changes **4 files changed** (+90 additions, -7 deletions) <details> <summary>View changed files</summary> 📝 `packages/vinext/src/server/app-route-handler-policy.ts` (+19 -5) 📝 `packages/vinext/src/server/app-route-handler-response.ts` (+12 -0) 📝 `tests/app-route-handler-policy.test.ts` (+42 -2) 📝 `tests/app-route-handler-response.test.ts` (+17 -0) </details> ### 📄 Description ## What this changes Route handlers exporting `export const revalidate = 0` now emit the canonical no-store `Cache-Control` header (`private, no-cache, no-store, max-age=0, must-revalidate`) that Next.js emits in the same situation. Previously they emitted no `Cache-Control` at all. ## Why `getAppRouteHandlerRevalidateSeconds` collapsed `revalidate = 0` into `null` by filtering on `> 0`. Every downstream gate (`shouldReadAppRouteHandlerCache`, `shouldApplyAppRouteHandlerRevalidateHeader`, `shouldWriteAppRouteHandlerCache`) short-circuits when `revalidateSeconds === null`, so the framework skipped the header write. CDNs and browsers then applied heuristic caching to a response the author had explicitly opted out of. The page path already handles this correctly: `resolveAppPageRscResponsePolicy` and `resolveAppPageHtmlResponsePolicy` each branch on `revalidateSeconds === 0` and return a no-store policy. The route handler path was the asymmetric one. ## Approach Preserve `0` as a distinct value in the policy helper. Use `Number.isFinite` so `NaN` and `Infinity` still fall to `null` alongside negatives. Split the three downstream gates along semantic lines: - `shouldReadAppRouteHandlerCache` and `shouldWriteAppRouteHandlerCache` keep `> 0`. A never-cache handler must not serve from ISR and must not persist to ISR. - `shouldApplyAppRouteHandlerRevalidateHeader` keeps `!== null`. It must fire for `0` so the header is written. - `buildRouteHandlerCacheControl` branches on `0` first and returns the canonical Next.js no-store string (matches `.nextjs-ref/packages/next/src/server/lib/cache-control.ts:28-29`). Non-goals: did not touch the App Router page paths (they were already correct), the Pages Router `buildPagesCacheResponse` helper (separate codepath, separate review), or the generated RSC entry (no change needed, the helper changes flow through). ## Validation - `vp test run tests/app-route-handler-policy.test.ts tests/app-route-handler-response.test.ts tests/app-route-handler-cache.test.ts tests/app-route-handler-execution.test.ts` - 26 passing. - `vp check` on the four touched files - clean. - The tests were committed ahead of the fix so they exercised the bug. Against the previous implementation, `tests/app-route-handler-policy.test.ts` fails with `expected 0, got null` on the helper, `expected true to be false` on the read/write gates with `revalidateSeconds: 0`, and `tests/app-route-handler-response.test.ts` fails with `expected "private, no-cache, no-store, max-age=0, must-revalidate" to be "s-maxage=0, stale-while-revalidate"`. ## Risks / follow-ups - The Pages Router `buildPagesCacheResponse` (`packages/vinext/src/server/pages-page-data.ts:163`) uses an SWR template without a `0` branch. It is called with a `revalidateSeconds` derived from `getStaticProps`, which has different semantics, so it is out of scope here. Worth a follow-up audit. - `resolveAppRouteHandlerSpecialError` (`packages/vinext/src/server/app-route-handler-policy.ts:147`) and the equivalent helper in `app-page-execution.ts` currently parse `NEXT_REDIRECT` digests without validating the number of `;`-delimited parts. A malformed digest will silently decode `undefined` or parse `NaN` as a status code. Not touched in this PR. Open issue / follow-up work. --- <sub>🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.</sub>
BreizhHardware 2026-05-06 13:10:46 +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#907
No description provided.