286 Commits

Author SHA1 Message Date
Stephen Zhou 5909e005dd feat: support vite8 resolve.tsconfigPaths (#494)
* feat: support vite8 resolve.tsconfigPaths

* Update
v0.0.29
2026-03-12 09:29:02 +00:00
Nathan Nguyen 9bd7f811e0 fix: make useParams() reactive on client-side navigation (#489)
* 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
2026-03-12 09:23:10 +00:00
Nathan Nguyen e9063411b8 fix: register reportRequestError with ctx.waitUntil on Workers (#493)
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.
2026-03-12 08:57:24 +00:00
Nathan Nguyen d093c3317b fix: Pages Router ISR background regeneration re-renders HTML (#487)
* 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
2026-03-12 08:28:15 +00:00
Nathan Nguyen f88d76a8a6 fix: middleware custom response corrupts binary bodies in dev (#485)
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.
2026-03-12 08:26:13 +00:00
Nathan Nguyen 78805d672f fix: add headersSent guard to Pages Router prod error handler (#481)
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.
2026-03-12 08:25:34 +00:00
Nathan Nguyen af6e0ce421 fix: exclude private folders (_prefix) from App Router route discovery (#484)
* 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
2026-03-12 08:25:18 +00:00
Nathan Nguyen 3e17beceac fix: add headersSent guard in API route error handler (#491)
* 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
2026-03-12 08:19:59 +00:00
Stephen Zhou 3fc76e4dbe fix: add next shim js variants (#482)
* fix: add next shim js variants

* Update

* Update

* trigger ci

* trigger ci

* Update

* Update

* Update

* Update

* Update
2026-03-12 08:18:34 +00:00
Jared Stowell cb2d1c9ed1 fix: Link onNavigate URL resolution for relative hrefs (#483)
* Fix onNavigate relative href report

* Verify onNavigate relative href

* Fix onNavigate URL mismatch

* Review url-utils changes
2026-03-12 08:17:43 +00:00
Nathan Nguyen 84997700b1 fix: call reportRequestError in Pages Router prod entry (#492)
* 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.
2026-03-12 08:08:21 +00:00
Jared Stowell 26d5eabb92 fix: skip synthetic slot sub-routes without parent default fallback (#458)
* Fix nested parallel slot routes

* Fix nested parallel slot discovery

* Fix parallel slot routing parity
2026-03-12 07:47:25 +00:00
Divanshu Chauhan (divkix) 9d0fc0d2fb fix: bind iterator methods on request headers proxy (#480) 2026-03-12 07:46:53 +00:00
James Anderson aba48698c2 feat(build): add Next.js-style route report to vinext build output (#477)
* 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
2026-03-11 19:58:06 +00:00
James Anderson 5b60a70f94 test: headers() and cookies() in server actions and route handlers (#473)
* 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
2026-03-11 18:07:54 +00:00
Divanshu Chauhan (divkix) 488fb350d4 fix: pin Rollup to patched version for CVE-2026-27606 (#476) 2026-03-11 18:03:57 +00:00
Nathan Nguyen b87ab02d58 feat: metadata parity for appLinks, iTunes, Twitter player/app cards (#452)
* 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>
2026-03-11 17:40:18 +00:00
Divanshu Chauhan (divkix) ddf4d337d5 docs/runtime: warn on external rewrites, sanitize Content-Disposition (#418)
* 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
2026-03-11 16:57:28 +00:00
Jared Stowell b66fd6488c fix: Preserve encoded path delimiters in route discovery and matching (#456)
* Fix App Router decoding collision

* Fix App Router path decoding

* Address bonk review: codegen safe segment decode, static serving, nits

* fix: add codegen strict normalizer, use it at 400-returning call sites

---------

Co-authored-by: James <james@eli.cx>
2026-03-11 16:52:29 +00:00
Divanshu Chauhan (divkix) 9b285e3c9f perf: async I/O + cache for og-inline-fetch-assets transform (#435)
* 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
2026-03-11 16:44:48 +00:00
Nathan Nguyen b191aff91d fix: TPR zone resolution for multi-part TLD domains (#470)
* 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
2026-03-11 16:43:09 +00:00
Divanshu Chauhan (divkix) 20f08bdd86 perf: replace O(n) linear route matching with radix trie (#442)
* 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.
2026-03-11 16:41:21 +00:00
Divanshu Chauhan (divkix) 1b4a796a3f perf: startup and cache micro-optimizations (#436)
* 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>
2026-03-11 16:22:58 +00:00
Stephen Zhou 657903055a fix: add client optimizeDeps entries (#469) 2026-03-11 16:02:30 +00:00
Jared Stowell 17dd075885 fix: App Router route-group and slot collision (#447)
* 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>
2026-03-11 13:30:33 +00:00
Jared Stowell f360380112 fix: Pages API body parser for invalid JSON and repeated form keys (#446)
* 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>
2026-03-11 13:24:36 +00:00
Divanshu Chauhan (divkix) 1c3401e245 perf: TTL-based eviction sweep for prefetch cache (#434)
* 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>
2026-03-11 12:39:49 +00:00
Jared Stowell 16a14c713b fix: Pages prod config headers and redirects after middleware rewrites (#448)
* Fix headers matching original path

* Review redirect ordering parity

* Fix config redirects order

* Fix dev middleware parity

* Fix dev middleware request parity

* address review: worker header/fallback parity, early-return in attachRouteHandlerMiddlewareContext, cache node request headers, add dev redirect test

* address second-round bonk comments: sanitizeDestination in worker redirect, nodeRequestHeaders comment, app-rsc-entry guard comment, E2E redirect test, updated snapshots

---------

Co-authored-by: James <james@eli.cx>
v0.0.28
2026-03-11 11:40:07 +00:00
Jared Stowell 67d7c2f90c Add Node execution context handling (#460) 2026-03-11 11:22:49 +00:00
Jared Stowell 3b982066d0 fix: align Pages API body parsing and res.send(Buffer) (#428)
* 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>
2026-03-11 11:16:51 +00:00
Nathan Nguyen 47500b180f fix: return route pattern from router.pathname, not resolved path (#463)
* 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>
2026-03-11 11:00:36 +00:00
Divanshu Chauhan (divkix) 769a98ac85 perf(kv): local tag cache to reduce KV round-trips on cache hit (#433)
* 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>
2026-03-11 10:59:25 +00:00
Jared Stowell f035e1a05e fix: object-form query array serialization in next/link and next/router (#457)
* Fix query param handling

* Fix scalar query serialization
2026-03-11 10:46:57 +00:00
Nathan Nguyen 45b72eade5 fix: honor the as parameter in router.push/replace (#453)
* 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.
2026-03-11 09:00:27 +00:00
Nathan Nguyen 15cd5d2881 fix: skip locale prefix for absolute and protocol-relative Link hrefs (#455)
* 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>
2026-03-11 08:38:48 +00:00
Jared Stowell 1551e4406a fix: App Router ISR invalidation parity for fetch and path tags (#445)
* Fix App Router ISR tag parity

* Fix KV tag persistence

* Fix KV cache tag handling
2026-03-11 08:17:50 +00:00
Divanshu Chauhan (divkix) 29649068f4 fix(fetch-cache): register stale-while-revalidate refetch with waitUntil() (#432)
* 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
2026-03-11 07:47:39 +00:00
James Anderson a08a2b5d92 refactor: make ExecutionContext ALS the single source of truth (#431)
* 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
2026-03-11 07:40:52 +00:00
Jared Stowell c4064ebb23 fix: next/form submitter overrides and query-string GET URLs (#426)
* Add submitter handling to form shim

* Update form shim URL validation

* Fix form submit attribute casing
2026-03-11 00:31:52 +00:00
James Anderson 60ca7192c2 fix(isr): use getRequestExecutionContext() from ALS in background regeneration (#429)
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.
2026-03-10 23:58:24 +00:00
Jared Stowell 0775ce55bb fix: return ReadonlyURLSearchParams from useSearchParams (#424)
* Update navigation search params

* Fix server readonly cache scope
2026-03-10 23:56:11 +00:00
Jared Stowell 6ccdaac78e fix: sitemap XML namespaces, video entries, and alternate-language links (#427) 2026-03-10 23:54:56 +00:00
Jared Stowell 88ed5550d1 fix: honor beforePopState when useRouter is mounted (#423) 2026-03-10 23:11:53 +00:00
James Anderson aa878d55ac fix(isr): KVCacheHandler.set() now awaits KV put to prevent perpetual STALE (#425)
* 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
2026-03-10 23:11:24 +00:00
Jared Stowell a6c5601cc2 fix: preserve repeated route and query params in rewrites and redirects (#422)
* Update redirect matcher replacements

* Fix rewrite param substitution

* fix nits
2026-03-10 22:28:36 +00:00
Divanshu Chauhan (divkix) b12eeebfce fix(isr): namespace cache keys by buildId (#419)
* 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>
2026-03-10 22:18:49 +00:00
Jared Stowell ec1b9da131 fix: preserve Pages Router query arrays and hash in asPath (#421)
* Handle array query params

* Fix router query merge order
2026-03-10 22:17:36 +00:00
Jared Stowell 11b27cd0a8 fix: reject conflicting dynamic route siblings during route discovery (#420)
* Validate route patterns in routers

* Validate intercepting routes
2026-03-10 21:48:36 +00:00
James Anderson 319f9296ff feat: ISR caching for App Router (production-only, stale-while-revalidate) (#405)
* 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
2026-03-10 21:39:33 +00:00
Jared Stowell cf0786ef34 fix: Reject non-terminal catch-all routes in pages and app routers (#401)
* Fix catch-all route overmatching

* Fix non-terminal slot routes

* Fix malformed slot route discovery

* Add validation for intercept catch-⁤

* Fix intercepting route catch-alls

* bonk

* Fix routing test route literals for typecheck

* Fix app route matcher test fixtures
2026-03-10 20:24:31 +00:00