kvDataAdapter rejects its own entries for static App Router pages (revalidate: Infinity is stored as null) since 1.0.0-beta.9
Summary
Since [email protected], a statically generated App Router page is written to the data cache with cacheControl.revalidate: Infinity. kvDataAdapter stores the entry with JSON.stringify, which turns Infinity into null. On the next read, validateCacheEntry requires cacheControl.revalidate to be a number, so the adapter rejects its own entry, logs Invalid cache entry shape, deletes it, and returns a miss.
The page still renders, but every request to it:
- reads the KV entry, rejects it and logs
console.error - deletes the entry in the background
- re-renders and writes the full HTML back to KV
The page is never served from the data cache, and every page view becomes a KV write.
Versions
| Package | Version |
|---|---|
vinext |
1.0.0-beta.10 (first bad: 1.0.0-beta.9) |
@vinext/cloudflare |
1.0.0-beta.8 |
@cloudflare/vite-plugin |
1.54.11 |
vite |
8.3.0 |
@vitejs/plugin-rsc |
0.5.35 |
react / react-dom |
19.3.0 |
wrangler |
4.133.0 |
Reproduction
A minimal app: one dynamic segment with generateStaticParams, and the KV data adapter.
package.json
{
"name": "vinext-kv-infinity-repro",
"private": true,
"type": "module",
"scripts": {
"build": "vinext build",
"preview": "wrangler dev"
},
"dependencies": {
"react": "^19.2.8",
"react-dom": "^19.2.8",
"vinext": "1.0.0-beta.10"
},
"devDependencies": {
"@cloudflare/vite-plugin": "^1.52.1",
"@vinext/cloudflare": "1.0.0-beta.8",
"@vitejs/plugin-rsc": "^0.5.34",
"react-server-dom-webpack": "^19.2.8",
"vite": "^8.2.1",
"wrangler": "^4.123.0"
}
}vite.config.ts
import { defineConfig } from "vite";
import vinext from "vinext";
import { cloudflare } from "@cloudflare/vite-plugin";
import { kvDataAdapter } from "@vinext/cloudflare/cache/kv-data-adapter";
export default defineConfig({
plugins: [
vinext({ cache: { data: kvDataAdapter() } }),
cloudflare({ viteEnvironment: { name: "rsc", childEnvironments: ["ssr"] } }),
],
});wrangler.jsonc
{
"name": "vinext-kv-infinity-repro",
"main": "vinext/server/app-router-entry",
"compatibility_date": "2026-08-03",
"compatibility_flags": ["nodejs_compat"],
"assets": { "binding": "ASSETS", "not_found_handling": "none" },
"kv_namespaces": [{ "binding": "VINEXT_KV_CACHE", "id": "00000000000000000000000000000000" }]
}app/layout.tsx
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}app/[lang]/page.tsx
export function generateStaticParams() {
return [{ lang: "en" }, { lang: "fr" }];
}
export default async function Page({ params }: { params: Promise<{ lang: string }> }) {
const { lang } = await params;
return <h1>Hello ({lang})</h1>;
}Steps:
bun install && bun run build
wrangler dev
# in another shell, request the same page a few times
curl -s -o /dev/null -D - http://localhost:8787/en # repeatObserved
Every request is a miss:
req 1: Cache-Control: no-store, must-revalidate X-Vinext-Cache: MISS
req 2: Cache-Control: no-store, must-revalidate X-Vinext-Cache: MISS
req 3: Cache-Control: no-store, must-revalidate X-Vinext-Cache: MISS
req 4: Cache-Control: no-store, must-revalidate X-Vinext-Cache: MISSThe wrangler dev log shows one error per read after the first write:
✘ [ERROR] [vinext] Invalid cache entry shape for key: app:<build-id>:/en:htmlThe stored entry, with value omitted:
{
"tags": ["/en", "_N_T_/en", "_N_T_/layout", "_N_T_/[lang]/layout", "_N_T_/[lang]/page"],
"lastModified": 1789640334484,
"revalidateAt": null,
"expireAt": 1821176334484,
"cacheControl": { "revalidate": null, "expire": 31536000 }
}set() builds cacheControl only when typeof effectiveRevalidate === "number", so a revalidate: null inside it can only come from a non-finite number serialised by JSON.stringify. revalidateAt is null for the same reason (now + Infinity * 1000).
Expected
The entry round-trips, and the second request is served from the data cache without an error. Before beta.9 these pages were not written to the data cache at all, so the mismatch never surfaced.
Bisect
Same app, wrangler dev, the same page requested four times:
vinext |
@vinext/cloudflare |
Page entry written to KV | Invalid cache entry shape errors |
|---|---|---|---|
| 1.0.0-beta.6 | 1.0.0-beta.6 | no | 0 |
| 1.0.0-beta.8 | 1.0.0-beta.6 | no | 0 |
| 1.0.0-beta.9 | 1.0.0-beta.7 | yes | 3 |
| 1.0.0-beta.10 | 1.0.0-beta.6 | yes | 3 |
| 1.0.0-beta.10 | 1.0.0-beta.8 | yes | 3 |
The adapter's set() and validateCacheEntry logic is the same in @vinext/cloudflare beta.6 and beta.8. The change in beta.9 is that core now writes these static pages to the data cache, which exposes the round-trip bug. (All rows were bisected on a larger app with the same route shape. Rows 2 and 5 were also confirmed on the minimal app above.)
Where
On current main (ebbebfc):
set()storesrevalidate: effectiveRevalidateas-is, includingInfinity: https://github.com/cloudflare/vinext/blob/ebbebfc1c70c96e277cf6f351c5cfb52b33e68d0/packages/cloudflare/src/cache/kv-data-adapter.runtime.ts#L482-L491validateCacheEntryrejects a non-numbercacheControl.revalidate: https://github.com/cloudflare/vinext/blob/ebbebfc1c70c96e277cf6f351c5cfb52b33e68d0/packages/cloudflare/src/cache/kv-data-adapter.runtime.ts#L687
Possible fix
JSON cannot carry Infinity, so the adapter needs to encode it explicitly. For example: write a non-finite revalidate as null (or a sentinel), accept that value in validateCacheEntry, and restore it to Infinity in get() before returning cacheControl. A round-trip test that runs set() with cacheControl: { revalidate: Infinity } and then get() would catch a regression.
Impact
Besides the log noise, each request to such a page does a KV read, a delete and a put of the full HTML. KV writes are billed, and a key accepts at most one write per second, so a popular page competes with itself.
Source: cloudflare/vinext