Skip to content

Commit 45b72ea

Browse files
fix: honor the as parameter in router.push/replace (#453)
* fix: stabilize instrumentation state and hydration E2E tests 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. * fix: honor the `as` parameter in router.push/replace 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. * fix: address PR review feedback - 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 * fix: remove pathname assertion that tests unimplemented behavior 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 #462.
1 parent 15cd5d2 commit 45b72ea

7 files changed

Lines changed: 136 additions & 39 deletions

File tree

Lines changed: 28 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,9 @@
11
/**
22
* Shared state for instrumentation.ts testing in app-router-cloudflare.
33
*
4-
* ## Why plain module-level variables work here
5-
*
6-
* register() is now emitted as a top-level `await` inside the generated RSC
7-
* entry module (see `generateRscEntry` in `entries/app-rsc-entry.ts`). This means it
8-
* runs inside the Cloudflare Worker process — the same process and module graph
9-
* as the API routes. Plain module-level variables are therefore visible to both
10-
* the instrumentation code and the API route that reads them.
11-
*
12-
* This is different from the old approach (writing to a temp file on disk) which
13-
* was needed when register() ran in the host Node.js process and API routes ran
14-
* in the miniflare Worker subprocess (two separate processes, no shared memory).
4+
* Cloudflare dev and worker paths can evaluate instrumentation and route modules
5+
* through different module instances. Store state on globalThis so the startup
6+
* register() path and the request-handling path observe the same values.
157
*/
168

179
export type CapturedRequestError = {
@@ -23,29 +15,44 @@ export type CapturedRequestError = {
2315
routeType: string;
2416
}
2517

26-
/** Set to true when instrumentation.ts register() is called. */
27-
let registerCalled = false;
18+
type InstrumentationState = {
19+
capturedErrors: CapturedRequestError[];
20+
registerCalled: boolean;
21+
};
22+
23+
function getInstrumentationState(): InstrumentationState {
24+
const scopedGlobal = globalThis as typeof globalThis & {
25+
__VINEXT_CLOUDFLARE_INSTRUMENTATION_STATE__?: InstrumentationState;
26+
};
2827

29-
/** List of errors captured by onRequestError(). */
30-
const capturedErrors: CapturedRequestError[] = [];
28+
if (!scopedGlobal.__VINEXT_CLOUDFLARE_INSTRUMENTATION_STATE__) {
29+
scopedGlobal.__VINEXT_CLOUDFLARE_INSTRUMENTATION_STATE__ = {
30+
capturedErrors: [],
31+
registerCalled: false,
32+
};
33+
}
34+
35+
return scopedGlobal.__VINEXT_CLOUDFLARE_INSTRUMENTATION_STATE__;
36+
}
3137

3238
export function isRegisterCalled(): boolean {
33-
return registerCalled;
39+
return getInstrumentationState().registerCalled;
3440
}
3541

3642
export function getCapturedErrors(): CapturedRequestError[] {
37-
return [...capturedErrors];
43+
return [...getInstrumentationState().capturedErrors];
3844
}
3945

4046
export function markRegisterCalled(): void {
41-
registerCalled = true;
47+
getInstrumentationState().registerCalled = true;
4248
}
4349

4450
export function recordRequestError(entry: CapturedRequestError): void {
45-
capturedErrors.push(entry);
51+
getInstrumentationState().capturedErrors.push(entry);
4652
}
4753

4854
export function resetInstrumentationState(): void {
49-
registerCalled = false;
50-
capturedErrors.length = 0;
55+
const state = getInstrumentationState();
56+
state.registerCalled = false;
57+
state.capturedErrors.length = 0;
5158
}

packages/vinext/src/shims/router.ts

Lines changed: 23 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,21 @@ function resolveUrl(url: string | UrlObject): string {
118118
return result;
119119
}
120120

121+
/**
122+
* When `as` is provided, use it as the navigation target. This is a
123+
* simplification: Next.js keeps `url` and `as` as separate values (url for
124+
* data fetching, as for the browser URL). We collapse them because vinext's
125+
* navigateClient() fetches HTML from the target URL, so `as` must be a
126+
* server-resolvable path. Purely decorative `as` values are not supported.
127+
*/
128+
function resolveNavigationTarget(
129+
url: string | UrlObject,
130+
as: string | undefined,
131+
locale: string | undefined,
132+
): string {
133+
return applyNavigationLocale(as ?? resolveUrl(url), locale);
134+
}
135+
121136
/**
122137
* Apply locale prefix to a URL for client-side navigation.
123138
* Same logic as Link's applyLocaleToHref but reads from window globals.
@@ -469,12 +484,8 @@ export function useRouter(): NextRouter {
469484
}, []);
470485

471486
const push = useCallback(
472-
async (
473-
url: string | UrlObject,
474-
_as?: string,
475-
options?: TransitionOptions,
476-
): Promise<boolean> => {
477-
let resolved = applyNavigationLocale(resolveUrl(url), options?.locale);
487+
async (url: string | UrlObject, as?: string, options?: TransitionOptions): Promise<boolean> => {
488+
let resolved = resolveNavigationTarget(url, as, options?.locale);
478489

479490
// External URLs — delegate to browser (unless same-origin)
480491
if (isExternalUrl(resolved)) {
@@ -524,12 +535,8 @@ export function useRouter(): NextRouter {
524535
);
525536

526537
const replace = useCallback(
527-
async (
528-
url: string | UrlObject,
529-
_as?: string,
530-
options?: TransitionOptions,
531-
): Promise<boolean> => {
532-
let resolved = applyNavigationLocale(resolveUrl(url), options?.locale);
538+
async (url: string | UrlObject, as?: string, options?: TransitionOptions): Promise<boolean> => {
539+
let resolved = resolveNavigationTarget(url, as, options?.locale);
533540

534541
// External URLs — delegate to browser (unless same-origin)
535542
if (isExternalUrl(resolved)) {
@@ -671,8 +678,8 @@ export function wrapWithRouterContext(element: ReactElement): ReactElement {
671678

672679
// Also export a default Router singleton for `import Router from 'next/router'`
673680
const Router = {
674-
push: async (url: string | UrlObject, _as?: string, options?: TransitionOptions) => {
675-
let resolved = applyNavigationLocale(resolveUrl(url), options?.locale);
681+
push: async (url: string | UrlObject, as?: string, options?: TransitionOptions) => {
682+
let resolved = resolveNavigationTarget(url, as, options?.locale);
676683

677684
// External URLs (unless same-origin)
678685
if (isExternalUrl(resolved)) {
@@ -715,8 +722,8 @@ const Router = {
715722
window.dispatchEvent(new CustomEvent("vinext:navigate"));
716723
return true;
717724
},
718-
replace: async (url: string | UrlObject, _as?: string, options?: TransitionOptions) => {
719-
let resolved = applyNavigationLocale(resolveUrl(url), options?.locale);
725+
replace: async (url: string | UrlObject, as?: string, options?: TransitionOptions) => {
726+
let resolved = resolveNavigationTarget(url, as, options?.locale);
720727

721728
// External URLs (unless same-origin)
722729
if (isExternalUrl(resolved)) {

tests/e2e/helpers.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import type { Page } from "@playwright/test";
2+
3+
export async function waitForHydration(page: Page) {
4+
await page.waitForFunction(() => Boolean((window as any).__VINEXT_ROOT__));
5+
}

tests/e2e/pages-router/hydration.spec.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { test, expect } from "../fixtures";
2+
import { waitForHydration } from "../helpers";
23

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

@@ -12,7 +13,8 @@ test.describe("Hydration", () => {
1213
// SSR should render the initial count
1314
await expect(page.locator('[data-testid="count"]')).toContainText("Count:");
1415

15-
// Wait for hydration — button should become interactive
16+
await waitForHydration(page);
17+
1618
await page.click('[data-testid="increment"]');
1719
await expect(page.locator('[data-testid="count"]')).toHaveText("Count: 1");
1820

@@ -57,6 +59,7 @@ test.describe("Hydration", () => {
5759
test("state is preserved within client navigation", async ({ page, consoleErrors }) => {
5860
// Start at counter page, increment, then navigate away and back
5961
await page.goto(`${BASE}/counter`);
62+
await waitForHydration(page);
6063
await page.click('[data-testid="increment"]');
6164
await page.click('[data-testid="increment"]');
6265
await expect(page.locator('[data-testid="count"]')).toHaveText("Count: 2");

tests/e2e/pages-router/navigation.spec.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import { test, expect } from "@playwright/test";
2+
import { waitForHydration } from "../helpers";
23

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

56
test.describe("Client-side navigation", () => {
67
test("Link click navigates without full page reload", async ({ page }) => {
78
await page.goto(`${BASE}/`);
89
await expect(page.locator("h1")).toHaveText("Hello, vinext!");
10+
await waitForHydration(page);
911

1012
// Store a marker on the window to detect if page fully reloaded
1113
await page.evaluate(() => {
@@ -29,6 +31,7 @@ test.describe("Client-side navigation", () => {
2931
test("Link navigates back to home from about", async ({ page }) => {
3032
await page.goto(`${BASE}/about`);
3133
await expect(page.locator("h1")).toHaveText("About");
34+
await waitForHydration(page);
3235

3336
await page.evaluate(() => {
3437
(window as any).__NAV_MARKER__ = true;
@@ -45,6 +48,7 @@ test.describe("Client-side navigation", () => {
4548
test("router.push navigates to a new page", async ({ page }) => {
4649
await page.goto(`${BASE}/nav-test`);
4750
await expect(page.locator("h1")).toHaveText("Navigation Test");
51+
await waitForHydration(page);
4852

4953
await page.evaluate(() => {
5054
(window as any).__NAV_MARKER__ = true;
@@ -58,6 +62,24 @@ test.describe("Client-side navigation", () => {
5862
expect(page.url()).toBe(`${BASE}/about`);
5963
});
6064

65+
test("router.push(url, as) uses the masked URL while resolving the real route", async ({
66+
page,
67+
}) => {
68+
await page.goto(`${BASE}/nav-test`);
69+
await expect(page.locator("h1")).toHaveText("Navigation Test");
70+
await waitForHydration(page);
71+
72+
await page.click('[data-testid="push-post-as-hook"]');
73+
await expect(page.locator('[data-testid="post-title"]')).toHaveText("Post: 42");
74+
await expect(page.locator('[data-testid="query"]')).toHaveText("Query ID: 42");
75+
await expect(page.locator('[data-testid="as-path"]')).toHaveText(
76+
"As Path: /posts/42?from=hook",
77+
);
78+
// TODO(#462): after implementing two-value url/as navigation, assert that
79+
// router.pathname stays as the route pattern (/posts/[id]) not the resolved path.
80+
expect(page.url()).toBe(`${BASE}/posts/42?from=hook`);
81+
});
82+
6183
test("router.replace navigates without adding history entry", async ({ page }) => {
6284
// Start at home, then go to nav-test, then replace to SSR
6385
await page.goto(`${BASE}/`);
@@ -66,6 +88,7 @@ test.describe("Client-side navigation", () => {
6688
// Navigate to nav-test via direct navigation
6789
await page.goto(`${BASE}/nav-test`);
6890
await expect(page.locator("h1")).toHaveText("Navigation Test");
91+
await waitForHydration(page);
6992

7093
await page.click('[data-testid="replace-ssr"]');
7194
await expect(page.locator("h1")).toHaveText("Server-Side Rendered");
@@ -76,9 +99,30 @@ test.describe("Client-side navigation", () => {
7699
await expect(page.locator("h1")).not.toHaveText("Navigation Test");
77100
});
78101

102+
test("Router.replace(url, as) uses the masked URL for singleton navigation", async ({ page }) => {
103+
await page.goto(`${BASE}/`);
104+
await expect(page.locator("h1")).toHaveText("Hello, vinext!");
105+
106+
await page.goto(`${BASE}/nav-test`);
107+
await expect(page.locator("h1")).toHaveText("Navigation Test");
108+
await waitForHydration(page);
109+
110+
await page.click('[data-testid="replace-post-as-singleton"]');
111+
await expect(page.locator('[data-testid="post-title"]')).toHaveText("Post: 84");
112+
await expect(page.locator('[data-testid="query"]')).toHaveText("Query ID: 84");
113+
await expect(page.locator('[data-testid="as-path"]')).toHaveText(
114+
"As Path: /posts/84?from=singleton",
115+
);
116+
expect(page.url()).toBe(`${BASE}/posts/84?from=singleton`);
117+
118+
await page.goBack();
119+
await expect(page.locator("h1")).toHaveText("Hello, vinext!");
120+
});
121+
79122
test("browser back/forward buttons work after client navigation", async ({ page }) => {
80123
await page.goto(`${BASE}/`);
81124
await expect(page.locator("h1")).toHaveText("Hello, vinext!");
125+
await waitForHydration(page);
82126

83127
// Navigate: Home -> About via link
84128
await page.click('a[href="/about"]');
@@ -98,6 +142,7 @@ test.describe("Client-side navigation", () => {
98142
test("multiple sequential navigations work", async ({ page }) => {
99143
await page.goto(`${BASE}/nav-test`);
100144
await expect(page.locator("h1")).toHaveText("Navigation Test");
145+
await waitForHydration(page);
101146

102147
await page.evaluate(() => {
103148
(window as any).__NAV_MARKER__ = true;
@@ -123,6 +168,7 @@ test.describe("Client-side navigation", () => {
123168
test("navigating to SSR page fetches fresh server data", async ({ page }) => {
124169
await page.goto(`${BASE}/nav-test`);
125170
await expect(page.locator("h1")).toHaveText("Navigation Test");
171+
await waitForHydration(page);
126172

127173
await page.click('[data-testid="link-ssr"]');
128174
await expect(page.locator("h1")).toHaveText("Server-Side Rendered");

tests/fixtures/pages-basic/pages/nav-test.tsx

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useRouter } from "next/router";
1+
import Router, { useRouter } from "next/router";
22
import Link from "next/link";
33

44
export default function NavTestPage() {
@@ -16,6 +16,34 @@ export default function NavTestPage() {
1616
<button data-testid="push-counter" onClick={() => router.push("/counter")}>
1717
Push to Counter
1818
</button>
19+
<button
20+
data-testid="push-post-as-hook"
21+
onClick={() =>
22+
router.push(
23+
{
24+
pathname: "/posts/[id]",
25+
query: { id: "42", from: "hook" },
26+
},
27+
"/posts/42?from=hook",
28+
)
29+
}
30+
>
31+
Push masked Post via hook
32+
</button>
33+
<button
34+
data-testid="replace-post-as-singleton"
35+
onClick={() =>
36+
Router.replace(
37+
{
38+
pathname: "/posts/[id]",
39+
query: { id: "84", from: "singleton" },
40+
},
41+
"/posts/84?from=singleton",
42+
)
43+
}
44+
>
45+
Replace masked Post via singleton
46+
</button>
1947
<Link href="/" data-testid="link-home">
2048
Link to Home
2149
</Link>

tests/fixtures/pages-basic/pages/posts/[id].tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export default function Post({ id }: PostProps) {
1111
<div>
1212
<h1 data-testid="post-title">Post: {id}</h1>
1313
<p data-testid="pathname">Pathname: {router.pathname}</p>
14+
<p data-testid="as-path">As Path: {router.asPath}</p>
1415
<p data-testid="query">Query ID: {router.query.id}</p>
1516
</div>
1617
);

0 commit comments

Comments
 (0)