Skip to content

fix: Link onNavigate URL resolution for relative hrefs#483

Merged
james-elicx merged 4 commits intocloudflare:mainfrom
JaredStowell:jstowell/fix-link-onnavigate-relative-hrefs
Mar 12, 2026
Merged

fix: Link onNavigate URL resolution for relative hrefs#483
james-elicx merged 4 commits intocloudflare:mainfrom
JaredStowell:jstowell/fix-link-onnavigate-relative-hrefs

Conversation

@JaredStowell
Copy link
Copy Markdown
Contributor

Fix next/link onNavigate so its callback URL matches the actual navigation target for relative hrefs.

This aligns Link callback behavior with the browser/history destination for cases like ?page=2, #hash, and other relative targets, including when basePath is configured.

What changed

  • fix onNavigate.url in the next/link shim to use the same resolved target as navigation
  • centralize relative href + basePath browser URL resolution in shared URL helpers
  • fix same-origin absolute URL handling so existing basePath prefixes are not applied twice
  • fix rendered anchor href behavior under basePath for relative values
  • update next/navigation and next/router to use the same shared browser-target resolution path
  • keep fixture onNavigate typings aligned with Next.js and use local casts where vinext exposes event.url

Tests

Added and updated regression coverage for:

  • pure relative href resolution
  • basePath + locale-prefixed browser targets
  • same-origin absolute URLs that already include basePath
  • SSR anchor rendering under basePath
  • Pages Router Link onNavigate relative query navigation
  • App Router Link onNavigate relative query navigation

Verification

Ran targeted checks:

  • pnpm test tests/link.test.ts
  • PLAYWRIGHT_PROJECT=pages-router pnpm exec playwright test tests/e2e/pages-router/link-advanced.spec.ts -g "onNavigate reports the resolved URL for relative query hrefs"
  • PLAYWRIGHT_PROJECT=app-router pnpm exec playwright test tests/e2e/app-router/nextjs-compat/navigation.spec.ts -g "Link onNavigate reports the resolved URL for relative query hrefs"
  • pnpm run typecheck
  • pnpm run fmt

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented Mar 12, 2026

Open in StackBlitz

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

commit: c3341f5

@JaredStowell
Copy link
Copy Markdown
Contributor Author

JaredStowell commented Mar 12, 2026

CI failures are github being github

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 74994f9f18

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/vinext/src/shims/url-utils.ts Outdated
@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.

Good refactor. Centralizing URL resolution into url-utils.ts and fixing the onNavigate.url to match the actual navigation target for relative hrefs is the right approach. The test coverage is thorough — unit tests for each helper, SSR rendering tests under basePath, and E2E tests for both routers.

The Codex P1 about false basePath deduplication is a false positive — the new withBasePath does not deduplicate; it unconditionally prepends, and the test at line 448-452 explicitly validates the correct double-prefix behavior (/docs/getting-started/docs/docs/getting-started).

A few minor observations below, none blocking.

const resolved = resolveRelativeHref(href, currentUrl, basePath);

if (!basePath) {
return withBasePath(resolved, basePath);
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: when basePath is falsy, withBasePath(resolved, "") is a no-op (the first guard !basePath returns resolved unchanged). This works but is slightly misleading — a plain return resolved; would be clearer.

Suggested change
return withBasePath(resolved, basePath);
return resolved;

!basePath ||
!path.startsWith("/") ||
path.startsWith("http://") ||
path.startsWith("https://") ||
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: the path.startsWith("http://") and path.startsWith("https://") branches are unreachable — they're already caught by !path.startsWith("/") since those strings start with h. The "//" check is still reachable (protocol-relative URLs start with /), so that one is needed. Not a bug, just dead defensive code.

Comment on lines +85 to +91
const pathname =
basePath && resolved.pathname === basePath
? ""
: basePath
? stripBasePath(resolved.pathname, basePath)
: resolved.pathname;
return pathname + resolved.search + resolved.hash;
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.

The empty-string sentinel for the basePath-root case (resolved.pathname === basePath → "") is correct but subtle — it relies on toBrowserNavigationHref catching resolved === "" two functions away. A comment here noting that contract would help future readers, e.g.:

// Returns "" when at basePath root so toBrowserNavigationHref can re-add it.

href="?page=2"
id="link-relative-query"
onNavigate={(event) => {
const navEvent = event as typeof event & { url: URL };
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.

The cast event as typeof event & { url: URL } is needed because vinext exposes event.url but the onNavigate type only has preventDefault(). If/when the fixture typings are updated to include url: URL in the onNavigate event, this cast can be dropped. Fine for now as a test fixture.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented Mar 12, 2026

Review submitted. Here's a summary:

Approved with a few minor nits:

  1. Codex P1 is a false positive — the new withBasePath does not deduplicate basePath. It unconditionally prepends, and the test explicitly validates correct double-prefix behavior (/docs/getting-started/docs/docs/getting-started).

  2. Minor observations (non-blocking):

    • toBrowserNavigationHref: the !basePath branch calls withBasePath(resolved, "") which is a no-op — could just be return resolved.
    • withBasePath: the http:// and https:// guard branches are unreachable (already caught by !path.startsWith("/")). Only the // check is reachable. Not a bug, just dead defensive code.
    • resolveRelativeHref: the empty-string sentinel for basePath-root (pathname === basePath → "") is correct but relies on toBrowserNavigationHref catching it — a comment documenting this contract would help.
    • The event as typeof event & { url: URL } cast in the test fixture is fine for now but can be dropped if fixture typings are updated.

github run

@james-elicx james-elicx merged commit cb2d1c9 into cloudflare:main Mar 12, 2026
20 checks passed
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