sendCompressed wrote the full body for every request. HEAD requests routed
through it (bot/crawler-buffered Pages HTML and Pages API routes) still
compressed and emitted a payload that Node then discards at the socket level.
Short-circuit with res.end() after writeHead when req.method is HEAD,
mirroring sendWebResponse. This skips spinning up a compressor for a body
that would be thrown away and keeps the buffered sender consistent with the
streamed and static-file senders, which already handle HEAD.
Co-authored-by: James <james@eli.cx>
* fix(pages): emit canonical next data script
* test(pages): expect canonical next data markup
* fix(pages): initialize canonical next data before routing
* test(pages): parse canonical next data markup
* fix(pages): preserve router bootstrap ordering
* test(pages): expect readiness bootstrap import
* fix(pages): guard readiness bootstrap without next data
* fix(app-router): align router autoscroll with Next
App Router navigation could diverge from Next.js router autoscroll semantics around hydration timing, loading-shell same-page search navigations, and routes whose first committed node is React-hoisted into head. That broke the upstream router-autoscroll deploy-suite because the client marked hydration too early, static loading-shell templates were not usable for navigation, and the fallback scroll path masked the old-handler hoisted-head behavior.
The fix moves the Next-compatible hydration marker into a passive effect, wires production hydrateRoot error callbacks to match Next's implicit root-boundary and recoverable-error handling, lets authoritative loading-shell templates drive static route optimistic payloads, and skips fallback document scrolling when the committed document only exposes a React-hoisted head resource. The ported tests cover the upstream router-autoscroll behavior plus focused unit boundaries for prefetch mode, optimistic routing, head-resource detection, and hydrateRoot callbacks.
* fix(app-router): hoisted-head scroll fallback is per-intent, not global head scan
* test(app-router): add full-chain scroll intent integration test
* fix(app-router): match legacy scroll focus parity
* fix(app-router): preserve latest scroll navigation intent
* fix(app-router): scope scroll and error recovery
* fix(app-router): gate navigation failure recovery
* fix(app-router): match navigation failure handling
* fix(app-router): align scroll completion parity
* fix(app-router): retain latest failure target
* fix(app-router): preserve route error boundaries
* fix(app-router): clear discarded failure targets
* fix(app-router): align failure recovery boundaries
* fix(app-router): disarm refused recovery targets
* fix(app-router): preserve failure target ownership
* fix(app-router): match boundary recovery parity
---------
Co-authored-by: James <james@eli.cx>
* fix(cache): honor fetch opt-outs in app page output
App Router fetches that explicitly opt out of caching could still leave the rendered page eligible for static output, while dynamic = "force-dynamic" was lowered to fetchCache = "force-no-store" and overrode explicit per-fetch cache/revalidate options.
The cache decision was conflating route-level dynamic rendering with a hard segment fetchCache mode. Next.js treats force-dynamic as a default no-store mode only for fetches without explicit cache or revalidate config, and marks explicit uncached fetches as dynamic for the surrounding render.
Mark explicit uncached fetch decisions through the existing dynamic usage signal, keep force-dynamic as a separate per-request fetch default, and preserve Response.url when reconstructing cached fetch responses.
* fix(cache): correct revalidate:false and Response.url semantics
- Split revalidate: false out of the no-store path; it now caches
indefinitely (INFINITE_CACHE) matching upstream patch-fetch.ts.
- Store actual response.url (freshResp.url / response.url) in cached
fetch entries instead of the input request URL.
- Remove markUncachedFetchForPageOutput() from the auth-header fallback
so it matches upstream's autoNoCache path and does not mark pages
dynamic.
- Update tests to reflect correct revalidate: false caching behavior
and add a test for redirect-style response URLs.
* fix(app-router): resolve target route dynamic config during intercepts and revalidation
Prevents the outer route's force-dynamic default from leaking into
interception source routes and ISR revalidation targets.
- app-rsc-entry.ts: expose __resolveRouteDynamicConfig
- app-page-dispatch.ts: pass resolveRouteDynamicConfig to dispatch,
sync currentForceDynamicFetchDefault when the render target changes
- app-page-dispatch.test.ts: 4 tests covering intercept + revalidation
paths for both lost and leaked defaults
* fix(app-router): sync fetchCache and force-dynamic default during action redirects/re-renders
* fix(cache): treat revalidate:false as non-explicit for force-dynamic parity
Upstream patch-fetch.ts computes noFetchConfigAndForceDynamic using
!currentFetchRevalidate (truthiness), so revalidate: false is treated
as 'no fetch revalidate config' and force-dynamic wins. vinext's
hasExplicitRevalidateValue() previously returned true for false,
blocking the force-dynamic no-store default and causing the fetch to
cache for 1 year instead.
Update hasExplicitRevalidateValue to exclude false (and 0) from the
explicit set, aligning with upstream's truthiness check. Add a parity
test covering force-dynamic + next.revalidate: false.
* fix(cache): split revalidate predicates for force-dynamic vs segment defaults
The previous fix to hasExplicitRevalidateValue() treated false and 0 as
non-explicit, which matched upstream's force-dynamic truthiness check
but broke segment cache defaults: default-cache would override
revalidate: 0 to force-cache, and default-no-store would override
revalidate: false to no-store.
Split into two helpers:
- hasExplicitRevalidateValue(): any defined value is explicit (for
segment defaults where 0 and false are explicit opt-outs)
- isFalsyRevalidate(): false and 0 are falsy (for force-dynamic's
noFetchConfigAndForceDynamic parity check)
Add parity tests for default-cache + revalidate: 0 and
default-no-store + revalidate: false.
* refactor(cache): extract ONE_YEAR_SECONDS and cover only-no-store revalidate:false
* fix(cache): keep dynamic fetch observations for auth bypass and cache-key fallback
Restore recordDynamicFetchObservation in the auth-header safety bypass so
auth-keyed fetches still downgrade the page output to fresh render, and
revert the cache-key-generation fallback to a plain observation so an
internal limitation (oversized/unserializable body) does not mark the
whole page dynamic. Lock in force-dynamic vs explicit segment fetchCache
precedence with a test.
* docs(cache): note deliberate no-cache dynamic-marking divergence from upstream
* fix(route-handler): apply segment fetchCache and force-dynamic fetch default to route handler dispatch
Route handlers never called setCurrentFetchCacheMode /
setCurrentForceDynamicFetchDefault, so a force-dynamic route handler did
not get the per-request no-store fetch default that page dispatch applies,
and an explicit fetchCache export on a route handler module was ignored.
Upstream's app-route module copies userland.fetchCache into the work store
and sets workStore.forceDynamic for dynamic = "force-dynamic", which
patch-fetch turns into a no-store default for fetches without explicit
cache config — for route handlers as well as pages. Mirror that in
dispatchAppRouteHandler and re-apply the state inside the background
regeneration request context.
* fix(fetch-cache): tolerate cached entries missing url and document revalidate:false cacheability
* fix(fetch-cache): fall back to the request URL for cached entries missing url
Reconstructing Response.url from the fetch input preserves the
pre-existing guarantee for legacy/foreign cache entries instead of
degrading to "". Also document why the intercept dispatch deliberately
does not save/restore fetch defaults around buildPageElement.
* docs(fetch-cache): note cached responses now join body-stream cleanup
* docs(fetch-cache): note explicit segment fetchCache beats force-dynamic default
* test(fetch-cache): pin tags-only fetch to no-store under force-dynamic
* docs(fetch-cache): document auth-bypass precedence and test no-store + auth
- Note at the auth-safety bypass that explicit no-store/no-cache/revalidate: 0
takes the stronger branch first and fully marks the page dynamic; the bypass
only covers implicitly-cacheable auth-keyed fetches.
- Add a test locking in that explicit no-store with auth headers routes through
markUncachedFetchForPageOutput rather than the softer auth bypass.
- Clarify in dispatchAppRouteHandler that the fetch-cache setters are new
wiring for route handlers, not a mirror of a pre-existing pattern.
* test(route-handler): lock no-store fetch bailing ISR for revalidating handlers
A route handler with revalidate = 60 that performs
fetch(url, { cache: "no-store" }) must skip its ISR cache write and be
marked known-dynamic, now that the patched fetch's explicit no-store
branch calls markDynamicUsage() (upstream patch-fetch parity). Exercises
the real fetch-cache shim and the real headers-shim dynamic-usage pair,
matching the app-route-handler-dispatch wiring.
---------
Co-authored-by: James <james@eli.cx>
Next.js's `metadata.icons.other` accepts either a single
`IconDescriptor` or an array of them, but `MetadataHead` iterated
it directly and threw `TypeError: metadata.icons.other is not iterable`
when given a single object. That crash bubbled up into the page
render so no `<link>` tags (favicon, shortcut, apple, custom) reached
the head at all for any page declaring `icons.other` as an object.
Normalize `metadata.icons.other` to an array before iterating, widen
its type to `OtherIconDescriptor | OtherIconDescriptor[]`, and also
forward the optional `type` attribute so descriptors like
`{ rel: "mask-icon", url, type: "image/svg+xml" }` round-trip
correctly. Ported coverage from
`test/e2e/app-dir/metadata-icons/metadata-icons.test.ts`.
Refs cloudflare/vinext#1492
* fix(app-router): stream generated metadata for non-html bots
Dynamic App Router metadata was always folded into the route head. That diverged from Next.js for normal browser-like requests, where generated metadata is sent through a body outlet while html-limited bots still receive blocking head metadata.
Track whether the matched route uses generateMetadata, thread next.config htmlLimitedBots into the generated RSC entry, and choose head versus body placement at page element construction.
* test(app-router): cover streaming metadata bot edge cases
Generated metadata must keep the default html-limited bot list when serialized config contains a falsy regex source. Empty config strings previously produced an empty regex, which treated every user agent as blocking and moved generated metadata back into the head.
Add regression coverage for the default Twitterbot path and the streaming body serializer, then match Next.js by falling back to the default bot regex for any falsy htmlLimitedBots value.
* fix(app-router): validate html-limited bot regex config
Invalid serialized htmlLimitedBots values could reach request handling and throw while building the bot matcher. That puts a config error on the request path and recompiles the same matcher for repeated requests.
Validate serialized regex sources while resolving next.config and reuse compiled bot matchers in the streaming metadata helper. The focused tests cover invalid config, falsy fallback, and matcher reuse.
* refactor(config): narrow next config option reads
Config resolution had several local assertions around experimental options, Turbopack aliases, output mode, and Sass options. Those assertions made the boundary wider than necessary in the code adjacent to htmlLimitedBots handling.
Read optional records, strings, arrays, and body-size inputs through typed narrowing helpers so the resolved config path keeps the same semantics without spreading unchecked asserted values.
* fix(config): keep bot regex helper out of server graph
Config resolution imported the streaming metadata server module to validate htmlLimitedBots. In RSC-backed App Router integration tests, that cross-layer import can pull server runtime modules through config resolution and surface rsc reference-validation errors.
Move the shared html-limited bot matcher cache to a neutral utils module and import it from config and the server streaming metadata helper. This keeps config validation independent from the server runtime graph while preserving the cached matcher behavior.
* feat(config): add experimental.appShells plumbing
Accepts experimental.appShells in next.config and defines
process.env.__NEXT_APP_SHELLS=false at build time, matching
Next.js PR #93997 plumbing scope. Behavorial implementation
is gated on upstream co-flags that vinext does not yet support.
- Adds experimental.appShells to CONFIG_SUPPORT as unsupported
- Sets __NEXT_APP_SHELLS define to false regardless of config value
- Tests for vinext check detection and Vite define injection
Fixes#1405
* review: address PR nits
- Expand experimental.appShells detail to explain why it's unsupported
- Move build-time define tests into their own describe block, so the
__NEXT_APP_SHELLS define test isn't nested under basePath
* review: drop orphaned server lifecycle from basePath describe
The two remaining resolveNextConfig tests import from source directly
and don't use the dev server.
---------
Co-authored-by: James <james@eli.cx>
* fix(i18n): strip locale prefix for API routes
Mirror Next.js's behaviour where a locale-prefixed request path like
`/fr/api/ok` is matched against `pages/api/ok` after stripping the
locale segment. Previously the locale prefix was kept through the
`/api/` startsWith check, so any locale-prefixed API URL 404'd.
Add a small helper `stripI18nLocaleForApiRoute(url, i18nConfig)` in
`server/pages-i18n.ts` that mirrors Next.js's
`normalizeLocalePath().pathname` (see
packages/next/src/shared/lib/i18n/normalize-locale-path.ts) and wire it
into all three Pages Router request pipelines so dev/prod parity holds:
- dev plugin: `packages/vinext/src/index.ts`
- prod (Node): `packages/vinext/src/server/prod-server.ts`
- prod (Cloudflare worker entry): `packages/vinext/src/deploy.ts`
App Router is intentionally unchanged: Next.js does not support the
`i18n` config field with App Router, so App Router route handlers
under `app/*/route.ts` never see a locale prefix to strip.
Refs #1336 (item 3). Items 2 (sticky locale on client navigations) and
4 (default-locale path normalisation) are being handled in parallel
PRs.
Tests:
- Unit tests for `stripI18nLocaleForApiRoute` covering basic, regional
(`nl-NL`), query-preserving, unconfigured-prefix, and null-config
cases (`tests/pages-i18n.test.ts`).
- Dev integration tests (`tests/features.test.ts`) and prod integration
tests (`tests/pages-i18n-prod.test.ts`) that GET `/fr/api/ok` and
assert a 200 "ok" response against the existing
`pages-i18n-domains` fixture, ported from Next.js's
`test/e2e/middleware-redirects/test/index.test.ts` ("should redirect
to api route with locale").
- Updates to `tests/deploy.test.ts` assertions for the generated
worker-entry template strings.
* test(after-deploy): update worker-entry assertion after locale strip
The Pages Router worker entry now calls handleApiRoute(request,
apiLookupUrl, ctx) instead of using resolvedUrl directly, so this
generated-code assertion needs the new variable name.
Refs #1336 (item 3).
Trailing slash handling treated every non-api path the same on server redirects, so trailingSlash:true added slashes to file-looking catch-all routes and Pages Router imperative navigation skipped the client canonicalization path used by Link.
Next.js splits the invariant between route-manifest redirects and client resolveHref normalization: file-looking paths lose a trailing slash, non-file paths gain one, and queries stay attached to the canonical pathname. Share the server redirect decision across Pages Router runtimes and apply the same client helper in Router.push and Router.replace.
Adds focused regressions for catch-all dot segments, query preservation, .well-known exclusion, and Pages Router history writes.
* fix(metadata): match Next dynamic route semantics
Metadata routes diverged from Next.js in several observable cases: robots.txt emitted Sitemap before Host, dynamic metadata Response results could miss framework cache headers, TSX sitemap files were not discovered, and rendered metadata URLs treated canonical, manifest, and file-based social image routes as the same URL class.
The implementation now follows Next's metadata route extension, cache, robots, and URL resolution rules while keeping file-based social image route markers non-enumerable so resolved metadata shape does not leak internal state.
* fix(metadata): gate social image preview fallback
File-based social image routes used the preview deployment URL whenever VERCEL_URL was present outside development. That let deployment URLs override an explicit metadataBase in non-preview production environments.
Gate preview URL fallback on VERCEL_ENV=preview, add coverage for metadataBase precedence and production fallback URLs, and keep static metadata image cache headers stable in development.
Metadata title defaults currently render without the active ancestor template. That diverges from Next.js when a child layout or page provides title.default under a parent layout title.template.
The merge path resolved only the final title after collecting templates, so object defaults skipped the stashed template used for that segment. Resolve each title as it is encountered against the current ancestor template, then stash the current layout template for descendants.
Covers the child-layout default regression and updates page-default expectations to match Next.js resolveTitle semantics.
* fix(router): preserve filesystem routes before afterFiles rewrites
afterFiles rewrites were evaluated before App and Pages filesystem route matches in several runtime paths. That let a rewrite override an existing non-dynamic page, which diverges from Next.js route ordering.
The fix checks the page/app match first and only applies afterFiles rewrites when no non-dynamic route wins, while still allowing afterFiles to run before dynamic routes. Regression coverage exercises App RSC handler, Pages dev/prod, and generated Worker wiring.
* test(router): align chained afterFiles rewrite expectations
The chained rewrite fixture expected an afterFiles rewrite to override a concrete /intermediate page. That is the route-ordering behavior this branch fixes.
Update the fixture to cover both intended contracts: middleware can chain into afterFiles when no page file wins, and concrete page files are not overridden by afterFiles rewrites.
* refactor(router): remove route-ordering type assertions
Validate generated Pages route metadata at the boundary instead of asserting its shape, and make the app handler test fixture route matching explicit.
* test(router): cover afterFiles order in Pages Worker
Add a built Cloudflare Pages Router regression where a concrete page route must win before an afterFiles rewrite. Align custom Pages Worker entries with the generated worker route-match gate.
* fix(fetch-cache): dedupe identical render fetches
Uncached and no-store fetches currently bypass the persistent fetch cache by calling the original fetch directly. That diverges from Next.js, where the patched fetch wraps the original fetch in a request-scoped dedupe layer before persistent cache policy runs, so repeated GET and HEAD fetches during a render share one network response.
Add request-scoped fetch dedupe state to the fetch cache context and route network misses and cache-bypass fetches through it. The dedupe key follows the Next.js method, header, mode, redirect, credentials, referrer, referrerPolicy, and integrity semantics while still opting out for abort signals, keepalive, and side-effecting methods.
Update fetch-cache coverage for uncached render dedupe, independent response bodies, request-scope isolation, trace header exclusion, and the changed no-store/no-cache interaction.
* fix(fetch-cache): give each cloned dedupe response its own Headers
Pass `new Headers(response.headers)` per clone instead of sharing the same
Headers object across both branches and the dedupe entry. Matches Next.js'
`cloneResponse` and avoids a future-correctness trap if any code path mutates
response headers post-fetch.
Also drops two redundant `runWithFetchDedupe` calls inside `dispatchAppPage`
(the ISR revalidation render path and the intercept stream render path) — both
sites are inline anonymous functions that already inherit the dedupe scope from
the outer `dispatchAppPage` / `runAppPageRevalidationContext` wrap, so the
inner calls were no-ops and just obscured that inheritance.
* test(fetch-cache): cover Request input dedupe + always clone bodyless responses
Always construct fresh Response objects in cloneDedupeResponse, including the
bodyless path that previously returned the same `response` reference for both
tuple slots. Mirrors Next.js' cloneResponse and avoids any "disturbed response"
surprise if a runtime tracks consumption state on the shared reference. Factor
the per-clone construction into buildDedupeClone() so bodied and bodyless
clones share the headers-copy + url-restore + finalizer-register treatment.
Adds a comment near entry.response assignment noting that the unconsumed tee
branch is bounded by the render scope: when runWithFetchDedupe exits, the
dedupe map becomes unreachable and the FinalizationRegistry cancels any
still-unconsumed branch.
Also adds two tests covering the Request-object input path of
createFetchDedupeCandidate — one verifying dedupe applies for identical
Request inputs, one verifying differing non-trace headers on Request inputs
prevent dedupe.
* fix(fetch-cache): drop failed dedupe entries so later callers can retry
When the original fetch rejects, the dedupe entry stayed in the map with
response: null. Subsequent callers within the same render scope chained on
the rejected promise — propagation was correct, but they could never retry,
because the failed entry blocked fresh attempts for the rest of the scope.
React.cache() (which Next.js uses) avoids this because each call site
naturally retries on failure.
Splice the entry out of the URL bucket on rejection so a later fetch to the
same URL within the same render scope creates a fresh entry and re-issues
the upstream request.
Also align the fetch-dedupe-metadata fixture with fetch-dedupe-isr-metadata
by replacing the `as CountBody` cast with a runtime assertCountBody check.
* docs(fetch-cache): document scope inheritance + harden ISR test stabilization
Rename `dispatchAppPageWithDedupe` to `dispatchAppPageInner` so the name
reflects that it runs *inside* the dedupe scope rather than activating it.
Add comments at the four `runWithFetchDedupe` / `renderToReadableStream`
sites explaining how each one relates to the surrounding dedupe scope:
- app-page-render and app-page-boundary: defensive wrap, no-op under
dispatch; standalone callers must keep an outer scope alive across async
stream consumption since `runWithFetchDedupe` of a synchronous fn only
covers the synchronous portion.
- ISR revalidation render and intercept render in dispatch: explicitly note
why no inner wrap is needed (the outer revalidation context / dispatch
wrapper already activated dedupe).
- runWithFetchDedupe doc: document the ALS-scope-vs-async-consumption
caveat that ties this together.
Replace the fixed 200ms post-condition sleep in the ISR background dedupe
test with a 500ms count-stabilization poll. A stray third upstream fetch
(which would betray dedupe leaking across the metadata + page render
boundary) is now caught regardless of when it fires.
---------
Co-authored-by: James <james@eli.cx>
NextResponse.next({ status }) currently continues routing but drops the middleware response status. That diverges from Next.js middleware handling and makes successful route rendering mask middleware-selected statuses such as 404.
The middleware runtime now exposes a status override for continue responses, and App Router plus Pages Router dev/prod consumers feed that value into their existing middleware response contexts. Rewrite status behavior remains covered by the same path.
Tests cover the middleware runtime contract, App Router context propagation, and a Pages Router dev integration case with a real matched page.
* fix(shims): deep-merge nested metadata and add OG/Twitter inheritance
mergeMetadataEntries did a flat top-level key replacement, so setting
openGraph: { title: "Override" } in a child layout erased all root
OpenGraph fields like siteName and images. Next.js resolves these
per-subkey; we now shallow-merge the nested objects for openGraph,
twitter, icons, alternates, robots, and other.
Setting only openGraph.title without twitter.title produced no Twitter
Card tags because vinext skipped Next.js's postProcessMetadata step.
We now auto-fill twitter:title from og:title (or metadata:title),
twitter:description from og:description (or metadata:description),
and twitter:images from og:images, and default twitter:card to
summary_large_image when images are present.
Ported from Next.js:
- https://github.com/vercel/next.js/blob/canary/packages/next/src/lib/metadata/resolve-metadata.ts
- https://github.com/vercel/next.js/blob/canary/packages/next/src/lib/metadata/resolvers/resolve-opengraph.ts
* fixup! fix(shims): deep-merge nested metadata and add OG/Twitter inheritance
Extract postProcessMetadata from mergeMetadataEntries to avoid
polluting intermediate merge results during layout accumulation.
Run it once at the end of resolveAppPageHead after file-based
metadata has been applied, matching Next.js's accumulator pattern.
Also fix two issues caught by Copilot review and CI:
1. Twitter auto-fill now works even when openGraph is absent —
metadata.title and metadata.description serve as fallback sources.
2. Remove all as assertions from production code; use typed locals
and direct index access via Metadata's string index signature.
3. Update app-page-head.test.ts expectations to match post-processed
output and add missing regression test for no-openGraph case.
* chore: address review feedback — clone input, ?? for title, missing test
- postProcessMetadata now shallow-clones its input to prevent accidental
mutation of the caller's object.
- resolveStringTitle uses ?? instead of || to avoid treating empty
string as falsy (semantically correct but practically no difference).
- Added comment explaining why verification, appleWebApp, appLinks,
and formatDetection are excluded from DEEP_MERGE_KEYS.
- Added test: child can clear a parent openGraph subkey by setting it
to undefined, verifying twitter auto-fill skips cleared images.
* fix(shims): match Next metadata segment replacement
Nested metadata objects were being merged across segments, which kept stale parent OpenGraph, Twitter, alternates, icons, and robots fields when a child segment defined the same key.
Next only merges custom other metadata across segments. Replace the broad nested merge with an explicit other merge, keep OG-to-Twitter post-processing gated by openGraph, and update metadata tests to lock in the compatibility contract.
* fix(image): strip priority prop before forwarding to UnpicImage to prevent DOM leak
The `priority` prop is a Next.js-specific concept that must never reach the
DOM as an attribute. On the two UnpicImage render paths (remote URL with fill
and remote URL with width+height), `priority={true}` was forwarded directly
to `@unpic/react`'s Image component which did not reliably strip it before
rendering the DOM `<img>`, triggering:
Received `true` for a non-boolean attribute `priority`.
Fix: replace `priority={priority}` with the equivalent HTML semantics —
`loading={priority ? 'eager' : loading ?? 'lazy'}` and
`fetchPriority={priority ? 'high' : undefined}` — matching what the local
image and custom-loader paths already do correctly.
Adds 10 reproduction tests covering both affected render paths.
* test: add 50ms slack to compressed streaming timing assertion to fix CI flakiness
* chore: migrate to vite plus
* Disable typeAware and typeCheck
* Update CI
* Fix CI
* Fix test
* Clean
* Run test with vp
* Try revert
* react: false In test
* Fix test
* Revert "Try revert"
This reverts commit 009da10473.
* Update
* Update
* Try revert ci changes
* revert
* Run vp migrate
* Disable typeAware and typeCheck for now
* Better resolve for test
* Use vp dev instead of vite
* Update expect
* Fix NormalizeManifestModuleId
* Try increase timeout
* Update to use vp
* Try new check
* Bring back npx vp
* Migrate CI
* Make next-intl resolvable
* Update
* Update
* Update
* fix: handle URL objects in metadata resolveUrl
The Next.js Metadata API types URL fields as `string | URL` (e.g.
`metadataBase`, `openGraph.url`, icon/image URLs). The internal
`resolveUrl()` function in `MetadataHead` only accepted `string`,
so passing a `URL` object (which is valid per Next.js types) crashed
with `url.startsWith is not a function`.
Add type coercion at the top of `resolveUrl()` to convert `URL`
objects to strings before processing.
Reproduction:
1. Set `metadataBase: new URL("https://example.com")` in a layout
2. Set `openGraph: { url: "/page" }` in a page's metadata
3. `resolveUrl` receives the URL object, calls `.startsWith()` on it
4. Crash: `url.startsWith is not a function`
With this fix, `URL` objects are coerced to strings via `.toString()`.
* fix: update Metadata types and rendering to accept string | URL fields
Address bonk review feedback:
- Add test covering URL object input for alternates.canonical and
openGraph.url to lock in the fix against regressions
- Update Metadata interface to type URL fields as string | URL,
matching Next.js types (openGraph.url/images/videos/audio,
twitter.images, icons, manifest, alternates.canonical/languages/
media/types)
- Add overload to resolveUrl() so it returns string when called with
a non-undefined argument, eliminating string | URL fallback issues
- Fix image/icon array normalisation to handle bare URL objects
alongside strings and descriptor objects
* fmt
* fix: remove dead-code ?? fallbacks and unnecessary test casts
Address bonk re-review feedback:
- Remove 'as unknown as string' casts in the URL-object test since the
Metadata interface now types those fields as string | URL
- Remove all 17 remaining 'resolveUrl(x) ?? x' fallbacks; resolveUrl
is overloaded to return string for any truthy input so these never
fired, and with string | URL fields the fallback value would have
passed a URL object directly to content=/href= attributes
* fmt
---------
Co-authored-by: James <james@eli.cx>
* fix: MemoryCacheHandler revalidate:0 creates immediately-stale entries
MemoryCacheHandler.set had an inconsistent guard on data.revalidate:
the ctx path checked `revalidate > 0` but the data path only checked
`typeof data.revalidate === "number"`, allowing revalidate:0 to set
revalidateAt = Date.now() — making entries immediately stale.
This was inconsistent with KVCacheHandler which already had the > 0
guard on both paths. The two handlers would produce different staleness
behavior for the same input: Memory → immediately stale, KV → no TTL.
Add the missing `> 0` guard to align both code paths and both handlers.
* fix: revalidate:0 should skip cache storage entirely
Both MemoryCacheHandler and KVCacheHandler had inconsistent handling of
revalidate:0. In Next.js, revalidate:0 means "don't cache." But the
handlers would still store entries — Memory made them immediately stale
(data path lacked > 0 guard), KV cached them forever (both paths
rejected 0, so revalidateAt=null meant no expiry).
Resolve effective revalidate from both ctx and data paths (data
overrides ctx), and early-return without storing when it's 0. Both
handlers now use identical logic.
Fix existing test data that incidentally used data.revalidate:0 in
tests meant to exercise stale-while-revalidate and tag invalidation.
* 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
* 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
* add oxfmt formatter: config, scripts, CI, editor setup, docs
* rebuild lockfile
* fix: add Format to required checks list, remove dead ignore pattern
* run fmt
* add format to agents.md again
* fix(instrumentation): defer ssrLoadModule call to post-middleware hook
Vite 7's SSRCompatModuleRunner requires the SSR environment's transport
channel to be initialized before ssrLoadModule() can be called. During
configureServer(), the channel is not yet ready, causing a TypeError
when instrumentation.ts exists.
Move the runInstrumentation() call from the configureServer body into
the returned post-middleware function, where environments are fully
initialized.
Fixes#167
* fix(instrumentation): address review comments on deferred ssrLoadModule
- Remove unreachable .catch() on runInstrumentation() since internal
try/catch already handles errors and the function never rejects
- Mock console.error in transport error test to suppress noisy output
and assert the expected error message
- Rename misleading test to accurately describe what it verifies
* test: address instrumentation review feedback
* fix: preserve multiple Set-Cookie headers in prod-server and worker entry
The response header merging in prod-server.ts and the generated Cloudflare
worker entry used Record<string, string> which flattened multiple Set-Cookie
headers — the last value won, or cookies with Expires dates got corrupted
by comma-joining.
Extract mergeResponseHeaders() helper that uses getSetCookie() to preserve
array-valued Set-Cookie headers. Apply the same fix to the worker entry
template in deploy.ts using the Headers API.
Fixes#295
* fix: address review feedback on Set-Cookie header preservation
- Add `as string` cast in deploy.ts worker entry for x-middleware-request-*
header unpacking (matches prod-server.ts)
- Normalize Vary header with Array.isArray check in sendCompressed to handle
the widened type correctly
- Add edge-case test for middleware cookie as plain string (not array)
* fix: apply Set-Cookie fix to example workers, remove redundant toLowerCase
- Fix the same Set-Cookie flattening bug in hand-written example worker
entries (pages-router-cloudflare, realworld-api-rest)
- Remove 4 redundant .toLowerCase() calls — Headers.forEach() always
yields lowercase keys
* fix: add missing as string cast in deploy.ts middleware header collection
* refactor: extract mergeHeaders to shared vinext/server/worker-utils
The mergeHeaders function was duplicated in both example worker entries
(pages-router-cloudflare, realworld-api-rest) and the deploy.ts template.
Extract it to a shared module that example workers import. The deploy.ts
template still inlines it (code generation constraint).
Reduces 3 copies to 2 (shared module + generated template).
* fix: array-accumulate Set-Cookie in config headers, fix JSDoc, add behavioral test
- deploy.ts template: config headers section was comma-joining Set-Cookie values
using += which corrupts cookies with Expires dates and loses all but the last
cookie when multiple config rules match. Matches the array-accumulation pattern
already used in prod-server.ts (lines 899-907) and the middleware section above.
- worker-utils.ts + deploy.ts template JSDoc: 'Response headers take precedence'
was misleading — Set-Cookie is additive, not overriding. Clarify both docs.
- tests/deploy.test.ts: add behavioral test for mergeHeaders via worker-utils import
(same function inlined in the generated template), replacing reliance on
string-contains assertions alone.
* fix: indentation in deploy.ts template, array-accumulate Set-Cookie in example workers
- deploy.ts: fix extra leading space in config headers block (lines 636-659)
and JSDoc lines 740-743 introduced in previous commit
- pages-router-cloudflare, realworld-api-rest: config headers section was
doing a plain assignment (middlewareHeaders[lk] = h.value) which overwrites
array-valued Set-Cookie built up by the middleware section above; use the
same array-accumulation pattern as prod-server.ts and the deploy.ts template
* fix: remove as any casts in prod-server.ts, add Vary handling to example workers
- prod-server.ts lines 904/906: middlewareHeaders is now Record<string, string | string[]>
so the as any casts are unnecessary; replace with as string (consistent with the
middleware collection section above at line 848)
- pages-router-cloudflare, realworld-api-rest: add Vary comma-joining to config
headers section to match prod-server.ts (line 908) and deploy.ts template (line 653)
---------
Co-authored-by: James <james@eli.cx>
* fix: inject default viewport meta (width=device-width, initial-scale=1)
mergeViewport() started from an empty object, so when a user exported
a viewport config with only themeColor (no width/initialScale), the
merged result was truthy but missing viewport dimensions. ViewportHead
then skipped the <meta name="viewport"> tag entirely, breaking mobile
rendering.
Seed mergeViewport with DEFAULT_VIEWPORT matching Next.js's
createDefaultViewport() defaults: { width: "device-width", initialScale: 1 }.
User-provided values still override these defaults.
* refactor: export DEFAULT_VIEWPORT and reuse at call sites
Export DEFAULT_VIEWPORT from metadata shim and replace the duplicated
inline literals in app-dev-server.ts to keep the default in one place.
* refactor: simplify mergeViewport call sites to always apply defaults
Since mergeViewport() always seeds from DEFAULT_VIEWPORT (even for
empty lists), the `viewportList.length > 0` guards and `?? DEFAULT_VIEWPORT`
fallbacks at both call sites were redundant. Remove them so the default
viewport is applied unconditionally via mergeViewport itself.
Addresses review feedback from james-elicx on PR #298.
Closes#228. vinext was not loading .env files, so environment variables
defined in .env were unavailable to server-side code (getServerSideProps,
API routes, server components) and NEXT_PUBLIC_* vars were missing from
the client bundle defines.
Uses Vite's loadEnv() in the config hook with an empty prefix to load
all .env vars (not just VITE_-prefixed) into process.env before
loadNextConfig() runs, matching Next.js behavior.
Wrap all decodeURIComponent calls on user-controlled URL paths in
try/catch to return 400 Bad Request instead of throwing an uncaught
URIError that crashes the Node process.
A single request with a malformed percent-encoded path (e.g. /%E0%A4%A)
could terminate the entire server process. This affected all server
entry points: prod server (App + Pages Router), dev server middleware,
Cloudflare Worker entry, and generated RSC/middleware handlers.
Fixes:
- prod-server.ts: App Router and Pages Router request handlers
- app-router-entry.ts: Cloudflare Worker entry
- app-dev-server.ts: Generated RSC entry handler
- index.ts: Dev server connect middleware + generated middleware runner
+ generated NEXT_LOCALE cookie parser
- middleware.ts: Pages Router dev middleware runner
Includes 11 regression tests across app-router, pages-router, and
features test suites.