Commit Graph

1756 Commits

Author SHA1 Message Date
James Anderson 8f89a2d1df fix(build): recognize Vite dist client aliases (#2750) 2026-07-29 16:10:06 +01:00
James Anderson 052a2f7b78 fix(server): support Node production entry contracts (#2749) 2026-07-29 15:44:45 +01:00
MaxtuneLee 25dc2f3f28 test(routing): isolate reserved-char param cases from Windows (#2698)
Two Pages Router tests build temp route fixtures whose param names use
characters reserved in Windows filenames: `[repo:name].tsx` (`:` opens an
NTFS alternate data stream, so pagesRouter never discovers the route) and
`[id*].tsx` (`*` makes writeFile throw). Both errored on `vp test run`
locally on Windows. Split each test so the valid-everywhere case (`.`, `+`)
runs on all platforms and the reserved-char case is gated with
`it.runIf(process.platform !== "win32")`. These scenarios are unreachable
on Windows in production too, so gating loses no coverage; POSIX CI still
exercises them.

Related to #1792
2026-07-28 23:26:20 +01:00
MaxtuneLee e845eced7d test(app-router): isolate * param route-graph case from Windows (#2697) 2026-07-28 23:25:45 +01:00
Jerry Zhao a0e5d1be86 chore(deps): upgrade vite-plus to 0.2.6 (#2694)
* chore(deps): upgrade vite-plus to 0.2.6

* fix(app-router): suppress benign AbortError from superseded navigations

A fast follow-up navigation aborts the in-flight navigation's RSC fetch
mid-stream. When the aborted stream still holds an un-consumed React Flight
chunk (e.g. streamed metadata the superseded route never rendered), React
reports the resulting AbortError globally as a window `error` event rather
than to a specific consumer, which trips the "no console errors" e2e
assertion (metadata-icons.spec.ts).

Install a page-lifetime window listener at bootstrap that preventDefault()s
these benign navigation AbortErrors, mirroring the existing redirect-error
bridge. Installed once (not in a component effect) so there is no listener
gap while the router tree re-renders mid-navigation.

The vite-plus 0.2.6 toolchain shifted navigation timing enough to expose
this latent race on CI.

* chore: adapt to vite-plus 0.2.6 toolchain

Follow-up to the vite-plus 0.2.6 bump; keeps CI green under the new
oxfmt/oxlint/rolldown toolchain. No published runtime change.

- Formatting: oxfmt 0.60.0 reformats README.md, apps/web/next.config.ts and
  tests/nextjs-compat/TRACKING.md (collapses empty-object-with-comment;
  unpads markdown tables). Applied `vp check --fix`.
- Lint: oxlint 1.75.0 now flags dynamic `import("node:path")` under the
  existing no-restricted-imports rule. Disabled inline in loadStaticPrerender
  with a reason -- the resolved path feeds a dynamic import(), so pathslash's
  forward-slash canonicalization buys nothing there.
- Test: rolldown code-splits the server build, so the font markers moved out
  of index.js into _next/static/* chunks. Scan the whole server output for
  the markers instead of index.js alone (verified they still exist).

* Revert "fix(app-router): suppress benign AbortError from superseded navigations"

Drop the runtime AbortError suppressor to keep this PR a pure toolchain bump.
The metadata-icons "rapid icon replacement" failure it addressed is a
pre-existing, timing-dependent race (not caused by the upgrade). Assessing
whether it triggers stably on CI / locally before deciding how and where to
fix it.

This reverts commit 84454d5fd of this branch.
2026-07-28 23:24:18 +01:00
James Anderson 795855ee4d fix(build): support dynamic package subpath imports (#2746)
* fix(build): support dynamic package subpath imports

* fix(build): harden package dynamic import resolution
2026-07-28 21:12:52 +01:00
Nathan Nguyen 73f56ef3f8 fix(app-router): honor cacheLife stale on the client router (#2708)
* fix(app-router): honor cacheLife stale on the client router

`cacheLife` profiles carry three independent numbers, and `stale` is the
client-router dimension: how long the browser may reuse cached route output
without asking the server. vinext aggregated it correctly (`resolveCacheLife`
min-reduces all three; the request scope accumulates min-wins) and then
projected it away at every consumer, so the only staleness a browser ever saw
was `dynamicStaleTimeSeconds` from `experimental.staleTimes` — a build-time
constant unrelated to the cached subtrees that produced the render. A subtree
declaring `cacheLife("seconds")` (stale: 30) was held for the full 5-minute
visited-response TTL.

Carry the resolved `stale` to the client on `x-nextjs-stale-time` (matching
Next.js's `NEXT_ROUTER_STALE_TIME_HEADER`) and combine it with the config
value by taking the minimum, so neither min-wins lattice overrides the other.
The 30s prefetch floor applies to the new value too.

Two normalization rules, both deliberate:

- An absent `stale` is never synthesized from `revalidate`/`expire`. The
  `default` profile has no `stale` and an `expire` of ~136 years, so deriving
  one would license session-long reuse without a refresh.
- The three numbers are not assumed to be ordered — `seconds` is
  `{ stale: 30, revalidate: 1, expire: 60 }` — so `revalidate` never
  constrains `stale`. Only the hard `expire` ceiling does.

* fix(app-router): source the client stale time from completed renders

The previous commit derived `x-nextjs-stale-time` from `peekRequestCacheLife()`
at response-construction time. That read happens after `probeAppPageBeforeRender`
but before the RSC stream is consumed, and the probe only awaits the page
component's own async result — it does not render the returned tree. Any
`use cache` scope in a child Server Component registers later, during stream
consumption, so the header described the probe rather than the output.

Because `cacheLife` aggregation is minimum-wins, missing one late scope is
enough to make the value wrong, and "it can only shorten" did not hold: with no
`dynamicStaleTimeSeconds` present (the usual case for `use cache` pages, which
are not dynamic renders), a peeked `stale: 300` widened the prefetch window from
`PREFETCH_CACHE_TTL` (30s) to 300s — 10x longer than today, in the direction the
change set out to fix.

Carry the value on the cache entry instead. The ISR write path reads the
request-scoped accumulation via the consuming `getRequestCacheLife()` inside the
cache-write closure, which runs after the captured stream has drained, so it
observes the completed render's minimum. `CacheControlMetadata` gains `stale`,
`isrSet` persists it, and `buildAppPageCachedResponse` re-advertises it on hits.
Prerender seeds carry it through `VINEXT_PRERENDER_CACHE_LIFE_HEADER` and the
prerender manifest so a seeded entry makes the same claim a runtime render would.

Two links that would otherwise silently drop the claim are closed with it: the
`use cache` entry write now persists `stale`, and `recordRequestScopedCacheControl`
re-registers it on a data-cache hit — without both, a page's advertised freshness
would depend on data-cache temperature rather than on what it declared.

Streaming responses now advertise nothing and leave the client on its configured
`experimental.staleTimes`, which is the honest answer for a value that is not yet
known when headers are committed. Covering fresh streaming renders needs a
render-completion signal the streaming RSC path does not have today; Next.js
solves it by streaming an `AsyncIterable<number>` in the RSC payload
(`app-render.tsx` `baseResponse.s`, closed after the render settles), which is
the natural follow-up.

The client-side combination logic is unchanged: both stale signals are still
min-reduced, absent `stale` is never synthesized from `revalidate`/`expire`,
`revalidate` never clamps `stale`, and `expire` still caps it.

* fix(app-router): deliver the resolved client stale time on fresh renders

The previous round persisted the completed render's cacheLife stale onto
the ISR entry and replayed it on cache hits, which left the value missing
or drifting wherever the entry's lifetime diverged from the render's:

- Fresh streaming renders advertised nothing, so the first visitor of any
  route (and every dev render, since dev writes no ISR entry) stayed on
  the configured staleTimes despite a declared cacheLife. The done-script
  emitted by the RSC embed transform's finalize() runs only after the
  full RSC stream has drained, so it can carry the completed render's
  minimum where streaming headers cannot. Emit the peeked request-scoped
  cacheLife there and seed the hydration visited-response entry from it.
  This also makes the client's min-combination of the config and
  cacheLife signals reachable: a dynamic render with use cache subtrees
  now legitimately carries both.

- The expire clamp bounded the value but not the elapsed window: an
  entry of { stale: 30, expire: 60 } hit at age 59s replayed a 30s reuse
  window reaching 29s past expire. Age the serve-time clamp with the
  entry's lastModified so only the remaining window is advertised.

- The two client caches applied different floors: prefetch entries
  floored a cacheLife stale at 30s while cold navigations honored it
  verbatim, so the same declaration produced two behaviors keyed on
  whether a prefetch fired first. Floor the cacheLife signal once in the
  shared resolver, mirroring Next.js getStaleTimeMs, before the min so
  it can never raise the config-derived bound.

Mechanically, the app-page cache setter's six-position signature
(declared identically in four modules) collapses into one exported
AppPageCacheSetter taking an AppPageCacheWritePolicy object, with
isrSetAppPage adapting to the shared positional isrSet, which stays
unchanged for the Pages Router and route handlers.

* docs(app-router): pin the cold RSC stale contract and expire precedence

The cold RSC fetch gap is a design decision, not a pending follow-up:
Next.js's AsyncIterable stale transport only works under staged rendering
(cacheComponents), where cache scopes settle before the render task queue
drains — in vinext's lazy streaming model the iterable could never close.
Next.js's plain-mode mechanism is a blocking cold render, which #961
deliberately rejected to keep ISR page streams unblocked. Assert the
resulting no-header contract on the streaming-response test.

Also reword the write-policy expire comment: a cacheLife-declared expire
replaces the config expireTime fallback (Next.js precedence), it is not
min-merged with a route-level ceiling — no such ceiling exists outside
cacheLife.

* fix(app-router): carry stale through regen and bound cold responses

Three fixes from review round 3:

Background regeneration dropped the regenerating render's cacheLife stale:
renderAppPageCacheArtifacts returned only { revalidate, expire }, so
resolveRegeneratedAppPageCachePolicy could never receive the stale it was
built to preserve and the first regen silently widened client reuse back
to the configured fallback. The producer now carries it, with a
real-producer regression test the mocked-cacheControl tests could not
provide.

The age-aware expire clamp is removed. Composed with the client's 30s
floor it delivered neither contract (a clamped 1 re-floored to 30), and
cached HTML replayed the unclamped done-script value regardless. Next.js
stores the stale header at generation and replays it verbatim on every
hit; vinext now does the same, keeping expire a serve-side ceiling.

Cold cacheable RSC responses stream before their cacheLife resolves
(#961), which left them on the 300s client fallback — reproducing the
headline bug for the first request of every entry epoch. They now carry
X-Vinext-Stale-Time-Pending, and both client caches bound such responses
at the 30s floor: the unresolved claim, once floored, could never
license less.

* fix(app-router): bound pending-stale responses by the dynamic stale time

The pending marker meant 'capture was attempted', but the client read it
as 'a cacheLife claim exists'. Capture eligibility is decided before the
lazy stream runs, so a late request-API read can make the completed
render dynamic — the finalizer skips the ISR write and no claim ever
exists, yet the marker granted 30 seconds of reuse even under
staleTimes.dynamic: 0.

Pending responses now carry the configured dynamic stale time (including
0) and the client takes the minimum, so an unresolved response never
receives a wider window than the dynamic bound.

* fix(app-router): keep the pending cap independent of staleTimes.dynamic

Pairing the pending marker with the configured dynamic stale time broke
the segment-cache-client-params compat test: dynamic-param routes
prefetch with no minimum TTL, so the paired 0 default made every cold
prefetch entry expire instantly and navigations refetched routes that
Next.js serves entirely from a static prefetch.

A cold stream cannot distinguish a render that will resolve static from
one that turns dynamic mid-stream, and bounding both by staleTimes.dynamic
sacrifices the guaranteed-correct case for the ambiguous one. Pending
responses go back to the 30s floor cap; the late-dynamic exposure
(one epoch-cold response, at most 30s) is documented as the price of
non-blocking cold renders (#961).

* fix: carry the client stale claim through KV, the prerender index, and nested cache hits

- writePrerenderIndex now copies `stale` into vinext-prerender.json so
  seedMemoryCacheFromPrerender actually receives it
- KVCacheHandler.set() and buildPrerenderKVPairs persist cacheControl.stale
  so warm hits on the Cloudflare KV backend replay the producing render's
  claim; validateCacheEntry accepts the field
- a nested use cache HIT pushes its stored lifetime into the enclosing
  cache context's lifeConfigs, mirroring the MISS path, so the outer entry
  keeps the child's stale claim once the outer goes warm

* perf: trim shipped comment bytes in the stale-time plumbing

The added modules ride in every consumer build environment; the review-grade
rationale lives in the PR body, the code keeps one-line constraints.

* refactor(app-router): model the cached client stale claim as one state

CachedRscResponse carried staleTimePending and staleTimeSeconds as two
independent optionals, so the cached form admitted a state the wire never
produces (pending and resolved at once) and every consumer had to encode
the precedence rule. Replace both with a discriminated serverStaleTime
(pending | resolved), collapsed once at the header-parse boundary.

* refactor(isr): give isrSet a cache-metadata write policy

The generic setter had grown to six positional arguments, the last an
App-Router-only `stale` value, with isrSetAppPage as a policy-object
wrapper that unpacked straight back into it. Take { cacheControl, tags }
instead: routers construct the metadata they actually claim, the wrapper
and its type disappear, and the shared isrCacheControl builder replaces
the cache-control literal duplicated across writers.

* fix(app-router): keep the dynamic bound on captured dynamic renders

The done-script metadata reused the RSC header's rule of dropping the
config-derived dynamic stale time while the speculative ISR capture was
armed. That rule only holds on the header path, which substitutes the
pending marker and its 30s floor; the done script emits the resolved
cacheLife instead, so a production render that turned dynamic shipped the
cacheLife claim as its only bound and let the hydration-seeded entry reuse
dynamic output for its full duration. Dev never took the capture path, so
the two diverged.

Also migrates the pages-basic seed fixture, the last positional isrSet
caller, which typecheck does not cover.

* ci: re-run after unrelated dev-overlay canary flake

* fix(app-router): preserve completed client stale metadata

* fix(app-router): strip completion metadata during HMR

* fix(app-router): stream completion metadata safely

* fix(app-router): validate completed stale metadata

* docs(cache): clarify zero stale client claim

---------

Co-authored-by: James <james@eli.cx>
2026-07-28 14:41:47 +01:00
Boy Steven f517288f40 fix(server): reject unsupported static asset methods (#2714)
* fix(server): reject unsupported static asset methods

* fix(server): route public asset mutations to 405

* chore: retrigger CI

* fix(server): preserve static method parity across runtimes

* fix(server): discard static probe responses safely

* fix(server): sanitize static method responses

* fix(server): preserve static method status

* fix(server): strip stale static range headers

* fix(server): preserve static rewrite ordering

* fix(dev): cache public file routes

* fix(dev): harden cached public route handling

* fix(worker): avoid unnecessary asset probes

* fix(server): preserve static asset lookup coverage

* fix(build): keep public path encoder internal

---------

Co-authored-by: James <james@eli.cx>
2026-07-28 13:59:20 +01:00
Boy Steven 66ce8172f3 fix(build): skip unhelpful precompressed variants (#2712)
* fix(build): skip unhelpful precompressed variants

* fix(build): require wire-beneficial precompression

* fix(server): ignore precompression temp artifacts

* fix(server): scope temp exclusion to hashed assets

* test(server): keep range fixture wire-beneficial

---------

Co-authored-by: James <james@eli.cx>
2026-07-27 20:48:25 +01:00
Nathan Nguyen bf2d7acce7 fix(navigation): reuse router.prefetch payloads during navigation (#2709)
* refactor(link): extract app prefetch policy helpers to internal module

link.tsx is a "use client" React module, so its prefetch policy helpers
(resolveAutoAppRoutePrefetch, resolveFullAppRoutePrefetch and their
supporting trie/route-href logic) could not be reached from navigation.ts,
which must stay importable without React. Move them verbatim into
shims/internal/app-route-prefetch-policy.ts (same layering as
internal/app-route-detection.ts) and name the previously inline policy
shape AppRoutePrefetchPolicy.

link.tsx re-exports canAutoPrefetchFullAppRoute and
resolveAutoAppRoutePrefetch, keeping its public test surface unchanged.
No behaviour change.

* fix(navigation): reuse router.prefetch payloads during navigation

router.prefetch() stored every response as a learning-only cache entry
(cacheForNavigation: false), so a router.prefetch(x) followed by
router.push(x) always issued a second RSC request; even an in-flight
programmatic prefetch was invisible to navigation. Next.js reuses
router.prefetch() results (PrefetchKind.AUTO by default, kind: "full"
caches the full payload), and <Link> prefetches in vinext already
produce reusable entries through the shared route-policy helpers.

Resolve the same policy in router.prefetch(): kind: "full" maps to the
full-prefetch policy, anything else to the auto policy for the target
route. Reusable prefetches send the same segment-prefetch headers as
<Link>, gate re-issue on a freshness-aware navigation cache probe
(discarding a stale learning-only twin first), and store the payload
with navigation TTLs plus ahead-of-navigation snapshot preparation.

Routes the auto policy declines (no manifest match, loading shell,
search params) keep the previous learning-only fetch, preserving
optimistic-route-template learning and matching Next.js AUTO semantics
for dynamic routes. In-flight sharing needs no extra machinery:
consumePrefetchResponseForNavigation already awaits a pending entry
once the entry is no longer learning-only.

Fixes #2707

* test(e2e): cover router.prefetch reuse across an intercepted navigation

Covers issue #2707 end to end on the /top -> /film/[imdbId] interception
fixture: a programmatic router.prefetch followed by a Link click must
produce exactly one /film RSC request while still rendering the
intercepted film panel. A companion test without the prefetch pins the
request counter so the reuse assertion cannot pass vacuously.

* fix(navigation): resolve rewrites and alias matches in router.prefetch reuse

Address review findings on the router.prefetch reuse path:

- A configured client rewrite can map the prefetched href onto a
  different App route. The policy previously described the source
  pattern, so a destination with a loading boundary or a
  requires-dynamic flag was incorrectly marked reusable. Load the
  rewrite-aware hybrid module first and resolve the policy (and the
  freshness-gate lookup) against the rewritten destination, mirroring
  Link's prefetchPolicyHref.

- When the freshness gate matched a reusable entry under a normalized
  _rsc variant or rendered-path alias, onInvalidate was attached via an
  exact-key lookup that silently missed, so the documented callback
  never fired on expiry. hasPrefetchCacheEntryForNavigation now attaches
  the callback to whichever entry it matched.

- Import the prefetch policy module dynamically inside the prefetch
  closure so its route-trie dependencies stay off the startup path of
  every next/navigation consumer (AGENTS.md performance guidance).

* fix(navigation): keep router.prefetch lifecycle correct across navigation

Two lifecycle gaps in the router.prefetch navigation-reuse path:

`router.prefetch()` awaits hybrid-route loading, the lazily imported
prefetch policy module, and RSC URL generation before it registers
anything in the prefetch cache. A navigation starting in the same task
finds nothing to share, issues its own request, and the late prefetch
then starts a second one. Track a navigation epoch alongside the
existing link-navigation-start signal and bail out of prefetch setup
when a navigation won the race, mirroring link.tsx's guard.

Consuming a prefetched response deleted the entry with notify=false,
which discarded `onInvalidate` without ever calling it -- so
`prefetch(href, { onInvalidate })` followed by `push(href)` silently
lost the subscription. Retain the callbacks past consumption so they
still fire once when the original stale window elapses or the prefetch
cache is invalidated, matching Next.js's prefetch-task contract. An
entry that aged out before navigation reached it now notifies instead
of dropping the callback.

* refactor(navigation): de-duplicate app prefetch policy and snapshot prep

No behavior change.

`<Link>` and `router.prefetch()` carried byte-identical `prepareSnapshot`
closures; extract `prepareNavigationPrefetchSnapshot` and share it.

`resolveAutoAppRoutePrefetch` repeated the same five-field "no prefetch"
literal at four early returns; collapse them to a single
NO_APP_ROUTE_PREFETCH constant. Every call site reads the resolved policy
without mutating or spreading it, so one shared object is safe.

Fold `resolveMatchedAutoAppRoutePrefetch` and its post-hoc search-param
override into one object literal: `prefetchShellFirst` becomes
`hasSearchParams || !isDynamic`, matching the previous unconditional
`true` on the search branch and `!isDynamic` otherwise.

`canAutoPrefetchFullAppRoute` re-ran the window / manifest / href /
trie-match checks before delegating; each of those failures already
resolves to `cacheForNavigation: false`, so drop them.

The policy module stays behind a dynamic import inside the prefetch
closure, keeping its route-trie dependencies off the next/navigation
startup path.

* fix(navigation): notify superseded prefetches and void setup on invalidation

Two more lifecycle gaps in the router.prefetch reuse path.

Upgrading a learning-only prefetch to a navigation-reusable one
(`router.prefetch(href)` then `router.prefetch(href, { kind: "full" })`,
or the equivalent <Link> transition) discarded the old entry with
notifications suppressed, so `onInvalidate` callbacks registered by the
first call could never run. A superseded prefetch is dirty in Next.js
terms, so notify instead of dropping. Fixed in the shared discard helper
rather than at the router call site: link.tsx reaches the same path on
the same upgrade. Entries are now collected before deletion so a
subscriber that seeds a new prefetch cannot be visited by the loop that
is still iterating the cache.

The prefetch setup epoch advanced only on navigation start, but
`router.refresh()` clears the caches via `clearNavigationCaches` ->
`invalidatePrefetchCache` without starting a navigation. A closure still
awaiting its policy import would resume afterwards and repopulate a
navigation-reusable entry derived from the pre-refresh generation,
undoing the refresh for that route. Advance the epoch on invalidation
too, and move it next to the cache it guards.

* refactor(navigation): reuse existing prefetch helpers and drop duplicated branches

No behavior change.

router.prefetch generated the RSC URL and its rewrite variant with two
sequential awaits over the same headers; run them together.

It also set the mounted-slots header by hand when
createAppPrefetchRequestHeaders already accepts `mountedSlotsHeader`.
The option sets the header for any non-null value while the call site
skipped empty strings, so pass `|| null` to keep an empty slot header
off the wire.

link.tsx built a `full-after-shell` policy literal byte-identical to
resolveFullAppRoutePrefetch(), which the policy extraction had already
made shared and which the file already imports; both arms of the ternary
now resolve the same way, so the branch collapses.

hasPrefetchCacheEntryForNavigation had two byte-identical if bodies and
a local closure that existed only to avoid duplicating their shared
tail. Merge the conditions, and hoist the callback attachment into
attachPrefetchInvalidationToEntry so the key-taking
attachPrefetchInvalidationCallback delegates to it instead of repeating
the "seeded entries need their invalidation timer scheduled" invariant.

* fix(navigation): promote a queued prefetch when navigation consumes it

Low-priority prefetches share a 4-slot queue whose runners do not start
until a previous response body is read. A navigation that reuses an
in-flight prefetch awaits that prefetch's promise, so when the request is
still queued the navigation waits on unrelated response bodies before its
own request starts — indefinitely if one of those streams stalls.

Promote on consume: `consumePrefetchResponseForNavigation` now starts a
queued request instead of waiting for a slot. A promoted request is no
longer a prefetch, it is the navigation, so it bypasses the concurrency
cap. Reaching the queued runner needs a handle the queue did not expose,
so it keeps a WeakMap from the returned promise to its runner, and the
cache entry records the schedulable promise (`entry.pending` is the
derived chain, not the promise the queue knows).

Pre-existing via <Link>: link.tsx passes cacheForNavigation through and
prefetchRscResponse defaults it to true, so low-priority viewport
prefetches already produced reusable entries that navigation awaited.
Fixed in the shared queue rather than the router path so both benefit.

Also drops prefetch cache-entry field assertions that restated the
implementation where the same test already asserted the behavior those
fields produce (consume returning a payload, or null).

* test(navigation): cover router.prefetch then router.push at the browser boundary

The issue reports router.prefetch(x) followed by router.push(x). The
existing e2e covered router.prefetch(x) followed by a <Link> click, which
proves cache interoperability but not the reported API sequence — a
regression confined to _appRouter.push or its navigation-start handling
would pass it.

Adds the literal reproduction, plus the same-task variant where push()
starts before prefetch setup has registered anything. Both assert the
contract (destination renders, route fetched once) rather than the
mechanism, so either cancelling the late prefetch or having navigation
adopt an already-started one satisfies them.

Removes the unit test that claimed to cover the same-task path. It
asserted fetch was never called after prefetch() + push(), but push()
issues no request in the unit environment, so it only ever proved the
prefetch did not fire — never the one-request contract. The browser test
replaces it and checks the actual invariant.

* fix(navigation): scope prefetch-setup cancellation to the destination

router.prefetch() guarded its asynchronous setup with a global generation
counter that any navigation advanced. A navigation elsewhere therefore
abandoned an unrelated programmatic prefetch: nothing was going to fetch that
route, so the prefetch became timing-dependent and a later navigation to it had
to fetch again.

Replace the counter with a registry of pending setup tokens keyed by
destination. A navigation cancels only the setups for the href it is about to
fetch itself; invalidatePrefetchCache() still cancels every pending setup,
which is the one case where all in-flight setup really is stale.

Cancellation stays sticky per token, so a navigation to /a followed by one to
/b leaves a pending /a prefetch cancelled — the property a "current
destination" comparison would lose.

Also check the caller's shouldConsume predicate before promoting a queued
prefetch past the concurrency cap, so a navigation superseded while its cache
lookup was being prepared cannot start a low-priority request that competes
with the current navigation.

* fix(navigation): do not cancel prefetch setup on shallow history updates

The patched history.pushState/replaceState reused the navigation-start hook to
clear a stale useLinkStatus() pending state. Once that hook began cancelling
prefetch setup by destination, those two call sites started dropping a pending
prefetch for the URL they moved to — but a raw history update only changes
browser state and issues no RSC request, so nothing takes the prefetch's place.

Split the hook: resetStaleLinkStatus() does the link-status half, and
notifyAppNavigationStart() adds destination cancellation for the callers that
really do fetch. The history patches take the former; popstate keeps the
latter, since the App Router's popstate handler drives an RSC fetch.

Covered by an e2e case rather than a unit test — the unit suite stubs
window.history, so the shim's patch is never the function under test there.

* fix(navigation): cancel superseded prefetch requests

Normalize pending setup keys without URL fragments so same-resource navigations cancel late prefetch setup. Give queued and active prefetch requests abort controls, allowing learning-only entries superseded by a full prefetch to stop instead of downloading a duplicate payload. Avoid loading the rewrite-aware ownership chunk when client rewrites are disabled.

* fix(navigation): capture prefetch context at call time

Match Next.js by snapshotting interception, mounted-slot, nextUrl, and router-tree request context before asynchronous prefetch setup. This prevents same-task history changes from re-keying an explicit prefetch as though it originated from the destination route.

* fix(navigation): keep prefetch setup across same-document hash changes

Stripping the fragment from pending-prefetch destinations made hash-only
navigations compare equal to the route they scroll within, so pushing
/film/x#cast while already on /film/x cancelled a pending /film/x prefetch.
That navigation issues no RSC request — the planner classifies it as a
same-document scroll — so nothing replaced the prefetch it dropped.

Skip cancellation for same-document hash changes. The fragment is still
stripped when comparing destinations, which is what makes a real navigation to
/film/x#cast supersede a pending /film/x prefetch; the two rules are
complementary, not competing.

popstate gets its own entry point: the browser has already applied the history
entry by then, so the pre-navigation URL that isHashOnlyBrowserUrlChange needs
is gone. It keeps cancelling by destination, which over-cancels a hash-only
back/forward at the cost of one re-prefetch and never a duplicate request.

Also fixes the eslint(no-unsafe-optional-chaining) failure in the queue test
that turned CI's `vp run check` red.

* test(navigation): keep queued prefetch assertions type-safe

* fix(navigation): preserve prefetch identity through streaming

Resolve relative prefetch ownership and policy from the call-time URL, matching the already captured request target. Keep abort controls alive until response bodies are consumed so superseded streaming prefetches release bandwidth and queue slots.

---------

Co-authored-by: James <james@eli.cx>
2026-07-27 18:25:03 +01:00
Boy Steven 3d1ab6bd50 fix(server): honor static freshness validators (#2715)
* fix(server): honor static freshness validators

* fix(server): preserve content type on cached 416 responses

* fix(cloudflare): preserve partial asset response status

* docs(server): clarify static range representation

* fix(server): align cached precondition headers

* fix(server): align slow-path not-modified headers

* fix(server): adjust RFC 850 leap-day centuries

* fix(server): align static range and method handling

* fix(server): match weak static etags for If-Match

---------

Co-authored-by: James <james@eli.cx>
2026-07-27 17:39:22 +01:00
Boy Steven c9a4a843ce fix(server): use weak comparison for If-None-Match (#2710)
* fix(server): use weak comparison for If-None-Match

* chore: retrigger CI

* fix(server): validate conditional static requests

* fix(server): tighten entity-tag parsing

* test(server): document entity-tag comma behavior

* fix(dev): weak-match public asset etags

* fix(dev): normalize public etag lookup paths

* fix(dev): canonicalize conditional public assets

* fix(dev): honor Vite public file identity

* fix(dev): mirror Vite public filesystem semantics

* fix(dev): refresh public filesystem capabilities

* fix(dev): verify public path aliases

* fix(dev): index verified public request aliases

* fix(dev): compose verified public path aliases

* fix(dev): resolve Unicode public path classes

* fix(dev): use Unicode simple case matching

* fix(dev): match Unicode expansion tokens

* fix(dev): keep public path node private

* fix(dev): resolve public-root symlink redirects

* refactor(dev): leave public etag handling to Vite

* test(server): reject malformed etag list tails

* fix(pages): share conditional etag matching

* refactor(pages): remove reversed etag matcher

---------

Co-authored-by: James <james@eli.cx>
2026-07-27 15:48:39 +01:00
Boy Steven b779754ee6 fix(server): serve static assets with standard MIME types (#2713)
* fix(server): serve static assets with standard MIME types

* fix(server): add UTF-8 to textual static asset types

* fix(server): match Next static charset casing

* fix(server): lowercase MIME charset values

---------

Co-authored-by: James <james@eli.cx>
2026-07-27 14:18:05 +00:00
Nathan Nguyen 357092873f test(e2e): gate clicks that can land before hydration commits (#2717)
* test(e2e): gate the dev-overlay soft-nav click on hydration

The soft-nav canary test waited only for the navigation runtime to publish
functions.navigate before clicking the Link. That happens before React
commits the hydrated tree — a probe measures a 12-30ms window locally, wider
on a loaded runner — so a click can land on an anchor with no listener
attached, the browser follows the href, and the reload canary the test
asserts on is lost.

Use waitForAppRouterHydration, which also requires __VINEXT_HYDRATED_AT (an
App Router post-commit effect) and settles two frames. It is what the file's
six other tests and the sibling app-with-src spec covering the same contract
already use.

Closes #2716. Recurrence of #1769; same race as #2409.

* test(e2e): gate use-params-app-pages clicks on hydration

The 12 clicks in tests/e2e/use-params-app-pages/ were dispatched with no
hydration gate. App Router publishes the navigation runtime 3-6 rAF ticks
before __VINEXT_HYDRATED_AT, and hydration is complete neither when goto()
resolves nor after one visibility assertion, so a click can land before
React attaches its delegated listeners and be dropped.

Gate each click on the helper the clicked element's router actually owns:
waitForAppRouterHydration for the App Router page at /, waitForHydration
for the Pages Router page at /form-source.

The prefetch test additionally asserted zero RSC requests, which passes
vacuously when the handler never runs. Add a positive control: the button
now also prefetches an App-owned /prefetch-control, whose RSC request only
appears if the handler fired. Its request filter also matched a literal
".rsc" path suffix, which this project never emits (_rsc is a query
param), so switch to the shared isAppRouterRscRequestForPath helper.

Refs #2716.
2026-07-27 13:05:47 +01:00
Tom Voss 0b58bbc8ff fix(metadata): preserve Content-Length for fully buffered responses (#2703)
* fix(metadata): preserve Content-Length for fully buffered responses

closeAfterResponseWithBody() wraps every body-bearing response in a
TransformStream to support function-form after(), which strips
Content-Length even when the body is already fully materialized.
Metadata file convention responses (robots(), sitemap(), manifest(),
static icons) always serialize to a string or byte array with no
producer left, so mark those bodies (markFullyBufferedBody) and skip
the wrap when nothing is registered.

Arbitrary Route Handler responses and a metadata route's Response
passthrough stay wrapped: their body's producer may still call after()
after the handler resolves, so a "nothing registered yet" check can't
prove them safe.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqasUFjUPt2Q8gkrzrLGyB

* fix(metadata): preserve deferred response lifecycle

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: James <james@eli.cx>
2026-07-27 12:04:59 +00:00
Nathan Nguyen cfcd60b9a9 test: give middleware auth guard assertion a CI-safe timeout (#2720)
* test: give the unit project the integration timeout

The unit project set no `testTimeout`, so it inherited vitest's 5s default
while holding 47 files that boot a dev server or run a production build. 39 of
those pass a per-test timeout (up to 180_000); the ones that did not sat on a
~20% margin — `middleware-matcher-auth.test.ts`'s auth-guard case measures 4.0s
under normal parallel load — which ordinary CI variance erases. It and
`typegen.test.ts` each failed a different CI run that way while passing locally.

Adopts the 30000 the integration project already declares. A timeout costs
wall-clock only when a test hangs, so nothing that passes slows down, and
per-test arguments still take precedence.

Closes #2719

* test(middleware): scope auth guard timeout

---------

Co-authored-by: James <james@eli.cx>
2026-07-27 13:03:41 +01:00
Nathan Nguyen da0a8ef1e5 fix(image): match Next.js 16 default image sizes (#2704)
* fix(image): match Next.js 16 default image sizes

Next.js 16 removed the 16px default, but vinext continued generating and accepting it when imageSizes was unset. This added an unnecessary srcSet candidate and diverged from the pinned Next.js behavior.

Use the shared server default for build-time defines and keep the client shim fallback aligned. Explicit imageSizes configurations that include 16px remain supported.

* test(image): update generated default width expectation

The Next.js 16 default-width change correctly updated generated App Router entries, but this behavior test still encoded the previous list. Align the expectation while preserving the neighboring explicit 16px opt-in coverage.
2026-07-27 12:57:32 +01:00
Nathan Nguyen 39b10bae43 refactor(navigation): derive bfcache segment identity from route graph (#2070)
* feat(navigation): derive bfcache segment identity from route graph

BFCache segment ids currently depend on AppElements wire keys and pathname segment counting. That lets route identity drift from the route graph and makes preservation sensitive to string surgery around pathnames, catch-all routes, slots, and intercepted state.

The violated invariant is that a restorable segment should be keyed by the route graph state that owns it, using the same bound segment key encoding as React reset keys, with the artifact graph version only as an equality component inside the current document.

Add a typed BFCache identity descriptor engine, carry canonical per-segment state keys over the AppElements wire metadata, and have SSR and browser identity construction read those same bytes. Missing metadata falls back to an empty bound segment key so older payloads remint instead of deriving a second identity path.

The tests cover graph-version changes, carried key preservation and reminting, catch-all canonical keys, default versus unmatched slots, traversal restoration, interception, and server-action refresh preservation.

* test(navigation): cover bfcache segment state key metadata

* fix(navigation): expose segment state key wire metadata

BFCache segment-state-key metadata is first-class AppElementsWire metadata, but the stable keys facade did not expose it. That left callers able to import the raw constant while bypassing the codec boundary.

Add segmentStateKeys to AppElementsWire.keys, cover it in the codec tests, and tighten local graphVersion wording to artifact-compatibility equality semantics.

* fix(navigation): require carried segment keys for bfcache identity

Missing __segmentStateKeys parsed to an empty map, but the browser state code converted an absent per-segment key to an empty bound key and still produced a comparable identity. Legacy or low-context payloads could therefore preserve BFCache ids through graphId and slot facts alone.

Return null when a segment lacks a carried key, preserve only proven non-null identities, and cover both absent-key reminting and explicitly carried empty-string keys.

* refactor(navigation): drop unreachable bfcache descriptor paths

The BfcacheSegmentDescriptor union carried a `kind: "default"` variant and a
template `ownerLayoutId` field that no producer ever constructs. Default
parallel slots are emitted as `kind: "slot"` with `state: "default"`, and the
template branch in createBfcacheSegmentIdentity hardcoded `ownerLayoutId: null`.
Their dedicated tests asserted behaviour the integration cannot reach, giving
false coverage confidence.

A template's owner layout shares the template's tree path, so a static owner id
would be fully determined by graphId and add no distinguishing identity; real
owner-remount re-keying would need the owner's live instance id, which is not
available here. Dropping the field keeps the typed contract honest about which
facts actually participate in segment identity.

Also refresh the stale slot.tsx comment that still described pathname-tracked
state keys (now route-graph-binding tracked) and annotate the state-key-map
omission site so the intentional legacy-payload remint reads as deliberate.

* fix(navigation): wire bfcache identity test metadata

* fix(app-router): keep graph versions out of bfcache ids

* fix(app-router): reuse slot reset keys for bfcache metadata

BFCache metadata computed slot bindings from the configured route even when an interception override changed the effective route. That let preservation identity diverge from the React reset boundaries for overridden parallel slots.

Populate each slot metadata key from the effective slot reset key at the render boundary, and assert the wire key follows changed interception bindings.

* refactor(app-router): finalize element metadata after rendering

Rendered element maps registered their dependency barriers before metadata was added, which made the metadata object reference part of the construction lifecycle. A final spread could also break dependency release because registration was attached to the pre-merge object.

Build the rendered element map first, assemble the completed metadata map at return time, and register render dependencies on the final returned object.

* fix(app-router): reject malformed segment identity maps atomically

Valid-looking segment entries must not remain authoritative when a wire payload contains a malformed sibling or a non-segment element id. Partial parsing could preserve untrusted BFCache bindings.

Validate each binding and wire id before accepting the map, returning an empty map on any failure so BFCache consumers remint conservatively. Add codec regressions for malformed values and route ids.

* refactor(app-router): carry opaque bfcache identities

BFCache consumers were rebuilding segment identity by joining bound keys, slot bindings, interception metadata, and parsed wire ids. That allowed independently valid metadata fragments to form a derived identity.

Build the complete identity at the route-graph boundary and carry one validated opaque map. Compare carried identities directly in the browser, and replace route-reconstruction tests with route-boundary behavior coverage.

* perf(app-router): avoid copying rendered element maps

Final metadata assembly copied the complete rendered element map after the route graph was built, adding avoidable allocation to the cold request path.\n\nMerge the completed metadata into the existing rendered map at the return boundary, then register dependencies and return that same final object.

* refactor(navigation): make bfcache identity graph-owned

Build an immutable per-render segment plan from route graph IDs, resolved bindings, slot ownership, and interception facts before rendering. Emit completed opaque identities once, while keeping loading reset-key work in the rendering shell.

Preserve concrete activeRouteId values for render planning, use semantic route IDs only inside BFCache descriptors, and use the graph route ID for synthetic carriers on layout-only routes.

* fix(navigation): preserve bfcache identity with merged segments

Planner-approved layouts and slots can retain their previous element value while the destination payload replaces the identity metadata map. That left preserved client state without its matching identity proof, so the following navigation minted a new BFCache ID.

Treat each preserved element and its opaque identity as one merge unit. If the previous payload has no valid proof, remove any destination identity for that retained value so reuse remains conservative. The regression follows a preserved modal through the commit and a subsequent navigation.

* refactor(navigation): remove wire ids from bfcache identity

Intercepted segment identities still incorporated payload-facing slot and route IDs, so transport encoding changes could alter logical equality even when the route graph was unchanged.

Carry the semantic interception target from route matching into the immutable segment plan and use graph-owned slot, layout, active-route, and target-route IDs in the identity tuple. Wire IDs now only address AppElements entries. If semantic interception data is unavailable, omit the proof and conservatively remint instead of falling back to transport identity.

* fix(navigation): preserve bfcache segment identity parity

* fix(navigation): retain nested bfcache segment ownership

* fix(navigation): preserve intercepted segment ownership

* fix(navigation): preserve slot shell segment ownership

* fix(navigation): commit bfcache snapshots atomically

* fix(navigation): key sibling interceptions by graph id

* refactor(app-router): extract the bfcache segment planner

Move createAppPageSegmentPlan and its helpers out of app-page-route-wiring
into app-page-segment-plan, and have route matching hand the planner a
resolved semantic branch (marker split off, binding param map chosen)
instead of re-parsing interception syntax downstream.

* docs(app-router): record template route-group keying parity

Codex flagged the template state key stopping at a route group as a missed
remount. It is not: Next keys a template from its immediate child segment
(createRouterCacheKey in layout-router.tsx) and route groups are levels in
the FlightRouterState, so sibling routes inside a group do share the
template above it. Note the parity so the behaviour is not re-litigated.

* refactor(app-router): tighten the bfcache segment planner

Collapse the nested-segment candidate flags that were constant per slot,
build the bound segment keys in one pass instead of re-slicing per index,
memoise the route prefix state keys shared by layouts and slot owners, and
index layout entries by id instead of scanning them per lookup. Share the
interception marker table with the tests and drop the one-element array
hop in resolveAppPageSemanticSegmentStateKey.

* refactor(app-router): collapse identical bfcache descriptor arms

page, layout and template serialise the same four fields; the kind tag
already keeps them distinct.

* chore: re-run CI benchmarks

* refactor(navigation): drop the unreachable bfcache id map fallback

getBfcacheIdMapContext only returns null when React.createContext is
unavailable, but the fallback was itself created by an unconditional
createContext call in this "use client" module: if the guarded condition
held, constructing the guard would have thrown first. Use the same
non-null context both call sites already assume.

---------

Co-authored-by: James <james@eli.cx>
2026-07-27 12:55:51 +01:00
github-actions[bot] 05eee9fecc chore: version packages (beta) (#2673)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
@vinext/cloudflare@1.0.0-beta.4 vinext@1.0.0-beta.4
2026-07-24 16:23:30 +01:00
James Anderson 1c69ce3fef fix(pages-router): load custom _app/_document via resolved file paths in dev (#2692)
* fix(pages-router): load custom _app/_document via resolved file paths in dev

The dev server imported pages/_app and pages/_document by extensionless id
and relied on the module runner applying custom resolve.extensions (e.g.
".page.tsx" from pageExtensions). The runner no longer resolves those, so
apps using pageExtensions silently lost their custom App/Document (the load
failure is swallowed). Import the file path resolved by findFileWithExts
instead, at all three call sites.

* test(e2e): add pages-router-complex example app and behaviour suite

A deliberately convoluted Pages Router app that serves as a compatibility
target: the kinds of patterns that only surface in large, long-lived
enterprise pages-router codebases, each pinned by a Playwright spec whose
behaviour is verified against real Next.js (73/73 under next dev --webpack).

Highlights: custom pageExtensions everywhere (middleware.page.ts,
instrumentation.page.ts), app-shell getInitialProps with a TTL-memoised
chrome fetch and embedded-shell/draft bypasses, class _document with its own
GIP, a zone route dimension driven by middleware rewrites and a real
i18next/react-i18next runtime, catch-all routes with static-sibling
precedence and cacheable-404/redirect hygiene, record-driven template
branching on a dynamic/static/dynamic route, an urql-style server-snapshot
data layer, shallow routing + router.events + next/compat/router +
next/navigation hooks in pages, next/image with a custom loader, draft-mode
gateway APIs, a function-form next.config, and a Cloudflare setup matching
the vinext init scaffold.

The playwright project (pages-router-complex, port 4199) is intentionally
not in the CI e2e matrix: vinext currently passes 59/73, and the failures
are the compat backlog documented in the example README.

* fix(pages-router): cover compound page extensions in CI
2026-07-24 15:35:16 +01:00
James Anderson 956bed233b fix(pages): preserve live Set-Cookie header arrays (#2689)
* fix(pages): preserve live Set-Cookie header arrays

* fix(pages): align writeHead cookie replacement
2026-07-24 14:29:27 +01:00
MaxtuneLee 756d7eeee4 fix(css): load Sass partials from paths containing tildes (#2691)
* fix(css): normalize Sass importer stylesheet keys

* perf(sass): simplify path handle

* style(sass): simplify stylesheet key handling
2026-07-24 11:22:51 +01:00
James Anderson 69f03c871b fix(test): prevent recursive deploy log growth (#2693) 2026-07-24 11:21:45 +01:00
Ching Wei Kang 431b044879 fix: honor build --mode dotenv files (#2523)
* fix: honor build mode dotenv files

* fix: preserve test-mode dotenv exclusion in config

* fix(build): keep NODE_ENV production with custom mode

* fix(build): set production env before config loading

* fix(env): preserve dotenv expansion operators

* fix(build): preserve explicit NODE_ENV for config loading

---------

Co-authored-by: James <james@eli.cx>
2026-07-23 20:30:53 +01:00
MaxtuneLee f2252edf15 fix(build): normalize jsx-in-js module ids on Windows (#2687) 2026-07-23 15:32:28 +01:00
Nathan Nguyen 391eda0ce9 test(link): isolate asynchronous prefetch setup (#2684)
Link prefetch tests could restore global mocks while detached RSC URL hashing was still in flight, allowing late requests to land in the next test. The dynamic-route case also counted a leaked viewport request as its second fetch.\n\nTrack cache-busting digest promises through teardown and make positive assertions wait for their owned requests. A delayed-digest regression keeps the original race reproducible.
2026-07-23 14:26:29 +01:00
MaxtuneLee 052b2661ab fix(rsc): handle Windows paths in client reference dedup (#2686) 2026-07-23 14:18:18 +01:00
James Anderson 5f259cee96 fix(app-router): install default not-found boundary (#2670)
* fix(app-router): install default not-found boundary

* fix(app-router): own default not-found at root layout
2026-07-23 13:51:48 +01:00
James Anderson 8e7fa22491 fix(app-router): scope dynamic params by segment (#2228)
* fix(app-router): scope dynamic params by segment

* Reviewed PR #2228: correct, approved

Co-authored-by: james-elicx <james-elicx@users.noreply.github.com>

* fix(app-router): render static param misses through not-found

* fix(app-router): preserve context for static param 404s

---------

Co-authored-by: ask-bonk[bot] <ask-bonk[bot]@users.noreply.github.com>
Co-authored-by: james-elicx <james-elicx@users.noreply.github.com>
2026-07-23 13:50:27 +01:00
James Anderson 8e36c914e0 fix(app-router): honor client cache stale times (#2449)
* fix(app-router): honor client cache stale times

* fix(app-router): preserve static reusable prefetch stale time

* fix(app-router): reset fresh page state after prefetch

* fix(app-router): preserve intercepted source state

* fix(app-router): preserve client cache segment identity

* fix(app-router): preserve dynamic slot cache semantics
2026-07-23 13:48:42 +01:00
James Anderson 22f106e18d perf(app-router): serialize streamed metadata once (#2675) 2026-07-22 21:00:40 +01:00
James Anderson e7800ad75f fix(app-router): resolve interception-only RSC targets (#2256)
* fix(app-router): dispatch interception-only targets

* fix(app-router): preserve interception action rerenders

* fix(app-router): align interception request path matching

* test(app-router): distinguish encoded interception paths

* test(app-router): preserve intercepted source params
2026-07-22 20:36:49 +01:00
James Anderson 95661c2192 fix(cache): preserve prerendered page cache tags (#709)
* feat(cache): implement Next.js 16 revalidateTag two-phase stale/expired model

- Add TagRevalidationDurations interface and update CacheHandler interface
- Rewrite MemoryCacheHandler to use stale/expired TagManifestEntry model
- Rewrite KVCacheHandler with KVTagEntry JSON format (backward-compat with legacy plain-timestamp)
- Add deprecation warning to public revalidateTag() when called without profile
- SWR semantics when profile with expire>0: mark stale immediately, hard-expire after window
- Hard invalidation when no profile or expire=0: set expired=now, next get() is a miss
- Fix >= comparisons for same-millisecond set()+revalidateTag() correctness
- Add tests for deprecation warning, SWR stale return, expire=0 hard miss, JSON KV format

* refactor(cache): address bonk review comments

- Document >= vs > as deliberate divergence from Next.js with rationale
  (same-millisecond set+revalidateTag must invalidate; strict > would allow
  stale serves when both events share a timestamp)
- Export TagRevalidationDurations interface from next-shims.d.ts and use it
  in CacheHandler, MemoryCacheHandler, and revalidateTag signatures
- Remove dead ...existing spread in MemoryCacheHandler.revalidateTag — both
  branches fully overwrite the TagManifestEntry, the spread could accidentally
  preserve stale fields if the interface grows
- Tighten deprecation warning test regex to match the exact emitted message

* fix(cache): match Next.js updateTags branch logic for durations without expire

When revalidateTag is called with a truthy durations object, stale is now
always written to the tag manifest regardless of whether expire is set.
The expired field is only set when durations.expire !== undefined.

This fixes two divergences from Next.js default.ts updateTags:
- revalidateTag('tag', {}) → { stale: now } (stale-only SWR), was { expired: now }
- revalidateTag('tag', { expire: 0 }) → { stale: now, expired: now }, was { expired: now }

Applies the same fix to both MemoryCacheHandler and KVCacheHandler.
Adds tests covering the new stale-only ({}) and expire=0 shapes.

* fix(cache): reuse now variable in MemoryCacheHandler.get for time-based expiry check

* fix(cache): preserve prerendered page cache tags
2026-07-22 20:33:12 +01:00
James Anderson c1b47304c5 fix(app-router): preserve hash query navigation semantics (#2669) 2026-07-22 20:31:44 +01:00
James Anderson a09207618a fix(pages): normalize decoded edge responses (#2668)
* fix(pages): normalize decoded edge responses

* fix(pages): thread hybrid edge runtime
2026-07-22 17:56:12 +01:00
James Anderson eb3b15fdc2 fix(app-router): reconcile streamed metadata icons (#2320)
* fix(app-router): reinsert streamed metadata icons

* fix(app-router): harden streamed metadata icons

* fix(app-router): make streamed icon insertion hydration-safe

* fix(app-router): reconcile all streamed metadata icons

* fix(app-router): preserve distinct streamed icon descriptors

* fix(app-router): match metadata icon descriptor parity

* fix(app-router): sanitize streamed metadata icons

* test(app-router): allow streamed metadata siblings

* test(app-router): ignore icon attribute order

* fix(app-router): preserve parser-time icon order
2026-07-22 17:51:51 +01:00
James Anderson 5fc6fb322b fix(pages): propagate Document script security props (#2044)
* fix(pages): propagate document asset props

* fix(pages): scope document asset props in dev

* fix(pages): preserve default asset tag attributes

* fix(pages): apply document asset props in default dev shell

* fix(pages): apply document props after dev transforms

* fix(pages): scope document asset marker cleanup

* fix(pages): preserve document asset ownership

* fix(pages): preserve document asset prop ownership

* fix(pages): avoid backtracking when adding asset props
2026-07-22 17:46:42 +01:00
James Anderson 5b6d0c867d fix(app-router): align prefetch server protocol (#2318)
* fix(app-router): align prefetch server protocol

* fix(app-router): keep prefetch state type internal

* fix(app-router): avoid consuming partial prefetches

* test(app-router): explicitly ignore router push result

* fix(app-router): preserve full prefetch semantics

* fix(app-router): preserve explicit full prefetch requests

* fix(app-router): guard malformed next-url inference

* test(app-router): clarify slot loading boundaries

* fix(app-router): publish transitional cache snapshots

* fix(app-router): canonicalize prefetched response URLs

* fix(app-router): preserve cross-origin prefetch responses
2026-07-22 13:12:09 +01:00
github-actions[bot] 3d30a3ca36 chore: version packages (beta) (#2632)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
@vinext/cloudflare@1.0.0-beta.3 vinext@1.0.0-beta.3
2026-07-21 23:24:35 +01:00
Iago Lima Toledo 023aa76403 fix(metadata): add meta's 2024 crawler UAs to the html-limited bot list (#2666)
* fix(metadata): add meta's 2024 crawler UAs to the html-limited bot list

Meta introduced meta-externalagent and meta-externalfetcher in 2024. The
default html-limited bot list (mirrored from Next.js) only carries the
legacy facebookexternalhit, so the new UAs get streamed metadata. Meta's
crawlers don't execute JS, so every og:* tag lands after </head> and
link previews on WhatsApp/Instagram/Facebook render blank.

Deliberate, documented divergence from Next.js's default list (which
predates Meta's UA migration) per AGENTS.md. A user-provided
htmlLimitedBots config still replaces the whole list.

Test: new UAs get blocking metadata, the legacy UA stays covered, and a
custom htmlLimitedBots config still replaces the default.

* refactor(metadata): share html-limited bot detection

---------

Co-authored-by: MrIago <28714061+MrIago@users.noreply.github.com>
Co-authored-by: James <james@eli.cx>
2026-07-21 23:17:55 +01:00
James Anderson 6f65eaab38 fix(app): log RSC render errors on the dev-server terminal (#2667)
* fix(app): log RSC render errors on the dev-server terminal

`reportRequestError` is a no-op when no `onRequestError` instrumentation
hook is registered, so a server-component render error was swallowed
silently in the dev server. Next.js's instrumentation wrapper logs render
errors in development regardless of user instrumentation; mirror that by
emitting a `console.error` from the RSC onError handler in dev.

The `!hasDigest` guard dedupes: the same error object reaches the handler
on both the RSC and SSR/HTML render passes, and the first pass stamps it
with a digest — matching Next.js's `silenceLog` dedup so it logs once.

* fix(app): ignore expected RSC render cancellations

* docs(app): clarify digest log suppression
2026-07-21 22:51:36 +01:00
Nathan Nguyen 88ed49e11d perf(build): cache repeated compatibility transforms (#2578)
* perf(build): cache repeated compatibility transforms

App Router builds run pure dynamic-request and typeof window transforms repeatedly across RSC, SSR, and analysis passes. Re-parsing identical module input adds build work without changing output.

The transform hooks now reuse results for exact module id, source, and environment replacement keys while replacing stale per-id source entries. Focused tests cover cache reuse and key separation.

* perf(build): reuse pure compatibility transforms

Repeated build environments can parse and rewrite the same module source more than once. The transform result is deterministic once the module id, source, and environment-derived variant are fixed.

Share the existing bounded per-module cache across the compatible transform plugins and cover source, module, variant, and null-result boundaries.

* fix(build): invalidate cached source identities after junction retargets

Repeated transforms can keep emitting import.meta.url and CJS globals for a junction's previous canonical target when the raw ID and source stay unchanged.

The import-meta-url cache variant omitted the canonical path even though the rewrite derives its output from that path. Include canonicalId in the variant and cover retargeting between identical source files.

* ci: rerun performance benchmarks

* perf(build): avoid composite import-meta cache keys

Eligible import-meta transforms allocate and hash a composite string containing the canonical root and module path on every invocation, including cache hits. That adds deterministic work to dev cold start.

Keep source, canonical root, and canonical id as direct equality fields in a per-module entry. Retain only the two-value environment result map so canonical-path invalidation remains correct without composite key allocation.

---------

Co-authored-by: James <james@eli.cx>
2026-07-21 22:04:22 +01:00
Nathan Nguyen 2824b7653a fix(router): replace stale optimistic layouts across dynamic params (#2609)
* fix(router): replace stale optimistic layouts across dynamic params

A detached optimistic shell can commit stale dynamic-layout output before the authoritative payload resolves. Preparing the latter from live router state then makes the shell appear current, so stale server props and BFCache identity survive a cross-param navigation.

All payloads in one navigation must derive reuse identity from the same initiation state. Capture that state once and pass it through commit preparation, while live router state remains the authority for cancellation and commit approval.

Add composition coverage for cross-param replacement, same-param preservation, and layout-owned slots, plus a deterministic browser regression for the prefetched-shell handoff.

* fix(router): require navigation initiation state for payload preparation

Navigation payload callers could omit the initiation state and silently prepare from live router state. A future caller could therefore compile while reintroducing the optimistic-to-authoritative identity bug.

Require the state at both browser-entry and controller boundaries and remove the live-state fallback. Isolated controller tests now choose current-state preparation through an explicitly named test helper.

* test(router): synchronize payload tests on state dispatch

Navigation payload regressions advanced a fixed number of microtasks before reading router state. That coupled the tests to the controller's current async scheduling depth.\n\nExpose a one-shot visible-commit dispatch waiter from the controller harness and await that explicit boundary before assertions.

* docs(router): document the currentState baseline in createPendingNavigationCommit

createPendingNavigationCommit's currentState param has no note on what
it should be. Navigation callers now pass the frozen navigation-initiation
state (per the previous two commits), while the HMR caller still passes
live state, and nothing marks that split as deliberate.

A future navigation call site that passes live state instead of the
initiation state would silently reintroduce the stale cross-param reuse
bug this branch fixes, with no type error to catch it.

Document the invariant on the field so the split reads as intentional.

---------

Co-authored-by: James <james@eli.cx>
2026-07-21 21:31:12 +01:00
Nathan Nguyen 060de14a0b fix(cloudflare): report custom-domain deploy URLs (#2630)
* fix(cloudflare): report custom-domain deploy URLs

Cloudflare deploys that disable workers.dev currently finish with "(URL not detected in wrangler output)" even when Wrangler confirms a custom-domain target. Wrangler emits custom domains as bare hostnames, while vinext only parses HTTPS workers.dev URLs.

Parse Wrangler custom-domain target markers, validate the hostname as an HTTPS origin, and ignore wildcard routes and disabled domains. Preserve workers.dev URL precedence and cover the deploy boundary plus Wrangler 4.110 output variants.

* ci: retry flaky unit test shard

The upstream unit-test shard failed while removing a temporary typegen directory after all test assertions passed. The affected test is unrelated to this branch and passed 20 consecutive targeted runs locally.\n\nTrigger a fresh GitHub Actions run without changing the pull request diff.

---------

Co-authored-by: James <james@eli.cx>
2026-07-21 21:18:24 +01:00
Andrew ba34531727 perf(build): split react-dom/server into its own client chunk (#2604)
* perf(build): split react-dom/server into its own client chunk

createClientManualChunks keyed the always-loaded "framework" chunk on the bare
package name ("react-dom"), so react-dom/server.browser + its cjs implementation
rode along on every page even though only client code that renders to a string
(e.g. an embedded Sanity Studio) imports it. Route the server/static renderer
entrypoints to a dedicated "react-dom-server" chunk so framework stays ~35KB
brotli lighter on every page; the server renderer loads only where used.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(build): normalize react-dom server chunk ids for Windows

* test(build): cover react-dom server chunk entrypoints

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: James <james@eli.cx>
2026-07-21 14:12:38 +01:00
ponharu 24eb360d22 fix(metadata): pass parent to cached resolvers with default or rest p… (#2660)
* fix(metadata): pass parent to cached resolvers with default or rest parameters

* fix(metadata): preserve parent for opaque cache exports

---------

Co-authored-by: James <james@eli.cx>
2026-07-21 11:29:54 +01:00
James Anderson d4d02cb805 fix(link): interpolate Pages Router dynamic hrefs (#2657)
* fix(link): interpolate Pages Router dynamic hrefs

* PR #2657 review: LGTM

Co-authored-by: james-elicx <james-elicx@users.noreply.github.com>

---------

Co-authored-by: ask-bonk[bot] <ask-bonk[bot]@users.noreply.github.com>
Co-authored-by: james-elicx <james-elicx@users.noreply.github.com>
2026-07-21 00:53:51 +01:00
Boy Steven 3a3dfe82d4 test(build): cover extensionless optimized imports (#2650)
* fix(build): resolve extensionless optimized imports

* fix(build): honor Vite extension resolution order

* fix(build): honor environment resolver extensions

* fix(build): preserve Vite directory resolution

* test(build): cover extensionless optimized imports

* test(build): harden extensionless import coverage

---------

Co-authored-by: James <james@eli.cx>
2026-07-21 00:37:45 +01:00
James Anderson ce00c8dbf6 fix: sync middleware matcher fixture lockfile (#2658) 2026-07-20 23:28:30 +01:00
Christoph Richter 3bef10c56b fix(init): complete an existing Cloudflare config instead of only adding to it (#2653)
* fix(init): complete an existing Cloudflare config instead of only adding to it

`vinext init --platform=cloudflare` generates a correct wrangler.jsonc when
there isn't one, but when a wrangler.jsonc already exists it only merged
`cache`, `images` and `kv_namespaces` into it. `main` and `assets` were never
added and never warned about, so the two paths disagreed about what a valid
Cloudflare config is and only one of them was checked.

Without `main`, @cloudflare/vite-plugin builds the project as assets-only: the
RSC environment emits no dist/server/wrangler.json, the `start:vinext` and
`deploy:vinext` scripts init just wrote point at a file that no longer exists,
and the deploy falls back to the assets-only config. It reports success and
ships a Worker with no SSR entry, so static assets return 200 and every route
returns 404 with no index.html to fall back on. Nothing in the output points
at `main`.

The vite config had the same shape of bug: `ensurePlugins` only adds plugins
that are absent, so an existing bare `cloudflare()` kept its defaults and an
App Router project silently lost
`viteEnvironment: { name: "rsc", childEnvironments: ["ssr"] }`.

- `updateWranglerConfigForCloudflare` now fills in `main` and `assets` when
  absent, reusing the worker-entry resolution `generateWranglerConfig` already
  used, so both paths produce the same config. Existing values are left alone
  and the update stays idempotent.
- `ensureCloudflareViteEnvironment` adds `viteEnvironment` to an existing
  `cloudflare()` call for App Router projects, whether it is called bare or
  with other options.

Reproduced on 1.0.0-beta.2 with a generator that emits an app and its
wrangler.jsonc together, then runs `vinext init --platform=cloudflare`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(init): validate existing Cloudflare config

---------

Co-authored-by: piffie <1213363+piffie@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: James <james@eli.cx>
2026-07-20 23:25:43 +01:00