Skip to content

fix(link): preserve native URI scheme navigation#1268

Open
NathanDrake2406 wants to merge 2 commits into
cloudflare:mainfrom
NathanDrake2406:nathan/link-native-schemes
Open

fix(link): preserve native URI scheme navigation#1268
NathanDrake2406 wants to merge 2 commits into
cloudflare:mainfrom
NathanDrake2406:nathan/link-native-schemes

Conversation

@NathanDrake2406
Copy link
Copy Markdown
Contributor

@NathanDrake2406 NathanDrake2406 commented May 16, 2026

Overview

Field Detail
Goal Keep browser-owned URI schemes out of vinext client routing.
Core change Share absolute/protocol-relative URL classification across Link, Pages Router, App Router navigation, and prefetch normalization.
Primary files packages/vinext/src/shims/url-utils.ts, packages/vinext/src/shims/link.tsx, packages/vinext/src/shims/link-prefetch.ts, packages/vinext/src/shims/router.ts, packages/vinext/src/shims/navigation.ts
Expected impact <Link href="mailto:...">, tel:, sms:, and other non-local schemes preserve native browser behavior while same-origin absolute URLs still use client navigation.

Why

next/link should only intercept hrefs the framework router can actually route. Next.js treats scheme-bearing URLs as absolute, then uses local URL detection to decide whether the router owns them. Native schemes such as mailto:, tel:, and sms: are not local app routes, so preventing the browser default turns a valid native action into a broken client navigation.

Area Principle / invariant What this PR changes
Link click handling Browser-owned hrefs must not call preventDefault() or RSC navigation. Native URI schemes now return before client navigation, matching external URL handling.
Link and router locale handling Locale prefixes apply to local paths only. Explicit locale handling no longer turns mailto:hello@example.com into /fr/mailto:hello@example.com.
Prefetch Only local or same-origin app URLs are prefetchable. Native schemes now normalize to null, so intent/viewport prefetch cannot fetch a mangled local target.
Shared shims Browser-owned URL classification should not drift across Link, router, and navigation. Router and navigation now reuse the shared url-utils predicate instead of local regex copies.

What Changed

Scenario Before After
<Link href="mailto:hello@example.com"> click Prevented default and attempted app-router navigation. Browser handles the native mail protocol.
locale="fr" with native scheme Rendered a locale-prefixed path. Renders the original native URI.
Production prefetch for native scheme Treated the value like a relative href. Treats it as non-prefetchable.
Pages Router external URL helpers Had a separate regex copy. Reuses the shared URL utility predicate.
App Router navigation helpers Had a private regex copy and http-only prefetch classification. Reuses the shared URL utility predicate.
Same-origin absolute URL Client-routed. Still client-routed.
Maintainer review path
  1. packages/vinext/src/shims/url-utils.ts: review the shared absolute URL predicate and its use in basePath/relative resolution.
  2. packages/vinext/src/shims/link.tsx: review the locale and click-interception call sites that now use the shared predicate.
  3. packages/vinext/src/shims/link-prefetch.ts: review native scheme exclusion from prefetch normalization.
  4. packages/vinext/src/shims/router.ts and packages/vinext/src/shims/navigation.ts: review reuse of the shared predicate in router/navigation helpers.
  5. tests/link.test.ts, tests/link-navigation.test.ts, and tests/shims.test.ts: review regression coverage for render output, click interception, prefetch behavior, and router helper behavior.
Validation
  • vp test run tests/link.test.ts tests/link-navigation.test.ts tests/shims.test.ts passed: 988 tests.
  • Earlier mutation-style red check: temporarily reverting only the original source change while keeping the new tests produced failures for native scheme locale rendering, prefetch normalization, and click interception.
  • vp check passed: formatting, lint, and type checks clean.
  • Commit hooks also ran vp check --fix and knip successfully.
Risk / compatibility
  • Public API: no API shape change.
  • Runtime: narrows Link interception to URLs the app can route. Same-origin absolute URLs and protocol-relative same-origin URLs keep existing client-navigation behavior.
  • Compatibility: aligns with Next.js URL scheme classification and local URL routing boundaries.
  • Security: dangerous schemes remain blocked by the existing isDangerousScheme path before anchor rendering.
Non-goals
  • Does not change config/server rewrite URL classification in config-matchers.ts.
  • Does not change image remote URL detection or metadata URL composition semantics.
  • Does not add support for legacy as validation parity beyond preserving the current as ?? href behavior.

References

Reference Why it matters
Next.js isAbsoluteUrl Defines scheme-bearing URLs, including native protocols, as absolute.
Next.js isLocalURL Router ownership is limited to relative URLs or same-origin URLs inside the base path.
Next.js App Router Link click handling Non-local hrefs return without client navigation.
Next.js Pages Router Link click handling Pages Router uses the same local URL boundary.
Next.js invalid-href mailto tests Confirms mailto: is valid Link href output and should not report an error.
Next.js parse-relative-url native scheme tests Confirms mailto:, tel:, and sms: are not valid relative router URLs.

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.
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 16, 2026

Open in StackBlitz

npm i https://pkg.pr.new/vinext@1268

commit: 8e0c59c

@NathanDrake2406 NathanDrake2406 marked this pull request as ready for review May 16, 2026 13:06
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.
@NathanDrake2406 NathanDrake2406 marked this pull request as draft May 16, 2026 14:09
@NathanDrake2406 NathanDrake2406 marked this pull request as ready for review May 16, 2026 14:36
@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clean, well-scoped fix. The shared predicate correctly mirrors Next.js's isAbsoluteUrl (same regex, same charCode fast-path), and the consolidation across Link, router, navigation, and prefetch eliminates the classification drift that caused the bug.

I verified:

  • Regex parity: ABSOLUTE_URL_REGEX and the isAbsoluteUrl implementation match Next.js's packages/next/src/shared/lib/utils.ts exactly.
  • resolveRelativeHref fix: This is where the core bug lived — the old code fell through to new URL('mailto:...', base) which stripped the scheme and returned just hello@example.com as a relative path. The new early-return correctly preserves the full URI.
  • withBasePath equivalence: The !path.startsWith("/") guard already excluded mailto:, tel:, etc., so the refactoring is semantically equivalent. The isAbsoluteOrProtocolRelativeUrl check only matters for // protocol-relative URLs (which do start with /).
  • Click handler flow: isAbsoluteOrProtocolRelativeUrl → toSameOriginAppPath → null correctly returns before preventDefault(), letting the browser handle native schemes.
  • Prefetch flow: new URL('mailto:...') parses with origin: null, which fails the same-origin check, so getLinkPrefetchHref returns null as expected.
  • Test coverage: Tests cover render output, click interception, prefetch normalization, locale handling, and router helper behavior across all three native schemes.

One minor observation below, but it doesn't block merge.

path.startsWith("https://") ||
path.startsWith("//")
) {
if (!basePath || !path.startsWith("/") || isAbsoluteOrProtocolRelativeUrl(path)) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: since path.startsWith("/") is checked first, the isAbsoluteOrProtocolRelativeUrl(path) call can only ever match protocol-relative // paths — isAbsoluteUrl requires the first character to be a letter, which conflicts with /. So this is correct and equivalent to the old inline check, but the reader has to reason through two guards to see it. Might be worth a one-line comment like // only protocol-relative "//" can reach here; absolute schemes don't start with "/" to save the next person the mental detour. Not blocking.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 16, 2026

Review submitted. Here's a summary:

Approved the PR with one minor nit.

The fix is correct and well-structured. The core bug was in resolveRelativeHref where new URL('mailto:...', base) would strip the scheme and return just hello@example.com as a relative path. The shared isAbsoluteUrl predicate (which mirrors Next.js's implementation exactly — same regex, same charCode fast-path) now catches native URI schemes early across all call sites: Link click handling, locale prefixing, prefetch normalization, and router/navigation helpers.

The only comment was a readability nit on withBasePath where the isAbsoluteOrProtocolRelativeUrl(path) check can only ever match // protocol-relative paths (since !path.startsWith("/") is checked first and absolute schemes start with letters, not /). A brief comment would save future readers the mental detour.

github run

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants