* fix: make useParams() reactive on client-side navigation
useParams() was reading a module-level variable directly instead of
subscribing via useSyncExternalStore, so navigating between dynamic
routes (e.g. /posts/1 → /posts/2) wouldn't re-render components.
Two changes:
- useParams() now uses useSyncExternalStore with the shared listener
system, matching usePathname() and useSearchParams()
- setClientParams() now calls notifyListeners() when params change,
so the external store knows to re-check snapshots
* chore: fix formatting in test fixture
* fix: stabilize useParams server snapshot and deduplicate subscribe
- Add _EMPTY_PARAMS constant to avoid creating a new {} on every
getServerSnapshot call (Object.is would never match, causing
hydration churn)
- Extract getClientParamsSnapshot/getServerParamsSnapshot named
functions for consistency with usePathname/useSearchParams
- Extract subscribeToNavigation helper to deduplicate the identical
subscribe callback across all three hooks
On Cloudflare Workers the isolate can terminate after the Response is
sent, killing in-flight error reports (e.g. Sentry HTTP requests).
Fix by having reportRequestError() self-register with
ctx.waitUntil() via getRequestExecutionContext(). This is the same
pattern used by KVCacheHandler, ISR regeneration, and fetch cache
revalidation. On Node.js (dev or vinext start) the context is null
so it's a no-op — fire-and-forget is fine because the process stays
alive.
Since reportRequestError never rejects (internal try/catch), the
redundant .catch() at all 5 call sites is removed.
* fix: Pages Router ISR background regeneration re-renders HTML
Background regeneration only re-ran getStaticProps but cached the old
HTML, so server-rendered content was permanently stale after the first
revalidation cycle. The pageData JSON was updated but never used to
re-render the page.
Now the regeneration callback:
- Re-renders the page component with fresh props
- Rebuilds __NEXT_DATA__ with fresh props
- Updates the revalidate duration map (dev server)
- Passes full locale context to getStaticProps
Fixed in both dev server and production server entry.
* ci: retrigger checks
* fix: preserve document shell during prod ISR regeneration
The prod entry's regeneration was hardcoding a simple HTML suffix,
losing custom _document content between <Main /> and <NextScript />.
Now splits the cached HTML at known markers (<div id="__next"> and
__NEXT_DATA__ script) to preserve the full document structure: head
section, gap content, and tail are carried forward from the original
cached entry.
Also updates the entry-templates snapshot.
* ci: retrigger checks
* ci: retrigger checks
The dev server read middleware custom response bodies with `text()`,
which decodes bytes as UTF-8. This corrupts any binary response body
(images, protobuf, etc.) that middleware returns via `new Response(data)`.
The prod server already uses `arrayBuffer()` + `Buffer.from()` correctly.
Apply the same approach to the dev path.
Also adds middleware fixture routes for binary responses and multi-cookie
responses, with corresponding regression tests.
If SSR begins streaming then throws, res.writeHead(500) is called on an
already-sent response, causing a silent socket error. The App Router path
already has this guard — add the same check to the Pages Router path.
* fix: exclude private folders (_prefix) from App Router route discovery
Next.js treats underscore-prefixed directories in app/ as "private
folders" that are excluded from routing. This is a recommended pattern
for colocating non-route files (e.g. _components, _lib, _utils).
vinext was only excluding @-prefixed slot directories, so files like
app/_components/page.tsx would incorrectly create routes.
Add the underscore check to all four App Router filesystem scan points:
- Main page/route discovery (scanWithExtensions exclude callback)
- Slot sub-page scanner (already had it)
- Intercepting route scanner (scanForInterceptingPages)
- Intercepting page collector (collectInterceptingPages)
* chore: format app-router.ts
* fix: add headersSent guard in API route error handler
If a Pages Router API handler partially writes a response (e.g.
res.writeHead(200)) and then throws, the catch block would crash with
ERR_HTTP_HEADERS_SENT when trying to write a 500/413 response.
This matches the existing guard pattern in prod-server.ts and index.ts.
* fix: end response when headers already sent but stream not closed
If a handler calls res.writeHead()/res.write() then throws, headersSent
is true but writableEnded is false. Without res.end() the connection
hangs until timeout. Now we close the response in that case.
* chore: retrigger CI
* fix: call reportRequestError in Pages Router prod entry
Pages Router production (pages-server-entry.ts) was missing
reportRequestError() calls in the SSR and API catch blocks.
Dev (dev-server.ts, api-handler.ts) and App Router prod
(app-rsc-entry.ts) both reported errors, so instrumentation
hooks like Sentry's onRequestError silently stopped firing
in Pages Router production only.
* fix: use original URL path in SSR error report, not locale-stripped
routeUrl has the locale prefix stripped by extractLocale(), so i18n
requests like /fr/about would be reported as /about. Use url (the
original request path) to match dev-server.ts behavior.
* feat(build): add Next.js-style route report to vinext build output
- Adds build/report.ts with regex-based route classification (static, ISR, dynamic, unknown, API)
- Clean build logger suppresses Vite/Rollup noise; keeps timing lines and errors
- --verbose flag bypasses filtering and shows raw output
- Routes sorted alphabetically by path (filesystem order)
- Legend sorted alphabetically by label
- Unknown routes show a note explaining classification will improve in a future release
- 55 unit tests covering all classification paths and formatting
* fmt
* fix exmaple
* support -v
* .
* .
* fix(build-report): address review comments
- dynamic='error' now classifies as static (not ssr) — it enforces static
rendering by throwing if dynamic APIs are used, same as force-static
- add TODO comment on extractGetStaticPropsRevalidate regex matching too broadly
- drop -v short alias for --verbose to avoid collision with -v/--version at top level
- classifyPagesRoute now returns unknown on file read failure, consistent with classifyAppRoute
- single-route table now renders with ┌ corner instead of └ (i===0 check takes priority)
- 3 new tests covering the above fixes (58 total)
* test(build-report): replace misleading classifyAppRoute tests with real fixture coverage
The two tests named 'classifies page with revalidate=0 as ssr' and
'classifies revalidate=Infinity as static' both passed null paths and
asserted unknown, not actually exercising the stated code paths.
Replace both with proper integration tests using new fixture files:
- revalidate-zero-test/page.tsx (export const revalidate = 0) → ssr
- revalidate-infinity-test/page.tsx (export const revalidate = Infinity) → static
* test: add regression tests for headers() and cookies() in server actions and route handlers
* test: address review comments on action-headers tests
- Remove ghost test that always passed via 404 bailout
- Remove unused getHeaderFromActionSync fixture export
- Fix extractReturnValue to parse full first line instead of greedy {.+} match
- Fix action ID extraction to match by export name instead of array index
- Add comment clarifying why the route handler test is distinct from existing ones
* test: address second round of review comments
- Use fetchHtml helper for page fetches (consistent with other nextjs-compat tests)
- Clarify page.tsx comments: wrapper closures discard return values, named
export tests are needed for value assertions
* feat: add metadata support for appLinks, iTunes, and Twitter player/app cards
Add four missing Next.js metadata features to the Metadata type and
MetadataHead renderer:
- appLinks: renders al:* property tags (ios, iphone, ipad, android,
windows_phone, windows, windows_universal, web)
- itunes: renders apple-itunes-app name tag with app-id and app-argument
- twitter player cards: renders twitter:player, twitter:player:stream,
twitter:player:width, twitter:player:height
- twitter app cards: renders twitter:app:name/id/url per platform
(iphone, ipad, googleplay)
Includes fixture pages and tests ported from Next.js test suite.
* chore: format metadata files
* fix: resolve twitter player/app and appLinks URLs against metadataBase
* fix: un-nest twitter app card name/url checks to match Next.js behavior
In Next.js, the name, id, and url checks in the twitter app card
platform loop are independent. Our implementation nested name and url
inside the id check, so a platform without an id wouldn't get name or
url tags. This un-nests them to match Next.js parity.
Also adds the missing twitter:app:name:ipad test assertion.
* Update packages/vinext/src/shims/metadata.tsx
---------
Co-authored-by: James Anderson <james@eli.cx>
* fix: warn on external rewrites and sanitize Content-Disposition
Add a startup warning when next.config contains rewrites targeting
external origins (e.g. https://...), alerting that credential headers
(cookie, authorization, x-api-key, proxy-authorization) are forwarded.
Sanitize contentDispositionType in image optimization and prod server
to only allow "attachment" — any other value defaults to "inline",
preventing header injection via arbitrary config values.
Closes#414
* fix: improve external rewrite warning and add Content-Disposition sanitization test
Use proper singular/plural in the external rewrite warning message
instead of "rewrite(s)". Add test assertions verifying the warning
includes the source pattern and arrow separator. Add a test confirming
that invalid contentDispositionType values default to "inline".
* fix: correct external rewrite warning to reflect actual header forwarding
proxyExternalRequest copies all request headers, not just credential
headers. Updated warning text to accurately state this and suggest
server-side fetch as an alternative. Tightened test assertion for
exact plural form.
* fix: address PR review feedback on external rewrite warning
Clarify why API routes are preferable (control over forwarded headers),
tighten test assertion to prevent false singular/plural match.
Refs: #430
* fix: refine external rewrite warning wording and add source path assertions
Mention route handlers alongside API routes in the warning suggestion,
and assert that source paths (/proxy1, /proxy2, /proxy3) appear in the
multi-rewrite warning message.
* fix: strip credential headers from outbound proxy requests
External rewrites via proxyExternalRequest now strip authorization,
cookie, proxy-authorization, and x-api-key headers before forwarding
to third-party origins. This prevents leaking user credentials to
external services.
Updated the warning message to reflect that credentials are stripped
(not forwarded) and updated tests to assert the new behavior.
Closes#430
* fix: align app-router credential stripping test with implementation
The integration test in app-router.test.ts was missed when c4a128d added
credential header stripping to proxyExternalRequest. Assertions now
correctly expect credentials to be undefined (stripped), matching the
unit test in shims.test.ts and the actual implementation.
* fix: keep Next.js rewrite forwarding
* fix: restore Next.js external rewrite header forwarding
* perf: convert og-inline-fetch-assets transform to async I/O with per-build cache
The `vinext:og-inline-fetch-assets` plugin used synchronous `fs.readFileSync()`
in Vite's transform hook, blocking the transform pipeline. This converts both
Pattern 1 (fetch inlining) and Pattern 2 (readFileSync inlining) to use
`await fs.promises.readFile()` and adds a per-build `Map<string, string>` cache
so repeated reads of the same file (common with shared fonts) hit memory instead
of disk.
* fix: use strict undefined check in og-inline cache and restore spies via afterEach
Replace falsy `if (!fileBase64)` with `if (fileBase64 === undefined)` in both
og-inline patterns to correctly handle hypothetical empty-string cache values.
Add `afterEach(() => vi.restoreAllMocks())` to prevent spy leaks on assertion
failure, replacing the manual mockRestore() call.
* fix: address og-inline review feedback
* style: format og-inline test assertions
* fix: TPR zone resolution for multi-part TLD domains
Replace hardcoded 12-entry MULTI_PART_TLDS list with progressive
domain candidate lookup. For "shop.example.co.uk", tries co.uk →
example.co.uk → shop.example.co.uk until a Cloudflare zone matches.
This fixes zone resolution for all public suffixes (.org.au, .co.in,
.gov.uk, etc.) without maintaining a TLD list. Common TLDs (.com, .io)
still resolve on the first API call.
Also fixes the missing fallback — the previous code returned null on
miss despite a TODO comment claiming fallback behavior existed.
* chore: format test file
* perf: replace O(n) linear route matching with radix trie
Every request triggered 3-6 linear scans over the full route list, calling
matchPattern() for every route until a match. For apps with many routes
this means thousands of comparisons per request.
This replaces the linear scan with a trie-based O(depth) lookup in all
4 codebases that share the matching logic:
- pages-router.ts (scanner module, dev time)
- app-router.ts (scanner module, dev time)
- app-rsc-entry.ts (generated entry, runtime)
- pages-server-entry.ts (generated entry, runtime)
The trie enforces correct precedence at each node via traversal order:
static > dynamic > catch-all > optional catch-all. Recursive DFS with
backtracking ensures dead-end branches fall through to alternatives.
matchPattern is retained only in app-rsc-entry.ts for the intercept
lookup (typically 0-5 entries, not worth trie-ifying).
* test: update entry-templates snapshot and assertion for trie-based matching
The server entry no longer inlines matchPattern — it imports the trie
module instead. Update the explicit assertion to check for buildRouteTrie
and trieMatch, and regenerate all entry template snapshots.
* fix: address review comments on trie-based route matching
- Correct "radix trie" → "trie (prefix tree)" in doc comment (no path compression)
- Remove unused `routes` parameter from `matchRoute` in app-rsc-entry
- Replace stream-of-consciousness test comment with concise explanation
* fix: update matchRoute regex in test to match new 1-arg signature
The test was asserting the old `matchRoute(url, routes)` signature in
the generated code. Updated to match the new `matchRoute(url)` signature
after the unused `routes` parameter was removed.
* perf: cache startup filesystem scans, optimize base64 and path normalization
- Cache hasMdxFiles() result per root directory to avoid redundant
recursive filesystem walks when config() fires per Vite environment
- Cache resolvePostcssStringPlugins() result per project root to skip
repeated existsSync checks across 17 PostCSS config file candidates
- Replace byte-by-byte base64 encode/decode in KV cache handler with
Buffer APIs for significantly faster serialization
- Pass pre-normalized URL to nodeToWebRequest() in App Router prod
server to eliminate redundant path normalization in the RSC handler
* fix: validate base64 structural length and improve test spy cleanup
Add length % 4 check to base64ToArrayBuffer to reject structurally
invalid base64 strings that pass the character-set regex but produce
empty/truncated buffers via Buffer.from(). Replace manual mockRestore()
calls with afterEach(vi.restoreAllMocks) to prevent spy leaks on
assertion failure.
* fix: address bonk review comments — composite mdx cache key, postcss comment order, hoist test import
* fix: cache Promise in resolvePostcssStringPlugins to prevent concurrent scan race; add POST urlOverride test
* fmt
---------
Co-authored-by: James <james@eli.cx>
* Fix app router route-group conflicts
* Review app router slot discovery
* fix: address review feedback from bonk
- Fix routeSegments bug: store raw filesystem segments (with route groups)
instead of normalizeVisibleRouteSegments output, so useSelectedLayoutSegments()
sees the correct segment list at runtime for synthetic slot sub-routes
- Eliminate redundant convertSegmentsToRouteParts call: store the pre-computed
result in subPathMap alongside rawSegments, reuse it in the merge loop instead
of re-deriving from already-stripped segments
- Make ownerDir required (non-optional) in ParallelSlot: it is always set by
discoverParallelSlots; remove the dead ?? '' fallback in slotKey
- Replace O(n) routes.find() with O(1) Map lookup: build routesByPattern before
the outer loop and keep it updated as synthetic routes are added
- Add comment to normalizeVisibleRouteSegments explaining the intentional difference
from convertSegmentsToRouteParts (dynamic segments not converted) and that both
functions must stay in sync
- Remove spurious g flag from normalizeRoutePattern regex: $ anchors to end of
string so there can only ever be one match
* fix: remove now-unused normalizeVisibleRouteSegments, add doc comment to convertSegmentsToRouteParts
---------
Co-authored-by: James <james@eli.cx>
* Fix Pages API parsing parity
* Fix Pages API body parsing parity
* Fix empty form body parity
* Regenerate entry-templates snapshots after merge
* fix: propagate statusText through prod server sendCompressed non-compressed path
The sendCompressed function had a statusText parameter but only used it in
the compressed response path. The non-compressed else branch called
res.writeHead directly without forwarding statusText, so short error
responses (like 'Invalid JSON' at 12 bytes, well below COMPRESS_THRESHOLD)
lost their custom reason phrase and fell back to the default 'Bad Request'.
Fix: replace the direct res.writeHead call in the else branch with the
writeHead closure that already handles the statusText conditional.
Also set statusText on the ApiBodyParseError response in the pages server
entry template so the value is present for prod-server to forward.
---------
Co-authored-by: James <james@eli.cx>
* perf: add TTL-based eviction sweep for prefetch cache
Before this change, expired prefetch cache entries were only cleaned on
individual access or displaced by FIFO when at the 50-entry cap. On
link-heavy pages, expired entries wasted slots and caused premature
eviction of still-valid entries.
Now storePrefetchResponse() sweeps all expired entries before falling
back to FIFO eviction, so fresh entries survive longer under pressure.
* fix: sync prefetched URL set on cache eviction
Evicted entries were not removed from getPrefetchedUrls(), causing
Link's prefetched.has() check to return true for stale URLs and skip
re-prefetch. Clean up the prefetched set in both sweep and FIFO paths.
Also derive test counts from MAX_PREFETCH_CACHE_SIZE constant and
assert prefetched URL set consistency after eviction.
* fix: address review comments on prefetch cache TTL sweep
- Hoist Date.now() to top of storePrefetchResponse so sweep and new entry timestamp use the same instant
- Export MAX_PREFETCH_CACHE_SIZE in next-shims.d.ts to match runtime export
- Use fixed arbitrary time values in tests instead of real wall clock
- Clarify why prefetchedUrls count excludes the new entry in the below-capacity test
---------
Co-authored-by: James <james@eli.cx>
* Fix Buffer handling in Pages API res
* fix: skip reporting handled Pages API parse errors
* address review: blank line nit, content-length parity for Buffer, fix duplicate Content-Length in sendCompressed
* fix: set content-length for Buffer in production res.send() to match dev parity
---------
Co-authored-by: James <james@eli.cx>
* fix: stabilize instrumentation state and hydration E2E tests
Move Cloudflare instrumentation example state from module-level
variables to globalThis so startup and request paths share one owner
across different module instances.
Add waitForHydration() helper to Pages Router E2E tests to eliminate
flaky button-click assertions that raced against React hydration.
* fix: honor the `as` parameter in router.push/replace
The `as` parameter in push() and replace() was silently ignored
(prefixed as _as) in both the useRouter() hook and the Router
singleton. This broke masked URLs and legacy dynamic-route flows.
Extract resolveNavigationTarget() helper that uses `as` when provided,
falling back to resolveUrl(url) otherwise. Apply to all four call
sites: useRouter().push, useRouter().replace, Router.push, Router.replace.
* fix: address PR review feedback
- Add doc comment to resolveNavigationTarget explaining the url/as
simplification vs Next.js's two-value approach
- Extract waitForHydration to shared tests/e2e/helpers.ts
- Add pathname assertion to verify router.pathname stays as route
pattern (/posts/[id]) after as-based navigation
* fix: remove pathname assertion that tests unimplemented behavior
router.pathname currently returns the resolved path after client-side
navigation, not the route pattern. The correct behavior (returning
/posts/[id] instead of /posts/42) requires the two-value url/as
approach tracked in #462.
* fix: return route pattern from router.pathname, not resolved path
In Next.js, router.pathname returns the route pattern (e.g.,
"/posts/[id]"), not the resolved path ("/posts/42"). vinext was
deriving pathname from window.location.pathname which gives the
resolved path.
Fix: use __NEXT_DATA__.page (which holds the route pattern) as
pathname, falling back to location.pathname when __NEXT_DATA__ isn't
available. asPath continues to use the resolved browser path.
Closes#462
* fix: set SSR pathname to route pattern to prevent hydration mismatch
All three SSR entry points (dev-server, pages-server-entry,
static-export) were setting pathname to the resolved URL path (e.g.,
"/posts/42") while the client now derives it from __NEXT_DATA__.page
(the route pattern "/posts/[id]"). This caused hydration mismatches
when components render router.pathname.
Fix: use patternToNextFormat(route.pattern) for the SSR context
pathname, matching what __NEXT_DATA__.page already provides.
---------
Co-authored-by: James <james@eli.cx>
* perf(kv): add local in-memory tag cache to reduce KV round-trips
When KVCacheHandler.get() validates tags, it was issuing one kv.get()
per tag in parallel. For entries with N tags, that's N KV round-trips
per cache hit. Same pattern in revalidateTag() — N parallel PUTs.
Add a local Map<string, { timestamp, fetchedAt }> with a 5-second TTL
that caches tag invalidation timestamps. Within the TTL window, tag
checks are served from memory with zero I/O. After TTL expiry, the
next request re-fetches from KV.
Key behaviors:
- revalidateTag() updates the local cache immediately so invalidations
are reflected without waiting for TTL expiry
- resetRequestCache() clears the local cache for per-request isolation
- NaN tag timestamps are cached and correctly treated as invalidation
- Only uncached/expired tags trigger KV reads (partial cache hits work)
* fix(test): guard fake timers with try/finally to prevent leaks
Wrap vi.useFakeTimers() in try/finally so vi.useRealTimers() runs even
if an assertion fails mid-test, preventing timer leaks into subsequent
tests.
* fix(kv): address bonk review comments on local tag cache
- Fix early return bug: split KV tag fetch loop into populate-cache pass
then invalidation-check pass, so already-fetched tag results are always
cached even when an earlier tag triggers an early return
- Fix unbounded _tagCache growth: delete stale entries when encountered
during the TTL check (cheap eviction instead of accumulation forever)
- Make tag cache TTL configurable via tagCacheTtlMs constructor option
(default 5000ms); eliminates fake timers in TTL test
- Clarify resetRequestCache() docstring: it is not called per-request by
vinext, it is an opt-in escape hatch for callers that need explicit isolation
- Add resetRequestCache() test: verifies tags are re-fetched from KV after
calling resetRequestCache()
* refactor(kv): reuse cached timestamps in invalidation check loop
The second pass over uncachedTags was re-calling Number(tagTime) and
re-reading tagResults[i]. Now it reads directly from _tagCache (which was
just populated in the first pass), eliminating the duplicate number
conversion and making the intent clearer.
---------
Co-authored-by: James <james@eli.cx>
* fix: stabilize instrumentation state and hydration E2E tests
Move Cloudflare instrumentation example state from module-level
variables to globalThis so startup and request paths share one owner
across different module instances.
Add waitForHydration() helper to Pages Router E2E tests to eliminate
flaky button-click assertions that raced against React hydration.
* fix: honor the `as` parameter in router.push/replace
The `as` parameter in push() and replace() was silently ignored
(prefixed as _as) in both the useRouter() hook and the Router
singleton. This broke masked URLs and legacy dynamic-route flows.
Extract resolveNavigationTarget() helper that uses `as` when provided,
falling back to resolveUrl(url) otherwise. Apply to all four call
sites: useRouter().push, useRouter().replace, Router.push, Router.replace.
* fix: address PR review feedback
- Add doc comment to resolveNavigationTarget explaining the url/as
simplification vs Next.js's two-value approach
- Extract waitForHydration to shared tests/e2e/helpers.ts
- Add pathname assertion to verify router.pathname stays as route
pattern (/posts/[id]) after as-based navigation
* fix: remove pathname assertion that tests unimplemented behavior
router.pathname currently returns the resolved path after client-side
navigation, not the route pattern. The correct behavior (returning
/posts/[id] instead of /posts/42) requires the two-value url/as
approach tracked in #462.
* fix: skip locale prefix for absolute and protocol-relative Link hrefs
applyLocaleToHref() blindly prepended /${locale} to any href string,
producing malformed URLs like /fr/https://example.com/about or
/fr///example.com/about for absolute and protocol-relative hrefs.
Guard against http://, https://, and // URLs before applying the
locale prefix — these are external or same-origin absolute URLs that
should not be rewritten.
* fix: skip locale prefix for absolute and protocol-relative URLs in applyNavigationLocale
applyNavigationLocale() in router.ts (used by Router.push() / Router.replace())
had the same bug as applyLocaleToHref() in link.tsx — it would prepend /{locale}
to absolute and protocol-relative URLs, producing malformed hrefs like
/fr/https://example.com/about or /fr///cdn.example.com/img.png.
Add the same guard that was applied to applyLocaleToHref(), and add
corresponding tests covering https://, http://, and // URL patterns.
---------
Co-authored-by: James <james@eli.cx>
* fix(fetch-cache): register stale-while-revalidate refetch with waitUntil()
The fetch cache's background refetch on stale entries was fire-and-forget.
On Cloudflare Workers, the isolate terminates after the response is sent,
killing in-flight refetches so stale entries never get refreshed.
Register the refetch promise with ExecutionContext.waitUntil() via the
existing getRequestExecutionContext() ALS accessor, matching the pattern
already used by ISR in isr-cache.ts.
* ci: re-trigger CI
* refactor: make ExecutionContext ALS the single source of truth
Remove ctx as an explicit parameter from internal functions so that all
downstream code consistently retrieves the Workers ExecutionContext via
getRequestExecutionContext() from the ALS. Changes:
- isr-cache.ts: drop ctx? param from triggerBackgroundRegeneration();
remove duplicate ExecutionContext interface (use ALS directly)
- app-rsc-entry.ts: drop ctx 4th param from generated _handleRequest;
replace ctx.waitUntil() calls with _getRequestExecutionContext()?.waitUntil()
- kv-cache-handler.ts: remove duplicate ExecutionContext interface;
import ExecutionContextLike from request-context instead
- isr-cache.test.ts: update ctx.waitUntil test to supply ctx via
runWithExecutionContext() (ALS) instead of as a direct argument
- Update entry-templates.test.ts snapshots
* fix(test): update _handleRequest signature assertion after ctx param removal
Instead of relying on the caller to pass ctx to triggerBackgroundRegeneration,
read it from AsyncLocalStorage via getRequestExecutionContext(). This ensures
waitUntil() is always registered on the live Workers ExecutionContext, fixing
stale responses that were not triggering revalidation on Cloudflare Workers.
* fix(isr): KVCacheHandler.set() now awaits KV put to prevent perpetual STALE
Background ISR regen was calling ctx.waitUntil(renderFn()) where renderFn()
resolved before the KV put network operation completed. set() returned
Promise.resolve() immediately, so await __isrSet() in the regen resolved early,
the waitUntil expired, and the Workers runtime killed the in-flight KV write —
leaving the cache entry perpetually STALE on every request after the TTL.
Fix: replace the fire-and-forget _putInBackground() with _put() which returns
the actual kv.put() promise. set() now returns that promise, so await __isrSet()
only resolves after the write is fully persisted. ctx.waitUntil() is also still
registered as belt-and-suspenders.
Adds three regression tests covering the STALE → regen → HIT lifecycle with a
delayed KV put mock to prove the set() promise is not an immediately-resolved stub.
* fix: remove unused variable flagged by lint
* fix(isr): namespace cache keys by buildId
Include buildId in ISR cache key generation to prevent stale cache
reuse across deploys. Key format changes from `<router>:<pathname>` to
`<router>:<buildId>:<pathname>` when buildId is available. Without
buildId the format is unchanged for backward compatibility.
Updated all callers: dev-server.ts (dev), pages-server-entry.ts (prod).
Closes#415
* test: update pages-server-entry snapshot for buildId cache key
The entry template changed to include buildId in ISR cache keys.
Update the snapshot to match the new generated code.
* fix(isr): inline isrCacheKey with fnv1a64 hashing in pages server entry
The generated pages server entry was constructing ISR cache keys inline
without the >200-char fnv1a64 hashing that exists in isr-cache.ts. With
buildId making keys longer, this risks exceeding KV key-length limits.
Inline fnv1a64 and isrCacheKey helpers into the generated entry (same
pattern as isrGet/isrSet/triggerBackgroundRegeneration) and replace both
inline cache key constructions with calls to isrCacheKey. Also rename
isrCacheKeyPrefix to _cachePrefix for consistent variable naming.
* address review: clarify __VINEXT_BUILD_ID is undefined in dev by design
* address review: align app-rsc-entry ISR key format with pages router, add comments
* address review: clarify keep-in-sync comment scope (prefix/hashing, not full key format)
---------
Co-authored-by: James <james@eli.cx>
* feat: add ISR caching for App Router (production-only, stale-while-revalidate)
- Inline ISR helpers (__isrGet, __isrSet, __triggerBackgroundRegeneration,
__isrCacheKey, __pendingRegenerations) in the generated RSC entry
- Cache READ fires before buildPageElement for prod pages with revalidate > 0,
returning cached HTML/headers without loading component modules on HIT/STALE
- Cache WRITE (+ X-Vinext-Cache: MISS header) is guarded by
process.env.NODE_ENV === 'production' so dev never reads/writes the cache
- Background regenration is deduplicated via __pendingRegenerations Map and
registered with ctx.waitUntil for Cloudflare Workers
- Thread ExecutionContext (ctx) from app-router-entry.ts through to _handleRequest
- Dev mode still emits correct Cache-Control headers based on export const revalidate
- Add integration tests: Cache-Control in dev, no X-Vinext-Cache in dev, RSC
requests bypass ISR, pages without revalidate emit no ISR headers
- Add 10 code-generation tests verifying the prod guard, helpers, and structure
* fix: remove unused isrCachePath variable and fix formatting
* fix: cache RSC wire format (rscData) in ISR entries; serve RSC requests from cache
Previously the ISR cache only stored HTML and excluded all RSC requests
from cache reads/writes. This caused two bugs:
1. RSC requests (client-side navigation, link prefetch) always triggered a
full component tree render even when a fresh cache entry existed, wasting
CPU on every soft-nav and prefetch.
2. rscData was never captured — the cache entry had rscData: undefined —
so there was no way to serve RSC requests from cache even if we wanted to.
Fixes:
- Tee the RSC stream before SSR on ISR-eligible HTML requests (prod + revalidate > 0)
to capture the RSC wire format as an ArrayBuffer stored in rscData
- Background regen also tees its RSC stream to keep rscData fresh
- Remove the !isRscRequest guard from the ISR READ block: both HTML requests
and RSC requests now hit the cache. HTML requests get cached html; RSC
requests get cached rscData (falling through to render if rscData is absent)
- HIT and STALE paths branch on isRscRequest: RSC responses get
Content-Type: text/x-component and the rscData bytes; HTML responses get
Content-Type: text/html and the html string
Tests updated:
- Replace 'RSC requests return RSC stream without cache headers' test with
two tests that assert Cache-Control IS emitted for RSC responses on ISR
pages (dev mode), and that Next-Router-Prefetch requests also get it
- Replace .tee() count test with assertion that >=2 tees exist (RSC + HTML)
- Add rscData storage assertions for WRITE and background regen paths
- Add assertion that ISR READ block has no !isRscRequest guard
* fix(isr): fix RSC cache miss and early-exit performance for App Router ISR
Two bugs fixed:
1. RSC requests (client-side nav / prefetch) were never cached because the RSC
stream tee that captures rscData was placed AFTER the 'if (isRscRequest)'
early-return, so the capture branch was never consumed. Move the tee to
immediately after renderToReadableStream — before the isRscRequest branch —
and register a waitUntil in the RSC path to write rscData to the cache.
2. The ISR cache read block fired after the generateStaticParams call, meaning
a cache hit still did the expensive static-params validation. Move the ISR
read to before generateStaticParams so a HIT skips all that work.
Tests: add two new generateRscEntry ISR assertions:
- ISR read index < generateStaticParams index in generated code
- __rscForResponse tee assignment before 'return new Response(__rscForResponse'
* feat(isr): cache RSC prefetch responses immediately as partial entries
RSC prefetch requests (Next-Router-Prefetch and client-side nav) now write
a partial cache entry with rscData + html:'' on MISS. Subsequent RSC requests
serve from cache (HIT) without waiting for an HTML request to populate the
entry first.
The ISR read block treats html:'' as a partial hit: RSC requests get a HIT,
but HTML requests fall through to render and overwrite the entry with a
complete html+rscData entry. This prevents blank HTML responses on first HTML
request after a prefetch-first cold start.
Also adds console.log logging for all ISR cache events (HIT/MISS/STALE/write)
in production to aid debugging on deployed Workers.
* fix(isr): use ALS to thread ExecutionContext into KVCacheHandler without constructor arg
Add runWithExecutionContext/getRequestExecutionContext to next/cache shim
backed by AsyncLocalStorage. The RSC entry handler now wraps each request
in this ALS scope so KVCacheHandler._putInBackground and _deleteInBackground
can call ctx.waitUntil() without needing ctx passed at construction time.
- cache.ts: add ExecutionContextLike interface + ALS helpers
- app-rsc-entry.ts: import runWithExecutionContext, wrap _run in ALS scope
- kv-cache-handler.ts: check ALS ctx first, fall back to constructor ctx
- deploy.ts: simplify generated template to new KVCacheHandler(env.VINEXT_CACHE)
- tests/deploy.test.ts: update expectation to match simplified constructor
* fix(isr): store activeHandler on globalThis to fix cross-environment cache isolation
Cloudflare Workers with Vite RSC loads separate module instances per
environment (worker / RSC / SSR). Previously, activeHandler was a
module-local variable, so setCacheHandler(new KVCacheHandler(...)) in
the worker environment set the worker copy while getCacheHandler() in
the RSC environment still returned a fresh MemoryCacheHandler from
its own copy — causing every ISR read/write to go to in-process
memory that is discarded after each request.
Fix: store the active handler on globalThis via Symbol.for('vinext.cacheHandler'),
the same pattern used by _cacheAls, _ctxAls, and _unstableCacheAls. All
Vite environments share the same globalThis on Cloudflare Workers, so
the KVCacheHandler set in worker/index.ts is now visible to the RSC
entry's getCacheHandler() call, enabling real KV-backed ISR cache
reads and writes.
* fix(isr): allow force-static/error pages to be served from ISR cache, prevent partial RSC write from clobbering complete entry
- Remove !isForceStatic and !isDynamicError from the ISR cache read guard.
Both modes are compatible with ISR: they control dynamic API behaviour during
render, not whether results can be cached. The asymmetry between the read guard
(which excluded them) and the write guard (which did not) caused writes to
succeed but reads to be skipped entirely, so every request returned MISS.
Only isForceDynamic should bypass the ISR cache.
- Add a read-before-write in the RSC partial-write path. When an RSC request
writes a partial cache entry (html: "", rscData set) before the first HTML
request has come in, a concurrent or later RSC request could race and overwrite
a complete html+rscData entry that the HTML write had already landed. The fix
reads the existing entry first and skips the partial write if a fresh, complete
entry (non-empty html) is already present, so the HTML write always wins.
* fix(isr): split HTML and RSC into separate cache keys, eliminating write races
Previously both HTML and RSC data were stored under a single key with an
html:"" partial-entry sentinel to signal 'RSC written, HTML pending'. This
caused write races on eventually-consistent stores like KV: a concurrent RSC
request could overwrite a complete html+rscData entry with a partial one,
or the read-before-write guard added to prevent that would itself race.
This mirrors how Next.js handles it in FileSystemCache: HTML is stored in
<key>.html and RSC in <key>.rsc as independent files, so each request type
reads and writes only its own key with no coordination needed.
Changes:
- Replace __isrCacheKey() with __isrHtmlKey() / __isrRscKey() which append
:html and :rsc suffixes respectively
- ISR read block selects the key based on isRscRequest before the get() call
- RSC write path uses __isrRscKey and stores only rscData (html: "")
- HTML write path uses __isrHtmlKey and stores only html (rscData: undefined)
- Background regen writes both keys independently via Promise.all
- Remove the read-before-write hack (no longer needed)
- Remove partial-entry sentinel comments (concept no longer exists)
- HTML write path no longer awaits __isrRscDataPromise
* fix(kv-cache): always use 30-day KV TTL regardless of revalidate frequency
Previously expirationTtl was 10x the revalidate period (clamped to 60s–30d).
For pages with short revalidate windows (e.g. revalidate=5), this meant entries
were evicted from KV after ~50 seconds of no traffic, causing the next request
to block on a fresh render instead of serving stale content.
KV eviction should never be the reason a stale entry disappears — staleness is
tracked by the revalidateAt timestamp in the stored JSON, not by KV TTL. Always
storing for 30 days means stale content is always available to serve while
background regen runs, and entries only disappear after 30 days of zero traffic
or explicit tag invalidation.
* fix(isr): prefix KV cache keys with per-deployment build ID, add ttlSeconds option to KVCacheHandler
- __isrCacheKey now embeds process.env.__VINEXT_BUILD_ID (statically
replaced by Vite's define at build time) so each deployment gets its
own effective cache namespace in KV — stale entries from previous
deploys are never served as hits
- vinextBuildId (crypto.randomUUID() at plugin scope) is passed to
generateRscEntry and injected as the __VINEXT_BUILD_ID define
- KVCacheHandler accepts an optional ttlSeconds constructor option
(defaults to 30 days) so users can override KV entry lifetime
* regen snaps
* fmt
* refactor(ctx): migrate to canonical request-context shim for ExecutionContext ALS
- shims/cache.ts: remove duplicated ALS implementation; re-export
runWithExecutionContext and getRequestExecutionContext from the
canonical shims/request-context.ts (Symbol.for('vinext.requestContext.als'))
- app-rsc-entry.ts: import runWithExecutionContext from request-context
via absolute path (consistent with pages-server-entry pattern) rather
than the bare 'vinext/shims/request-context' specifier which is not
in the alias map
- deploy.ts: restore missing ${isrSetup} interpolation that was dropped
during rebase conflict resolution
- Regenerate entry-template snapshots
* fix(isr): address PR review — hash collision, dedup key, regen headers, RSC key from HTML path, Infinity revalidate, Response init, deploy KV hint
* fix(isr): use __hasRsc/__hasHtml guards in cache read block; document 30-day flat KV TTL in test
* fix(isr): gate debug logs behind NEXT_PRIVATE_DEBUG_CACHE, add FNV seed comment, document headers limitation