Skip to content

fix: return route pattern from router.pathname, not resolved path#463

Merged
james-elicx merged 8 commits intocloudflare:mainfrom
NathanDrake2406:fix/router-url-as-two-value
Mar 11, 2026
Merged

fix: return route pattern from router.pathname, not resolved path#463
james-elicx merged 8 commits intocloudflare:mainfrom
NathanDrake2406:fix/router-url-as-two-value

Conversation

@NathanDrake2406
Copy link
Contributor

@NathanDrake2406 NathanDrake2406 commented Mar 11, 2026

Summary

  • router.pathname now returns the route pattern (e.g., /posts/[id]) instead of the resolved path (/posts/42), matching Next.js semantics
  • router.asPath continues to return the resolved browser URL as expected
  • Uses __NEXT_DATA__.page (which holds the route pattern from SSR and is updated on every client navigation) as the source of truth for pathname

Depends on #453 — this branch includes those commits. Merge #453 first, then this PR.

Closes #462

Test plan

  • Unit tests pass (652/652 in shims.test.ts)
  • Updated E2E assertion: router.pathname is /posts/[id] after as-based navigation
  • Updated E2E assertion: router.pathname is /posts/[id] on SSR page load
  • CI: all checks green

Move Cloudflare instrumentation example state from module-level
variables to globalThis so startup and request paths share one owner
across different module instances.

Add waitForHydration() helper to Pages Router E2E tests to eliminate
flaky button-click assertions that raced against React hydration.
The `as` parameter in push() and replace() was silently ignored
(prefixed as _as) in both the useRouter() hook and the Router
singleton. This broke masked URLs and legacy dynamic-route flows.

Extract resolveNavigationTarget() helper that uses `as` when provided,
falling back to resolveUrl(url) otherwise. Apply to all four call
sites: useRouter().push, useRouter().replace, Router.push, Router.replace.
- Add doc comment to resolveNavigationTarget explaining the url/as
  simplification vs Next.js's two-value approach
- Extract waitForHydration to shared tests/e2e/helpers.ts
- Add pathname assertion to verify router.pathname stays as route
  pattern (/posts/[id]) after as-based navigation
router.pathname currently returns the resolved path after client-side
navigation, not the route pattern. The correct behavior (returning
/posts/[id] instead of /posts/42) requires the two-value url/as
approach tracked in cloudflare#462.
In Next.js, router.pathname returns the route pattern (e.g.,
"/posts/[id]"), not the resolved path ("/posts/42"). vinext was
deriving pathname from window.location.pathname which gives the
resolved path.

Fix: use __NEXT_DATA__.page (which holds the route pattern) as
pathname, falling back to location.pathname when __NEXT_DATA__ isn't
available. asPath continues to use the resolved browser path.

Closes cloudflare#462
@pkg-pr-new
Copy link

pkg-pr-new bot commented Mar 11, 2026

Open in StackBlitz

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

commit: 9a2ab25

Copy link

@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: 090b76cbc2

ℹ️ 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".

// In Next.js, router.pathname is the route pattern (e.g., "/posts/[id]"),
// not the resolved path ("/posts/42"). __NEXT_DATA__.page holds the route
// pattern and is updated by navigateClient() on every client-side navigation.
const pathname = window.__NEXT_DATA__?.page ?? resolvedPath;

Choose a reason for hiding this comment

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

P2 Badge Keep router pathname source consistent across SSR and hydration

Switching client pathname to window.__NEXT_DATA__.page here makes dynamic-route pages hydrate with a different value than the server markup, because SSR still seeds router state from the resolved URL path (e.g. setSSRContext({ pathname: routeUrl.split("?")[0], ... }) in the pages server entries). When a page renders router.pathname in HTML (like /posts/[id] pages), SSR prints /posts/123 but hydration computes /posts/[id], which triggers hydration mismatch warnings and DOM replacement on first load.

Useful? React with 👍 / 👎.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good catch — fixed in 509e340. All three SSR entry points (dev-server, pages-server-entry, static-export) now use patternToNextFormat(route.pattern) for the SSR context pathname, matching what __NEXT_DATA__.page already provides. SSR and hydration are now consistent.

All three SSR entry points (dev-server, pages-server-entry,
static-export) were setting pathname to the resolved URL path (e.g.,
"/posts/42") while the client now derives it from __NEXT_DATA__.page
(the route pattern "/posts/[id]"). This caused hydration mismatches
when components render router.pathname.

Fix: use patternToNextFormat(route.pattern) for the SSR context
pathname, matching what __NEXT_DATA__.page already provides.
@james-elicx
Copy link
Collaborator

/bigbonk review

Copy link
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.

Review: fix router.pathname semantics

This is a solid fix for a real Next.js compatibility issue. The change correctly makes router.pathname return the route pattern (/posts/[id]) instead of the resolved path (/posts/42), matching Next.js semantics.

What's good

  • Consistent SSR/hydration: All three SSR entry points (dev-server.ts, pages-server-entry.ts, static-export.ts) now use patternToNextFormat(route.pattern) for the SSR context pathname. This avoids the hydration mismatch that the Codex bot flagged (and the author fixed in this commit).
  • Clean separation of pathname vs asPath: getPathnameAndQuery() correctly uses __NEXT_DATA__.page for pathname (route pattern) and the actual browser URL for asPath.
  • resolveNavigationTarget helper: Good extraction — the as parameter handling is clear and the JSDoc accurately describes the limitation (purely decorative as values aren't supported).
  • Test coverage: New E2E tests for push(url, as) and Router.replace(url, as) with proper assertions on both pathname and asPath.
  • waitForHydration shared helper: Good consolidation. Though note the codebase still has many inline copies of this pattern (see minor comment below).
  • instrumentation-state.ts refactor: The globalThis-based approach is the right fix for cross-module-instance state in Cloudflare Workers.

Minor observations (non-blocking)

A few things worth noting but none are blockers.


export async function waitForHydration(page: Page) {
await page.waitForFunction(() => Boolean((window as any).__VINEXT_ROOT__));
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Nice to have this as a shared helper. There are ~15+ other E2E spec files that still define their own local waitForHydration or inline page.waitForFunction(() => (window as any).__VINEXT_ROOT__). Consolidating those to use this shared import could be done in a follow-up to reduce duplication.

as: string | undefined,
locale: string | undefined,
): string {
return applyNavigationLocale(as ?? resolveUrl(url), locale);
Copy link
Contributor

Choose a reason for hiding this comment

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

The comment above accurately describes the limitation: as must be a server-resolvable path because vinext fetches HTML from it. Worth noting that in Next.js, router.push({ pathname: '/posts/[id]', query: { id: '42' } }) (without as) resolves the dynamic segments automatically. Here, without as, resolveUrl would produce /posts/[id]?id=42 which isn't a valid server path.

This is a known gap and the comment documents it, but it could catch users who rely on the pattern-only form without as. Consider a follow-up to resolve dynamic segments from the UrlObject.pathname when as is omitted (match /posts/[id] + query.id/posts/42).

// In Next.js, router.pathname is the route pattern (e.g., "/posts/[id]"),
// not the resolved path ("/posts/42"). __NEXT_DATA__.page holds the route
// pattern and is updated by navigateClient() on every client-side navigation.
const pathname = window.__NEXT_DATA__?.page ?? resolvedPath;
Copy link
Contributor

Choose a reason for hiding this comment

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

The fallback to resolvedPath when __NEXT_DATA__ isn't available is sensible — it handles edge cases where the page loaded without the SSR data script (e.g., a raw HTML shell). Good defensive coding.

@ask-bonk
Copy link
Contributor

ask-bonk bot commented Mar 11, 2026

Review submitted. Here's a summary of my findings:

Approved — This is a correct and well-tested fix for router.pathname semantics.

Key changes reviewed

  1. router.pathname now returns the route pattern (e.g., /posts/[id]) instead of the resolved path (/posts/42), sourced from window.__NEXT_DATA__.page on the client and patternToNextFormat(route.pattern) during SSR.

  2. SSR/hydration parity — All three SSR entry points (dev-server.ts, pages-server-entry.ts, static-export.ts) were updated consistently, avoiding the hydration mismatch that was initially flagged.

  3. as parameter supportpush(url, as) and replace(url, as) now correctly use as as the navigation target via the new resolveNavigationTarget helper, with honest documentation that purely decorative as values aren't supported.

  4. instrumentation-state.ts — Refactored to use globalThis storage, fixing cross-module-instance issues in Cloudflare Workers.

Minor notes left on the PR

  • The new waitForHydration shared helper is good, but ~15+ other spec files still have inline copies that could be consolidated in a follow-up.
  • resolveUrl without as on a dynamic UrlObject (e.g., { pathname: '/posts/[id]', query: { id: '42' } }) would produce a broken URL — a known limitation documented in the code, but worth a follow-up to auto-resolve dynamic segments.

github run

…-value

# Conflicts:
#	tests/e2e/pages-router/navigation.spec.ts
@james-elicx james-elicx enabled auto-merge (squash) March 11, 2026 10:47
@james-elicx james-elicx merged commit 47500b1 into cloudflare:main Mar 11, 2026
17 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.

Track full url/as two-value navigation approach

2 participants