Skip to content

fix: honor the as parameter in router.push/replace#453

Merged
james-elicx merged 4 commits intocloudflare:mainfrom
NathanDrake2406:fix/router-as-parameter
Mar 11, 2026
Merged

fix: honor the as parameter in router.push/replace#453
james-elicx merged 4 commits intocloudflare:mainfrom
NathanDrake2406:fix/router-as-parameter

Conversation

@NathanDrake2406
Copy link
Contributor

@NathanDrake2406 NathanDrake2406 commented Mar 11, 2026

Summary

  • Fix push() and replace() silently ignoring the as parameter in both useRouter() hook and Router singleton — this broke masked URLs and legacy dynamic-route flows
  • Extract resolveNavigationTarget() helper: uses as when provided, falls back to resolveUrl(url) otherwise
  • Applied to all four call sites: useRouter().push, useRouter().replace, Router.push, Router.replace
  • Move Cloudflare instrumentation example state to globalThis for cross-module-instance visibility
  • Add waitForHydration() helper to Pages Router E2E tests to fix flaky hydration races

Test plan

  • New E2E test: router.push(url, as) uses masked URL while resolving the real route
  • New E2E test: Router.replace(url, as) uses masked URL for singleton navigation
  • Existing navigation and hydration E2E tests pass with hydration waits
  • Full Vitest suite: 68 files, 2845 tests passed
  • Full E2E suite: 476 passed
  • CI (Format, Lint, Typecheck, Vitest, Playwright E2E)

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

pkg-pr-new bot commented Mar 11, 2026

Open in StackBlitz

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

commit: 1cf0c0a

@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: as parameter support in router.push/replace

The core fix is correct — as was being silently ignored via _as prefix, and the resolveNavigationTarget() helper is a clean factoring. The E2E tests cover the common case well, and the hydration/instrumentation fixes are sensible improvements.

One architectural concern about how as is handled, and some smaller notes below.

Semantic gap with Next.js as

In Next.js, url and as are tracked as two separate values throughout the navigation lifecycle:

  • url determines which page module to load and which data to fetch
  • as is the cosmetic URL shown in the browser bar
  • Both are stored in history.state for back/forward restoration

This PR collapses them into a single value: when as is provided, url is completely discarded. The returned value is used for both window.history.pushState({}, "", full) and navigateClient(full). This works for the common case tested here (where as is a concrete URL that the server can route to the same page), but would break if as were a purely decorative URL that doesn't map to the real route.

I think this is acceptable for now — the truly-decorative-URL pattern was largely replaced by rewrites after Next.js 9.5.3. But it's worth documenting the limitation. See inline comments.

waitForHydration duplication

The same waitForHydration helper is copy-pasted into both hydration.spec.ts and navigation.spec.ts. Worth extracting to a shared location (e.g., tests/e2e/fixtures.ts or a new tests/e2e/helpers.ts) to avoid drift.

Comment on lines +121 to +128
function resolveNavigationTarget(
url: string | UrlObject,
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.

This is the crux of the fix and it handles the common case correctly. However, it collapses url and as into a single value, whereas Next.js keeps them separate: url for data fetching/page resolution, as for the browser URL bar.

When as is provided, url is entirely discarded. The result is used for both history.pushState and navigateClient(). If someone passes an as that doesn't correspond to a real server route (a purely cosmetic URL), navigateClient would fetch the wrong page or 404.

This is fine for the overwhelmingly common case (and the pattern shown in the tests), but a comment documenting the simplification would help future contributors:

Suggested change
function resolveNavigationTarget(
url: string | UrlObject,
as: string | undefined,
locale: string | undefined,
): string {
return applyNavigationLocale(as ?? resolveUrl(url), locale);
}
function resolveNavigationTarget(
url: string | UrlObject,
as: string | undefined,
locale: string | undefined,
): string {
// When `as` is provided, use it as the navigation target. This is a
// simplification: Next.js keeps `url` and `as` as separate values (url for
// data fetching, as for the browser URL). We collapse them because vinext's
// navigateClient() fetches HTML from the target URL, so `as` must be a
// server-resolvable path. Purely decorative `as` values are not supported.
return applyNavigationLocale(as ?? resolveUrl(url), locale);
}

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Added the doc comment in 3da95b4. Also filed a tracking issue for the full two-value approach if we ever need it.

): Promise<boolean> => {
let resolved = applyNavigationLocale(resolveUrl(url), options?.locale);
async (url: string | UrlObject, as?: string, options?: TransitionOptions): Promise<boolean> => {
let resolved = resolveNavigationTarget(url, as, options?.locale);
Copy link
Contributor

Choose a reason for hiding this comment

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

Worth noting: after this line resolves to the as value, downstream navigateClient(full) fetches HTML from as, and history.pushState({}, "", full) stores only as in the URL. On back/forward (popstate), the handler reads window.location.pathname (= as) to re-fetch data. This works when as is a concrete resolvable URL but wouldn't for purely cosmetic ones.

Next.js stores both url and as in the history state object and uses url for data fetching on popstate. Consider filing a tracking issue for the full two-value approach if/when someone hits this.

const BASE = "http://localhost:4173";

async function waitForHydration(page: import("@playwright/test").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.

This same waitForHydration helper is duplicated in hydration.spec.ts:5. Consider extracting it to a shared location (e.g., tests/e2e/fixtures.ts or a new tests/e2e/helpers.ts) to avoid the copy drifting.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done — extracted to tests/e2e/helpers.ts in 3da95b4. There are ~20 other files with the same duplication but keeping the scope to what this PR touched.

Comment on lines +68 to +82
test("router.push(url, as) uses the masked URL while resolving the real route", async ({
page,
}) => {
await page.goto(`${BASE}/nav-test`);
await expect(page.locator("h1")).toHaveText("Navigation Test");
await waitForHydration(page);

await page.click('[data-testid="push-post-as-hook"]');
await expect(page.locator('[data-testid="post-title"]')).toHaveText("Post: 42");
await expect(page.locator('[data-testid="query"]')).toHaveText("Query ID: 42");
await expect(page.locator('[data-testid="as-path"]')).toHaveText(
"As Path: /posts/42?from=hook",
);
expect(page.url()).toBe(`${BASE}/posts/42?from=hook`);
});
Copy link
Contributor

Choose a reason for hiding this comment

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

Good test case. One thing that would strengthen coverage: assert that router.pathname is the route pattern (/posts/[id]) rather than the resolved path (/posts/42). This is an important Next.js semantic — after navigating with as, pathname should remain the route pattern while asPath is the actual browser URL. The fixture already has a [data-testid="pathname"] element, so adding:

await expect(page.locator('[data-testid="pathname"]')).toHaveText("Pathname: /posts/[id]");

would verify that vinext correctly reports the route pattern via router.pathname after an as-based navigation.

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 — added await expect(page.locator('[data-testid="pathname"]')).toHaveText("Pathname: /posts/[id]"); in 3da95b4.

Comment on lines +103 to +121
test("Router.replace(url, as) uses the masked URL for singleton navigation", async ({ page }) => {
await page.goto(`${BASE}/`);
await expect(page.locator("h1")).toHaveText("Hello, vinext!");

await page.goto(`${BASE}/nav-test`);
await expect(page.locator("h1")).toHaveText("Navigation Test");
await waitForHydration(page);

await page.click('[data-testid="replace-post-as-singleton"]');
await expect(page.locator('[data-testid="post-title"]')).toHaveText("Post: 84");
await expect(page.locator('[data-testid="query"]')).toHaveText("Query ID: 84");
await expect(page.locator('[data-testid="as-path"]')).toHaveText(
"As Path: /posts/84?from=singleton",
);
expect(page.url()).toBe(`${BASE}/posts/84?from=singleton`);

await page.goBack();
await expect(page.locator("h1")).toHaveText("Hello, vinext!");
});
Copy link
Contributor

Choose a reason for hiding this comment

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

Nice — testing the Router singleton path separately from the useRouter() hook path gives good coverage of all four call sites. The goBack assertion at the end verifying we land on Home (not nav-test) correctly validates the replace semantics.

Comment on lines +23 to +36
function getInstrumentationState(): InstrumentationState {
const scopedGlobal = globalThis as typeof globalThis & {
__VINEXT_CLOUDFLARE_INSTRUMENTATION_STATE__?: InstrumentationState;
};

/** List of errors captured by onRequestError(). */
const capturedErrors: CapturedRequestError[] = [];
if (!scopedGlobal.__VINEXT_CLOUDFLARE_INSTRUMENTATION_STATE__) {
scopedGlobal.__VINEXT_CLOUDFLARE_INSTRUMENTATION_STATE__ = {
capturedErrors: [],
registerCalled: false,
};
}

return scopedGlobal.__VINEXT_CLOUDFLARE_INSTRUMENTATION_STATE__;
}
Copy link
Contributor

Choose a reason for hiding this comment

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

The globalThis approach is the right fix for cross-module-instance visibility, and the lazy initialization is clean.

Minor question: is there any concern about state leaking across requests in production Workers (where globalThis persists across isolate reuses)? resetInstrumentationState() exists, but is it called at the start of each request? If not, capturedErrors from a previous request could accumulate.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

No concern here — this file is only used in the example app for e2e testing, not production code. capturedErrors accumulation is intentional: tests hit routes that trigger errors, then assert on the captured list via GET /api/instrumentation-test. The DELETE endpoint exists to reset between test runs. In a real app, users wouldn't use this module.

@ask-bonk
Copy link
Contributor

ask-bonk bot commented Mar 11, 2026

Review posted successfully. Here's a summary of the feedback I left on PR #453:

Core fix is correctas was silently ignored via _as prefix in all four call sites, and the resolveNavigationTarget() helper is a clean extraction.

Key architectural feedback:

  1. Semantic gap with Next.js as — The PR collapses url and as into a single value, whereas Next.js maintains them separately (url for data fetching, as for the browser URL bar). This works for the common case but would break for purely cosmetic as values. Suggested adding a documentation comment explaining the simplification (with a code suggestion).

  2. History state limitation — On back/forward, Next.js retrieves the stored url from history state for data fetching. vinext only stores as in the URL, so popstate uses it for both display and data fetching. Suggested filing a tracking issue for the full two-value approach.

  3. Missing pathname assertion — The new E2E test verifies asPath and query but doesn't assert that router.pathname remains the route pattern (/posts/[id]) after navigation. This is an important Next.js semantic.

  4. waitForHydration duplication — The helper is copy-pasted into two test files; should be extracted to a shared location.

  5. globalThis state leaking — Asked about whether resetInstrumentationState() is called per-request in production Workers to prevent cross-request state accumulation.

github run

- 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.
@james-elicx
Copy link
Collaborator

/bigbonk re-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: as parameter support in router.push/replace

The core fix is correct and well-implemented. The _as prefix was silently discarding a real Next.js API parameter, and the resolveNavigationTarget() extraction is clean. The previous review from ask-bonk covered the main architectural concerns thoroughly, and the author has addressed them (doc comment added, waitForHydration extracted, tracking issue filed). The E2E tests cover both useRouter() hook and Router singleton paths with good assertions.

A few remaining observations below — none are blocking.

"As Path: /posts/42?from=hook",
);
// TODO(#462): after implementing two-value url/as navigation, assert that
// router.pathname stays as the route pattern (/posts/[id]) not the resolved path.
Copy link
Contributor

Choose a reason for hiding this comment

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

The TODO referencing #462 is a good way to track this. One clarification: the comment says "assert that router.pathname stays as the route pattern" — but currently, after client navigation with as, does router.pathname return the resolved path (/posts/42) or the pattern (/posts/[id])? If it returns the resolved path, that's a known behavioral gap (not just a missing assertion). It might be worth making that explicit in the issue.

Also, the [data-testid="pathname"] element is present on posts/[id].tsx — even without the TODO assertion here, navigating to this page does implicitly render router.pathname. A quick sanity check: if router.pathname currently resolves to /posts/42 instead of /posts/[id], that would also affect the asPath value (since Next.js computes asPath differently when it has both url and as). Worth documenting in the tracking issue.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, it was a behavioral gap — router.pathname was returning the resolved path (/posts/42) because getPathnameAndQuery() derived it from window.location.pathname. Fixed in #463: now uses __NEXT_DATA__.page (the route pattern) for pathname, while asPath continues using the resolved browser path. Both SSR and client-side navigation are covered.

await expect(page.locator("h1")).not.toHaveText("Navigation Test");
});

test("Router.replace(url, as) uses the masked URL for singleton navigation", async ({ page }) => {
Copy link
Contributor

Choose a reason for hiding this comment

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

Minor: the test navigates to home first (page.goto(BASE)), then to nav-test (page.goto(BASE/nav-test)). The double goto is intentional to set up the history stack for the goBack assertion at the end, but a brief comment explaining this (similar to the one in the router.replace test at line 84) would help readability.

Suggested change
test("Router.replace(url, as) uses the masked URL for singleton navigation", async ({ page }) => {
test("Router.replace(url, as) uses the masked URL for singleton navigation", async ({ page }) => {
// Start at home so goBack() at the end lands here (replace removes nav-test from history)
await page.goto(`${BASE}/`);
await expect(page.locator("h1")).toHaveText("Hello, vinext!");

import type { Page } from "@playwright/test";

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.

Good extraction. Note that there are ~15+ other E2E test files that still define their own local waitForHydration (e.g., shallow-routing.spec.ts, routes.spec.ts, server-actions.spec.ts, form.spec.ts, etc.). The author noted this is out of scope for this PR which is reasonable — but a follow-up to consolidate all of them would prevent future drift. Consider adding a brief comment or filing an issue to track it.

Also worth noting: this helper currently has no timeout parameter. If hydration takes longer than Playwright's default timeout (30s), the test will fail with a generic timeout error rather than a clear "hydration didn't complete" message. Not blocking, but a { timeout } option would improve debugging:

Suggested change
await page.waitForFunction(() => Boolean((window as any).__VINEXT_ROOT__));
export async function waitForHydration(page: Page, timeout = 10_000) {
await page.waitForFunction(() => Boolean((window as any).__VINEXT_ROOT__), { timeout });
}

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.

Nit: when as is provided as a string like "/posts/42?from=hook", it bypasses resolveUrl() entirely — which is correct for the common case. But if someone passes as with special characters that need encoding, this could produce unexpected URLs. Next.js internally normalizes the as value. Not blocking since the same limitation existed before (just with url), but worth being aware of.

@ask-bonk
Copy link
Contributor

ask-bonk bot commented Mar 11, 2026

Review posted successfully. Here's a summary of the feedback on PR #453:

Verdict: Approved — The fix is correct and the implementation is clean.

The core issue was that all four call sites (useRouter().push, useRouter().replace, Router.push, Router.replace) prefixed the as parameter with _ (i.e., _as), silently discarding it. The resolveNavigationTarget() helper is a good extraction that centralizes the logic with a clear doc comment explaining the simplification (collapsing url and as into one value).

Inline comments left:

  1. TODO Track full url/as two-value navigation approach #462 clarification — Suggested making the tracking issue explicit about whether router.pathname currently returns the resolved path vs the route pattern, since that's a behavioral gap (not just a missing assertion).

  2. Test readability — Suggested adding a comment on the Router.replace test explaining why it does a double page.goto() (to set up history stack for the goBack assertion).

  3. waitForHydration helper — Noted the ~15+ remaining duplicate definitions in other test files, and suggested adding a timeout parameter for better error messages when hydration fails.

  4. URL normalization — Noted that as bypasses resolveUrl() entirely, so special characters won't be normalized. Not blocking since it matches the pre-existing behavior.

github run

@james-elicx james-elicx merged commit 45b72ea into cloudflare:main Mar 11, 2026
21 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