* fix(router): support experimental gesture push
Vinext did not expose useRouter().experimental_gesturePush when Next.js apps enabled experimental.gestureTransition. Upstream gesture-transition fixtures call the method inside a held async transition, so the target route never became visible before the canonical router.push completed.
The fix resolves the config flag into the same __NEXT_GESTURE_TRANSITION define used by Next.js, exposes the optional router method only under that flag, and lets gesture navigation use the existing RSC navigation pipeline with an immediate approved visible commit. The ported e2e fixture preserves the upstream held-transition flow and target/static/dynamic assertions.
* test(router): update navigate mock assertions to expect default transition commit mode
* fix(router): address ask-bonk review comments on gesture push
- track scheduled app router navigation in experimental_gesturePush for
parity with push/replace, so a refresh() issued in the same task skips
its redundant re-fetch
- document the synchronous visible-commit mode's null pendingRouterState
invariant in the browser navigation controller
- add a unit test asserting experimental_gesturePush is only attached
when the __NEXT_GESTURE_TRANSITION define is set
* style(tests): fix formatting in shims.test.ts
* docs(router): clarify gesture push normalization and flushSync commit paths
* test(router): fix unbound-method lint in shims test and document gesture spec coverage
* docs(router): clarify gesture define boolean form and spec held-state rationale
* docs(test): note gestureTransition is fixture-wide for the gesture spec only
* fix(router): no-op experimental_gesturePush before the navigation runtime is ready
Upstream gesturePush early-returns when getCurrentAppRouterState() is null,
so a gesture dispatched before hydration is a no-op. Previously vinext fell
through navigateClientSide's non-runtime branch and performed a real history
push. Guard on the runtime's navigate function (the same readiness check
navigateClientSide uses) and document that the un-awaited
void navigateClientSide(...) deliberately matches push/replace's
fire-and-forget rejection shape.
* docs(router): note gesturePush guard/dispatch runtime reads have no TOCTOU window
* docs(test): document gesture fixture's double-navigation divergence from upstream
---------
Co-authored-by: James <james@eli.cx>
* fix(link): full-prefetch dynamic routes without loading shells
Automatic App Router prefetch skipped dynamic routes unless they exposed a loading boundary. That diverged from Next.js segment-cache behavior for client-param routes, where making the link visible must initiate a prefetch and a click to the exact URL can reuse the prefetched payload without another request.
The bad assumption was that every dynamic route needs shell-only learning or no prefetch. Vinext keys the prefetched RSC snapshot by the exact target URL, so dynamic routes without a loading shell can safely seed navigation cache while dynamic routes with loading.tsx keep the existing shell-only path.
Port the focused Next.js client-params regression into the Link decision and scheduling tests.
* fix(link): skip shell-first inlining for client-param prefetches
Automatic dynamic routes without loading shells now seed the navigation cache, but the prefetch-inlining branch still treated those cacheable requests like static route payloads and issued a loading-shell request before the full fetch.
That shell request has no loading boundary to learn from for client-param routes, so it is a wasted roundtrip. Split cache eligibility from shell-first transport eligibility, document the route-shape heuristic, and derive the test-only full-prefetch helper from the same decision.
Add coverage that dynamic no-loading routes remain single-fetch under prefetch inlining.
* fix(app-router): honor per-response dynamic stale times on the client
App Router navigation reused and evicted cached RSC responses using one global prefetch TTL, so pages with different unstable_dynamicStaleTime values could not stay fresh independently. Traversal also needed a route instance boundary so browser history restores hidden state instead of reusing a forward-navigation component instance.
The client cache now snapshots dynamic stale-time response metadata, computes expiry per entry, preserves staleAt across cache replays, and derives mounted-slot cache variants from the decoded payload. Browser traversal keeps route instances under React Activity while normal navigations allocate fresh route instances, matching Next's BFCache/stale-time contract.
* fix(link): make prefetch dedupe freshness-aware for navigation prefetches
The old prefetched.has(cacheKey) gate in prefetchUrl returned early for
any exact key match, including stale entries. This meant an expired
prefetch with the same cache key would never reach
hasPrefetchCacheEntryForNavigation, so it would never be deleted and
never refreshed.
The new helper is built to check resolvePrefetchCacheEntryExpiresAt,
delete stale entries, and return false. But the Link path bypassed it
when the old prefetched Set contained the exact key.
Fix: gate the exact-key check on the helper's freshness decision. When
the helper reports the exact entry is stale and deletes it, also remove
the key from the prefetched Set so the fall-through path can issue a
fresh fetch. Keep the second helper call for equivalent _rsc variants.
Add an integration test proving visible Links re-prefetch when their
exact cache entry goes stale, not just that the helper reports stale
entries as unavailable.
* refactor(app-router): collapse redundant prefetch/visited-cache gates
Address review feedback on the client dynamic-stale-time changes:
- link.tsx: the prefetch dedupe path called
hasPrefetchCacheEntryForNavigation twice when an exact entry already
existed. Collapse to a single freshness-aware gate that covers both the
exact entry and equivalent _rsc variants.
- app-browser-entry.ts: drop the redundant early refresh return in
getVisitedResponse; isVisitedResponseCacheEntryFresh already bypasses
the cache for refresh navigations (and the miss path evicts the entry).
* docs(app-router): document intentional refresh eviction in visited cache
Per review feedback: getVisitedResponse now routes refresh navigations
through isVisitedResponseCacheEntryFresh (the single source of truth),
which returns false for refresh and therefore falls through to the read
eviction. Document at the fall-through delete that a refresh intentionally
drops any prior snapshot, since the navigation re-fetches and re-stores a
fresh one.
* docs(navigation): clarify load-bearing prefetch-cache dual checks
Per review feedback, document two checks that look superficially redundant
but preserve observable contracts:
- isPrefetchCacheEntryCompatibleWithMountedSlots: the two clauses accept
either the entry's pinned request-time slot context or the server-declared
snapshot header; they diverge in the prefetchRscResponse case.
- consumePrefetchResponse expiresAt guard: only synthesize expiresAt onto the
returned snapshot when the entry already carried one, so entries without an
explicit expiry round-trip unchanged for callers/tests.
---------
Co-authored-by: James <james@eli.cx>
* fix: align app router navigation parity
* fix(app-router): preserve canonical RSC and hard-nav semantics
App Router RSC requests now keep the route pathname shape when adding _rsc. Stripping a trailing slash made trailingSlash routes fetch a non-canonical URL and let the server redirect the client-side RSC request back to the slash form.
The MPA navigation scheduler also keys pending hard navigations by both href and history mode so a later replace does not reuse an earlier push closure. Redirect handling keeps the global browser bridge out of passive server rendering while retaining client-side redirect recovery.
Adds focused regression coverage for trailingSlash RSC requests, same-href push-to-replace hard navigation, and client redirect guard loops. Updates existing navigation observers to the canonical route URL plus _rsc transport.
* fix(app-router): stabilize action and prefetch parity
* fix(app-router): reset MPA dedupe after bfcache restore
A bfcache-restored App Router document can keep the scheduler's last pending hard-navigation marker after the external navigation already left the page. Reissuing the same href and history mode then entered the suspended MPA path without scheduling another location change.
Expose a scheduler reset for the browser lifecycle boundary and call it on persisted pageshow events. Cover the scheduler contract so the same external navigation can run again after reset.
* fix(app-router): isolate redirect bridge from passive boundaries
Unhandled client redirect recovery currently lives inside every RedirectBoundary, while Pages Router params fallback can keep stale App Router params after an App-to-Pages transition. That makes passive boundaries own browser runtime wiring and lets stale app params win over live Pages Router params.
Move the unhandled redirect bridge into BrowserRoot under AppRouterContext, keep RedirectBoundary passive, and prefer live Pages Router params when that compat context exists. Add an App-to-Pages navigation regression fixture.
* fix: extract shared decodeRedirectError helper, rename stale test
- Add decodeRedirectError() to shims/navigation.ts as the single
source of truth for parsing redirect error digests
- Replace three private helpers in error-boundary.tsx with shared
decodeRedirectError() call
- Replace inline digest parsing in AppRouterRedirectBridge with
shared decodeRedirectError() call (fixes unsafe decodeURIComponent)
- Rename test that claimed to test redirect() but actually tests
<Link> navigation
* refactor: eliminate unnecessary type assertions
- isRedirectError: use in-narrowing instead of as-casts
- AppRouterRedirectBridge: remove as after guard already narrows
- RedirectErrorBoundary: inline digest access, use in for handled check
* ci: retrigger checks
* fix: emit noindex for Forbidden/Unauthorized boundaries, extract retryScrollTo helper
* docs: clarify decodeRedirectError comment about defensive join
Adds regression coverage mirroring Next.js's
`test/e2e/new-link-behavior/index.test.ts` so the Pages Router `<Link>`
shim is guarded against ever regressing on:
1. `onClick` firing on click with the synthetic event
2. `event.preventDefault()` inside `onClick` cancelling the Link's
own client-side navigation (no router.push, no history mutation,
no popstate dispatch)
The unit-level invariants in the current Link shim match Next.js's
`linkClicked` ordering — call user onClick, check defaultPrevented,
only navigate if not prevented — so these tests pass against the
current implementation and lock that behaviour in place.
Fixes#1576. PR #1575 hoisted the dangerous-scheme guard out of the async
`performNavigation` body for Pages Router `router.push`/`router.replace`, which
covers two of the six Next.js `test/e2e/app-dir/javascript-urls/javascript-urls.test.ts`
scenarios. This adds regression coverage for the remaining four Link-flavoured
scenarios — App Router and Pages Router, both `href` and `as` — by toggling
`appNavigation` in `renderIsolatedLink` and swapping `href` <-> `as`. Each test
clicks the rendered anchor and asserts:
- `console.error` is emitted with a message containing "has blocked a
javascript: URL as a security precaution." (the substring the Next.js E2E
matcher asserts on).
- `navigate` / `fetch` are never invoked, so navigation does not proceed.
- A user-provided `onClick` still fires, so analytics or local UI state is
preserved.
The Link shim's existing `handleDangerousClick` path (`shims/link.tsx`) and the
`assertSafeNavigationUrl` guard inside the app router push/replace already
emit the matching message; this PR locks that contract behind a test matrix
so a future Link refactor cannot regress it silently.
The two `router.push` / `router.replace` scenarios from the issue are already
covered by `tests/router-javascript-urls.test.ts:96-128` (App Router) and
`tests/router-javascript-urls.test.ts:180-302` (Pages Router, from #1575).
Ported from Next.js: test/e2e/app-dir/javascript-urls/javascript-urls.test.ts
* fix(app-router): complete default autoscroll page target parity
App Router page navigations can commit loading shells, final payloads, and superseded payloads out of order. A pending scroll intent that is only keyed by navigation start can be consumed by the wrong committed tree, which makes default page target scrolling and focus timing diverge from Next.js when loading boundaries or rapid navigations are involved.
Bind scroll intents to approved render commits, consume only the matching intent from the committed scroll target, and clear hard-navigation failures only for their own intent. Extend the Next.js compat fixture coverage for skipped first children, loading boundary timing, same-page search param changes, focus behavior, horizontal scroll preservation, and stale intent races.
* test(app-router): update link navigation scroll intent assertion
* fix(app-router): address review — consume scroll intent on no-commit, tighten test
- Consume scroll intent before returning no-commit in renderNavigationPayload,
preventing the navigateClientSide fallback from scrolling for an abandoned
navigation.
- Replace waitForTimeout(700) with expect.poll for robust race-condition
stability assertion (CI-flake-safe, stronger proof of stability).
- Update wrapperless targeting comment to clarify outer function / inner
class split.
- Add three unit tests covering no-commit cleanup, stale intent rejection,
and claim-without-commitId consumption paths.
* fix(app-router): render optimistic loading shells for dynamic navigation
Dynamic App Router links currently skip automatic prefetch for route patterns with params, so a navigation to a sibling dynamic URL waits for fresh RSC data before committing anything. That diverges from Next.js, where automatic prefetch learns the loading shell for dynamic routes and the router can commit the shell immediately while the full payload is fetched.
The client prefetch path now requests a loading-shell render for dynamic app routes, keeps that entry out of the navigation cache, learns reusable optimistic route templates from settled shell prefetches, and commits a detached optimistic payload for matching dynamic navigations. The server emits shell-only RSC payloads under a separate cache variant so normal navigation data is not polluted.
Tests cover matching semantics, shell-only page wiring, cache key isolation, prefetch cache consumption, Link prefetch behavior, and the upstream Next.js optimistic-routing deploy-suite case.
* chore(app-router): address optimistic routing review
Match Next.js's observable behaviour when `<Link>` clicks, `router.push`,
`router.replace`, and `router.prefetch` block a `javascript:` (or `data:`/
`vbscript:`) URL. Vinext already prevented navigation, but the
`console.error` that Next.js's E2E suite asserts on
(`test/e2e/app-dir/javascript-urls`) was missing because:
- App Router `router.push`/`replace`/`prefetch` threw synchronously
inside `React.startTransition`, so the throw never reached React's
event-handler error reporter.
- `<Link>` rendered an inert anchor with no `href`, so clicking did
nothing — no throw, no log.
- Pages Router `next/router` had no `javascript:` guard at all and
would fall through to `window.location.assign(...)`.
Centralise the matching message in `url-safety.ts` via a new
`reportBlockedDangerousNavigation()` helper. `assertSafeNavigationUrl`
now logs before throwing, and Link's dangerous-click branch and the
Pages Router `performNavigation` both invoke the guard so the throw +
console.error pair mirrors Next.js exactly.
Closes#1349
References:
- packages/next/src/client/components/app-router-instance.ts:345,402,442,460
- packages/next/src/client/components/segment-cache/navigation.ts:537
- packages/next/src/shared/lib/router/router.ts:1025,1057
vinext already exposes `<Link onNavigate>` and the matching `NavigateEvent`
contract (`{ url, preventDefault(), defaultPrevented }`), but the Next.js
deploy suite still fails the `replace history state for external links
with replace prop` case: when `<Link>` points at a truly external URL and
has `replace` set, vinext falls through to the browser's default click
navigation, which pushes a new entry instead of replacing the current one.
This mirrors `linkClicked` in
`.nextjs-ref/packages/next/src/client/link.tsx` for the external + replace
branch: call `e.preventDefault()` and `window.location.replace(href)` so
the entry is replaced rather than pushed.
Adds focused unit tests around the onNavigate contract: onClick + onNavigate
fire for internal clicks, `preventDefault()` cancels the navigation, and
onNavigate is skipped for modifier-key clicks, target=_blank, download
links, and external URLs (with and without `replace`).
Closes#1357
* refactor(router): quarantine navigation runtime topology
* fix(router): restore manifest-backed interception runtime
App Router browser navigations lost first-hop intercepted route context after snapshot topology stopped acting as fallback proof. That made /feed to /photos/:id request the direct page payload instead of the declared modal payload.
The violated invariant was that route topology must come from the manifest, while snapshots only carry runtime proof. Resolve first-hop interception from the manifest, move RSC bootstrap streams onto the symbol-backed runtime, teach prerender extraction that script shape, and harden the runtime contract around route manifest and RSC data.
Coverage includes runtime seam tests, prerender extraction tests, planner/browser navigation tests, package rebuild, and the app-router intercepted routes E2E slice.
* fix(router): narrow prerender RSC script parsing
The prerender extractor accepted any script starting with the symbol-runtime bootstrap expression, then parsed it as though it had the exact RSC chunk-push prefix. That was harmless at the current head, but it made future runtime scripts a foot-gun.
Only the exact runtime chunk prefix and exact done marker are now accepted. A focused extractor test covers non-chunk runtime scripts that share the bootstrap expression prefix.
* fix(router): address navigation runtime review
* fix(router): resolve navigation runtime review threads
* fix(link): preserve native URI scheme navigation
Link treated only http, https, and protocol-relative hrefs as browser-owned. Native schemes such as mailto:, tel:, and sms: were therefore locale-prefixed, eligible for prefetch normalization, and intercepted by client navigation.
The URL boundary now mirrors Next.js absolute URL scheme classification while preserving vinext's protocol-relative handling. Link rendering, click interception, relative resolution, and prefetch normalization all share that classification so non-local schemes stay with the browser while same-origin absolute URLs remain routable.
Adds focused regression coverage for native scheme rendering, click handling, and prefetch decisions.
* refactor(router): reuse shared URL scheme detection
Router and navigation shims already had local external URL predicates with the same browser-owned URL semantics now used by Link.
Reuse the shared url-utils predicate for Pages Router locale handling, Pages Router external detection, App Router external detection, and App Router prefetch normalization. Add focused shim coverage for native URI schemes through the Pages Router helpers.
* test(link): add hasAttribute stub to native URI scheme click event
The merge of main introduced a download-attribute check in handleClick that
runs before the native-URI early return, so test click events must provide
currentTarget.hasAttribute.
---------
Co-authored-by: James <james@eli.cx>
Link currently treats anchors with the HTML download attribute like normal same-origin SPA navigations. That prevents the browser from performing its native download behavior and can also run onNavigate for a click that should never become client-side navigation.
The click handler now bails out after user onClick and before preventDefault whenever the anchor has a download attribute, matching Next.js Link semantics. A focused regression test covers the default action, onNavigate, transition, and RSC navigate boundaries.
* fix(link): reschedule visible prefetches
Visible App Router links were prefetched as one-shot observer callbacks. That lost the mounted visibility state needed to re-prefetch after cache invalidation or router state changes, and automatic dynamic links could not be upgraded to a full prefetch on hover.
The Link shim now keeps mounted prefetch instances, tracks the visible set, exposes an internal ping hook for cache and router commits, and persists the full-prefetch strategy after unstable_dynamicOnHover intent.
Regression coverage verifies visible-link cache invalidation and dynamic-on-hover strategy upshift.
* fix(link): preserve pages intent prefetch
Pages Router Link prefetch=false should only disable viewport prefetch. The shared gate also blocked hover and touch intent prefetches, which matches App Router but diverges from the Pages Router contract.
Split intent eligibility by router mode so App Router keeps the stricter gate while Pages Router intent still prefetches in production. Pages viewport prefetch remains one-shot per mounted link to avoid duplicate document prefetch nodes under the visible-link registry.
Regression coverage verifies App Router prefetch=false intent suppression, Pages Router hover and touch intent prefetch, and Pages viewport re-entry dedupe.
The "clicking an RSC Link starts app-router navigation inside a React
transition" test ran link.js's dynamic import and vi.importActual("react")
in parallel via Promise.all. When importActual won the race, "react" got
cached as the actual module before the doMock factory ran, so link.tsx's
transitive `import React from "react"` resolved to the unmocked module.
That made React.startTransition the real implementation rather than the
spy, causing toHaveBeenCalledTimes(1) to occasionally see 0.
Sequencing the imports — load link.js first (with the doMock factory in
effect), then grab React via importActual — eliminates the race.
Refs: https://github.com/cloudflare/vinext/actions/runs/25878626751/job/76052351860
* fix(link): keep auto prefetch from fetching dynamic app routes
App Router Link treated null, undefined, and auto prefetch as the same full RSC prefetch used by prefetch={true}. That violates Next.js's public prefetch contract and over-fetches dynamic route payloads as soon as links enter the viewport.
The broken assumption was that prefetch only needed a boolean enabled state. Split Link prefetch into disabled, auto, and full modes, emit a small browser route manifest, and allow automatic full RSC prefetch only for matched static app routes while preserving explicit full prefetch.
Add focused tests for the mode mapping and generated route manifest.
* test(link): opt interception prefetch fixture into full mode
The interception-context E2E asserts that full RSC prefetch cache keys stay separate by source context. After auto prefetch stops full-fetching dynamic routes, that fixture needs to request the full prefetch mode explicitly.
* fix(link): align app-router prefetch scheduling with intent
Link currently schedules viewport RSC prefetches outside production and does not promote user mouse or touch intent into an immediate prefetch. That diverges from Next.js Link behavior and can waste dev-server work during rapid iteration while missing an important production navigation warmup path.
The shim now gates automatic prefetching to production, shares same-origin prefetch normalization, and schedules high-priority RSC prefetches from mouse and touch intent while preserving user handlers. Focused Link navigation tests cover development suppression, intent prefetch, prefetch=false, and dangerous-link handler passthrough.
* fix(link): align app-router prefetch scheduling with intent
Link currently schedules viewport RSC prefetches outside production and does not promote user mouse or touch intent into an immediate prefetch. That diverges from Next.js Link behavior and can waste dev-server work during rapid iteration while missing an important production navigation warmup path.
The shim now gates automatic prefetching to production, shares same-origin prefetch normalization, and schedules high-priority RSC prefetches from mouse and touch intent while preserving user handlers. Focused Link navigation tests cover development suppression, intent prefetch, prefetch=false, and dangerous-link handler passthrough.
* test: tighten link prefetch coverage
* test(link): tighten prefetch decision coverage
* test(link): address prefetch review notes
* fix(app-router): schedule link navigations as transitions
App Router link clicks could commit cross-route payloads synchronously because the browser entry passed route path equality into the navigation controller as a transition toggle. That diverged from Next.js, where app-router push and replace enter React.startTransition for every app navigation.
Remove the transition override from normal navigation payload commits, keep synchronous commits private for HMR and same-URL server-action handling, and start RSC Link navigations through the same programmatic transition path used by useRouter().
* test(app-router): cover link transition scheduling behavior
* docs(navigation): drop stale isSameRoute references in comments
The isSameRoute concept was removed from app-browser-entry.ts in this PR;
update two comments in navigation.ts that still referenced it.
---------
Co-authored-by: James <james@eli.cx>