* fix(app-router): align router autoscroll with Next
App Router navigation could diverge from Next.js router autoscroll semantics around hydration timing, loading-shell same-page search navigations, and routes whose first committed node is React-hoisted into head. That broke the upstream router-autoscroll deploy-suite because the client marked hydration too early, static loading-shell templates were not usable for navigation, and the fallback scroll path masked the old-handler hoisted-head behavior.
The fix moves the Next-compatible hydration marker into a passive effect, wires production hydrateRoot error callbacks to match Next's implicit root-boundary and recoverable-error handling, lets authoritative loading-shell templates drive static route optimistic payloads, and skips fallback document scrolling when the committed document only exposes a React-hoisted head resource. The ported tests cover the upstream router-autoscroll behavior plus focused unit boundaries for prefetch mode, optimistic routing, head-resource detection, and hydrateRoot callbacks.
* fix(app-router): hoisted-head scroll fallback is per-intent, not global head scan
* test(app-router): add full-chain scroll intent integration test
* fix(app-router): match legacy scroll focus parity
* fix(app-router): preserve latest scroll navigation intent
* fix(app-router): scope scroll and error recovery
* fix(app-router): gate navigation failure recovery
* fix(app-router): match navigation failure handling
* fix(app-router): align scroll completion parity
* fix(app-router): retain latest failure target
* fix(app-router): preserve route error boundaries
* fix(app-router): clear discarded failure targets
* fix(app-router): align failure recovery boundaries
* fix(app-router): disarm refused recovery targets
* fix(app-router): preserve failure target ownership
* fix(app-router): match boundary recovery parity
---------
Co-authored-by: James <james@eli.cx>
A <Link href={{ query: {...} }} /> with no `pathname` was resolved by
defaulting the base to "/", producing "/?query" instead of a query-only
href. The shallow navigation then recorded the wrong history entry (the
site root) so back/forward traversal landed on the wrong URL/page.
Mirror Next.js's formatUrl() (`pathname = urlObj.pathname || ''`): when
`pathname` is omitted, emit a relative query-only href (`?query`) so the
existing relative-href resolution applies it against the current path.
Adds a ported e2e regression (middleware-rewritten shallow link history
traversal) plus a Link unit test.
Closes#1540
* 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(i18n): make Pages Router locale sticky across client navigations
Refs #1336 (item 2).
Writes a Next.js-compatible history state shape on every router
push/replace, captures the active locale in `state.options.locale`, and
teaches the popstate handler to:
1. Ignore history entries written by third-party code (no `__N: true`).
2. Drop the first popstate when it carries the same locale + `as` as the
current page (Safari tab-restore / BFCache replay parity).
3. Honour the locale recorded in history state when re-fetching the page
on back/forward, so default-locale roots still go through their
locale-qualified HTML endpoint.
This matches the Next.js behaviour exercised by
`test/e2e/ignore-invalid-popstateevent` and the client half of
`test/e2e/i18n-preferred-locale-detection`.
Scope boundary: the server-side `Set-Cookie: NEXT_LOCALE=...` on initial
Accept-Language detection is intentionally NOT modified here. The
existing `parseCookieLocaleFromHeader` already gives the cookie priority
over Accept-Language, and changing where the cookie is written would
overlap with #1336 item 4 (default-locale prefix normalisation).
* fix(i18n): preserve back/forward navigation under new history-state shape
The previous commit attached `__N: true` to router-owned history entries,
but two regressions slipped in:
1. `saveScrollPosition` merges scroll fields onto the *current* entry
before a push. When the current entry was the initial document load
(`state === null`), the merge produced a state object without `__N`,
which the popstate foreign-state filter then dropped — breaking
browser back/forward.
Fix: when the existing state is null, mint a router-shaped state
(`{ __N: true, url, as, options, key }`) before merging the scroll
fields, so every entry the router touches carries `__N`.
2. The Safari-replay filter compared `state.as` against `window.location`,
but `window.location` has already changed by the time popstate fires
on a real back/forward. That made every genuine back navigation look
like a replay and get ignored.
Fix: compare against `_lastPathnameAndSearch` (the URL the router
last actively navigated to). After a real back, that tracker still
points at the entry we were on, so a genuine navigation differs and
passes the filter — matching Next.js's `state.as === this.asPath`
semantics.
Verified locally:
- tests/pages-router-i18n-sticky-locale.test.ts: 7/7 pass
- tests/shims.test.ts: 944/944 pass
- tests/e2e/pages-router/{navigation,before-pop-state,hydration,router-events}.spec.ts:
28/28 pass (these were the CI failures from the previous commit).
* fix(i18n): stamp initial history entry with active locale on install
Closes the remaining gap in the popstate locale-recovery path: the
initial document entry has `state: null` until the first push, so a
back-navigation to it would fall back to the live `window.__VINEXT_LOCALE__`
— which an intervening locale-changing push may have flipped. Stamping
router-shaped state at install captures the SSR-resolved locale into
`options.locale` and lets the popstate handler restore the correct locale.
Also:
- Strip the hash from `state.as` so the Safari-replay filter's
`withBasePath(state.as) === _lastPathnameAndSearch` comparison works for
entries created from a hash-bearing URL (the tracker never carries a hash).
- Drop the unnecessary `withBasePath as withBasePathPrefix` import alias.
Adds 3 tests: install-time stamping, no-clobber of pre-existing state, and
the back-to-initial-entry locale-recovery regression.
* fix(i18n): prefer active locale over default + scope Safari-replay filter
Three small follow-ups discovered by CI on top of the i18n-sticky-locale
work:
1. `getCurrentBrowserLocale` was falling back to `__VINEXT_DEFAULT_LOCALE__`
before `__VINEXT_LOCALE__` when no path prefix was present, which threw
away the active locale on default-locale paths served under a
non-default locale. Flip the fallback order so the active locale wins —
exactly what the new `pages-router-i18n-sticky-locale.test.ts` 'Router
.push without an explicit locale' case asserts.
2. The Safari-replay filter on the first popstate event was running even
after the user had already navigated. After a hash-only `Router.push`,
the entry's `state.as` is recorded without a hash, which happens to
match `_lastPathnameAndSearch` (also without a hash); the filter then
incorrectly dropped a genuine `goBack()` popstate, freezing the E2E
router-events suite. Gate the filter behind a new `_routerDidNavigate`
flag so it only fires during the synthetic initial-load replay window.
3. Update the 6 `expect(pushState).toHaveBeenCalledWith({}, ...)`
assertions in `tests/shims.test.ts` to use
`expect.objectContaining({ __N: true })`, since pushState now carries a
Next.js-compatible state object instead of `{}`.
* test(i18n): align unprefixed-path locale tests with sticky-locale behavior
The previous "treat unprefixed path as default locale" assertions in
tests/link.test.ts and tests/shims.test.ts contradicted the intentional
sticky-locale flip in ab662c07 (getCurrentBrowserLocale now prefers
__VINEXT_LOCALE__ over __VINEXT_DEFAULT_LOCALE__). Update both tests to
assert the new behavior: a default-locale path served under a non-default
locale keeps its active locale for client-side root navigations.
* fix(link): forward href/onClick to child in legacyBehavior (#1469)
When `<Link legacyBehavior>` is set, the shim previously rendered its own
wrapping `<a>` around the user's child anchor, producing nested anchors and
duplicated content (e.g. a hidden `<a id="custom-button"></a>` alongside the
user's anchor) and breaking onClick propagation.
Mirror Next.js: when `legacyBehavior` is true, forward `href`/`onClick`/
`onMouseEnter`/`onTouchStart` to the single child via `React.cloneElement`
instead of wrapping. `href` is only forwarded when `passHref` is set or the
child is an `<a>` without its own `href`. String/number children are wrapped
in a plain `<a>` first so they have an element to clone, matching Next.js.
Ported from .nextjs-ref/packages/next/src/client/link.tsx (legacyBehavior
branch) and covered by .nextjs-ref/test/e2e/legacy-link-behavior-pages.
Fixes#1469
* fix(link): address bigbonk legacyBehavior parity gaps (#1469)
- Skip <Link>'s own onClick prop when invoked via legacyBehavior. The
child's onClick is the canonical handler; calling Link's onClick too
caused a double-fire. Added a dev console.warn matching Next.js when
onClick/onMouseEnter are passed to <Link legacyBehavior>.
- Use the child element's own ref in legacyBehavior (matches Next.js)
instead of routing through Link's forwardedRef. Merged with the
internal intersection-observer ref so prefetching still works.
- Use `'href' in child.props` (matches Next.js) instead of `!== undefined`
so an explicit `href={undefined}` on the child <a> is treated as the
child owning its href and is not overwritten.
- Clone the child in the dangerous-scheme branch when legacyBehavior is
set, so blocked javascript:/data:/vbscript: URLs do not produce nested
anchors. The dangerous href is intentionally not forwarded to the child.
Added 3 unit tests covering the new behaviors.
* fix(link): warn on repeated forward slashes in href
Closes#1554
When a `<Link href>` contains repeated forward-slashes (e.g. `/foo//bar`)
or a backslash, Next.js's `resolveHref` emits a `console.error` so the
developer notices the invalid URL during dev/CI. vinext's Link shim did
not surface that warning, which caused the Next.js Deploy Suite test
`test/e2e/repeated-forward-slashes-error` to fail waiting for the
message in the CLI output.
Add a small `warnOnRepeatedSlashesInHref` helper that mirrors the regex
and message used by Next.js, and call it from the Link body after the
href is resolved. The href continues to render and navigate unchanged —
Next.js does not block, it only warns.
Ported from Next.js: packages/next/src/client/resolve-href.ts
* fix(link): normalize repeated slashes and drop dedup for Next.js parity
Address review feedback on #1563:
- Remove the module-level `warnedRepeatedSlashHrefs` Set. Next.js fires
this warning on every `resolveHref` call, so dedup was an unintended
divergence and a latent test-ordering hazard.
- Add `normalizeRepeatedSlashes` (port of
packages/next/src/shared/lib/utils/normalize-repeated-slashes.ts) and
apply it after warning, so `<Link href="/a//b">` renders `href="/a/b"`
exactly as Next.js does.
- Preserve protocol-relative URLs (`//example.com/...`) unchanged —
vinext treats those as external via `isAbsoluteOrProtocolRelativeUrl`,
so we skip both the warning and the normalization for them to avoid
regressing the existing "locale does not mangle protocol-relative
URLs" test.
- Document the `window.location.pathname` fallback (Next.js uses
`router.pathname`; the difference is cosmetic and not asserted by the
compat test).
- Add tests covering no-dedup behaviour, href normalization (including
backslashes, query-string preservation, and protocol-prefixed URLs).
* fix(router): preserve default locale for unprefixed root links
Unprefixed Pages Router URLs represent the default locale, even when the browser's preferred locale has previously been detected as a non-default locale. Treating the ambient runtime locale as authoritative makes a Link from /new to / request /id and returns the wrong page locale.
Derive implicit client navigation locale from the current URL first, then domain/default locale state, and render a locale-qualified default root fallback href so non-intercepted root links do not re-enter preferred-locale detection. The regression tests port the relevant Next.js i18n preferred-locale cases at the Link and router boundaries.
* refactor(router): share client locale inference
The review called out that Link and Router had identical browser locale fallback logic. Keeping that chain duplicated makes future i18n changes easy to apply in one place but miss in the other.
Move the URL-first browser locale lookup into a shared shim helper and add SSR coverage for the intentional default-locale root fallback href.
* 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
Closes the last sub-problem of #1332.
The Pages Router `Router.push` / `Router.replace` already short-circuit the
`_next/data` fetch when `options.shallow === true`, but `<Link shallow>` never
made it to the router because `shallow` was missing from `LinkProps`. The prop
silently fell into `...rest`, was spread onto the underlying `<a>`, and the
click handler called `Router.push(href, undefined, { scroll, locale })` with
no shallow flag — so every `<Link shallow>` did a full server roundtrip and
GSSP re-ran.
This matches Next.js's deploy-suite fixture
`test/e2e/middleware-trailing-slash/app/pages/shallow.js`, which clicks a
`<Link href="/sha?hello=world" shallow>` and asserts the GSSP-generated
random message stays unchanged afterwards.
The fix is mechanical: add `shallow?: boolean` to `LinkProps`, destructure
it in the Link component, forward it through `navigatePagesRouterLink` into
the router transition options. `shallow` is only included in the router
options when explicitly set so existing callers that omit it keep the same
options shape.
* fix(pages-router): preserve i18n client root navigation
Pages Router client navigation fetched the unprefixed root URL even when the transition resolved to the default locale. That let root Accept-Language detection redirect a link click from /new to the preferred locale path, diverging from Next.js.
Use the active locale for implicit Link and Router transitions, fetch default-locale root HTML through a locale-qualified internal URL while preserving the visible unprefixed URL, and parse i18n __NEXT_DATA__ scripts that append locale globals.
* fix(pages-router): preserve i18n client root navigation
Pages Router client navigation fetched the unprefixed root URL even when the transition resolved to the default locale. That let root Accept-Language detection redirect a link click from /new to the preferred locale path, diverging from Next.js.
Use the active locale for implicit Link and Router transitions, fetch default-locale root HTML through a locale-qualified internal URL while preserving the visible unprefixed URL, and parse i18n __NEXT_DATA__ scripts that append locale globals.
* fix(pages-router): validate next data parsing
* fix(pages-router): preserve link locale transitions
* fix(pages-router): preserve popstate locale fetch
* chore(pages-router): address locale review nits
* 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>
* fix(link): normalise href for trailingSlash config
Port `normalizePathTrailingSlash` from Next.js (client/normalize-trailing-slash.ts)
into a small util under `shims/url-utils.ts`, wire the resolved `trailingSlash`
config through a new `process.env.__VINEXT_TRAILING_SLASH` build-time define,
and apply it inside `<Link>` so the rendered `href` matches the canonical URL
form instead of relying on a 308 redirect bounce.
This unblocks the upstream `e2e/trailing-slashes/*` suite, which asserts on
rendered `<Link>` hrefs (not just redirect Location headers).
* fix(link): normalise href again after applying basePath
Matches Next.js's `addBasePath`, which runs `normalizePathTrailingSlash`
after prefixing. Without this second pass, joining a non-empty basePath
with the bare root (`/`) under `trailingSlash: false` would render
`/foo/` instead of the canonical `/foo`.
* Update packages/vinext/src/shims/url-utils.ts
Co-authored-by: ask-bonk[bot] <249159057+ask-bonk[bot]@users.noreply.github.com>
---------
Co-authored-by: ask-bonk[bot] <249159057+ask-bonk[bot]@users.noreply.github.com>
* 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.
* chore: migrate to vite plus
* Disable typeAware and typeCheck
* Update CI
* Fix CI
* Fix test
* Clean
* Run test with vp
* Try revert
* react: false In test
* Fix test
* Revert "Try revert"
This reverts commit 009da10473.
* Update
* Update
* Try revert ci changes
* revert
* Run vp migrate
* Disable typeAware and typeCheck for now
* Better resolve for test
* Use vp dev instead of vite
* Update expect
* Fix NormalizeManifestModuleId
* Try increase timeout
* Update to use vp
* Try new check
* Bring back npx vp
* Migrate CI
* Make next-intl resolvable
* Update
* Update
* Update
The i18n locale context (locale, defaultLocale, domainLocales,
hostname) was stored on bare globalThis properties, which are
shared across concurrent requests. Under concurrent traffic
(Cloudflare Workers or Node.js production server), request A's
locale could be overwritten by request B before A finishes
rendering, causing Link components to produce incorrect
locale-prefixed URLs.
Introduce ALS-backed i18n state following the existing pattern
used by router-state.ts and head-state.ts:
- i18n-context.ts: bridge module with fallback globalThis
accessors and a registration API for ALS-backed overrides
- i18n-state.ts: server-only module that registers ALS-backed
accessors on import, providing per-request isolation
link.tsx now reads locale context via getI18nContext() instead
of bare globalThis, dev-server.ts sets context via ssrLoadModule
for the SSR environment, and the production entry wraps
rendering in runWithI18nState().
* fix: skip locale prefix for absolute and protocol-relative Link hrefs
applyLocaleToHref() blindly prepended /${locale} to any href string,
producing malformed URLs like /fr/https://example.com/about or
/fr///example.com/about for absolute and protocol-relative hrefs.
Guard against http://, https://, and // URLs before applying the
locale prefix — these are external or same-origin absolute URLs that
should not be rewritten.
* fix: skip locale prefix for absolute and protocol-relative URLs in applyNavigationLocale
applyNavigationLocale() in router.ts (used by Router.push() / Router.replace())
had the same bug as applyLocaleToHref() in link.tsx — it would prepend /{locale}
to absolute and protocol-relative URLs, producing malformed hrefs like
/fr/https://example.com/about or /fr///cdn.example.com/img.png.
Add the same guard that was applied to applyLocaleToHref(), and add
corresponding tests covering https://, http://, and // URL patterns.
---------
Co-authored-by: James <james@eli.cx>
* add oxfmt formatter: config, scripts, CI, editor setup, docs
* rebuild lockfile
* fix: add Format to required checks list, remove dead ignore pattern
* run fmt
* add format to agents.md again
* fix: normalize same-origin absolute URLs for client-side navigation
When <Link href="https://example.com/path"> is used and example.com
matches the current origin, vinext now does SPA navigation instead
of a full page reload. This matches Next.js behavior.
Adds toSameOriginPath() utility that extracts the local path from a
same-origin absolute URL. Applied consistently across:
- link.tsx handleClick() and prefetch paths
- navigation.ts navigateImpl() (App Router)
- router.ts push()/replace() (Pages Router hook + singleton)
Closes#335
* fix: handle protocol-relative URLs in toSameOriginPath()
Use window.location.origin as base for `//` URLs so they resolve
correctly for same-origin detection, while keeping `new URL(url)`
(no base) for http/https URLs to preserve existing behavior.
Addresses Copilot review comment on PR #336.
* Update tests/link.test.ts
Co-authored-by: ask-bonk[bot] <249159057+ask-bonk[bot]@users.noreply.github.com>
---------
Co-authored-by: James Anderson <james@eli.cx>
Co-authored-by: ask-bonk[bot] <249159057+ask-bonk[bot]@users.noreply.github.com>