Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 42 additions & 14 deletions packages/vinext/src/shims/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
type UrlQuery,
urlQueryToSearchParams,
} from "../utils/query.js";
import { matchRoutePattern, routePatternParts } from "../routing/route-pattern.js";
import { scrollToHashTarget } from "./hash-scroll.js";

/** basePath from next.config.js, injected by the plugin at build time */
Expand Down Expand Up @@ -291,6 +292,46 @@ function extractRouteParamNames(pattern: string): string[] {
return names;
}
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: extractRouteParamNames is now only called inside getRouteQueryFromNextData (the fallback path). If you wanted to tighten things up in a follow-up, you could consider inlining it there or just noting that it's exclusively used for the fallback. Not blocking — the current structure is clear enough.


type RouteQueryNextData = {
page?: string;
query?: Record<string, string | string[] | undefined>;
};

function splitPathSegments(pathname: string): string[] {
return pathname.split("/").filter(Boolean);
}

function extractRouteParamsFromPath(
pattern: string,
pathname: string,
): Record<string, string | string[]> | null {
return matchRoutePattern(splitPathSegments(pathname), routePatternParts(pattern));
}

function getRouteQueryFromNextData(
nextData: RouteQueryNextData | undefined,
resolvedPath: string,
): Record<string, string | string[]> {
const routeQuery: Record<string, string | string[]> = {};
if (!nextData?.query || !nextData.page) return routeQuery;

const routeParamNames = extractRouteParamNames(nextData.page);
if (routeParamNames.length === 0) return routeQuery;

const currentRouteParams = extractRouteParamsFromPath(nextData.page, resolvedPath);
if (currentRouteParams) return currentRouteParams;

for (const key of routeParamNames) {
const value = nextData.query[key];
if (typeof value === "string") {
routeQuery[key] = value;
} else if (Array.isArray(value)) {
routeQuery[key] = [...value];
}
}
return routeQuery;
}

function getPathnameAndQuery(): {
pathname: string;
query: Record<string, string | string[]>;
Expand All @@ -312,21 +353,8 @@ function getPathnameAndQuery(): {
// 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;
const routeQuery: Record<string, string | string[]> = {};
// Include dynamic route params from __NEXT_DATA__ (e.g., { id: "42" } from /posts/[id]).
// Only include keys that are part of the route pattern (not stale query params).
const nextData = window.__NEXT_DATA__;
if (nextData && nextData.query && nextData.page) {
const routeParamNames = extractRouteParamNames(nextData.page);
for (const key of routeParamNames) {
const value = nextData.query[key];
if (typeof value === "string") {
routeQuery[key] = value;
} else if (Array.isArray(value)) {
routeQuery[key] = [...value];
}
}
}
const routeQuery = getRouteQueryFromNextData(nextData, resolvedPath);
// URL search params always reflect the current URL
const searchQuery: Record<string, string | string[]> = {};
const params = new URLSearchParams(window.location.search);
Expand Down
22 changes: 22 additions & 0 deletions tests/e2e/pages-router/shallow-routing.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,28 @@ test.describe("Shallow routing (Pages Router)", () => {
);
});

test("dynamic route params update on shallow push across the same route template", async ({
page,
}) => {
// Ported from Next.js: test/e2e/middleware-rewrites/test/index.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/middleware-rewrites/test/index.test.ts
await page.goto(`${BASE}/posts/42`);
await expect(page.locator('[data-testid="post-title"]')).toHaveText("Post: 42");
await waitForHydration(page);

await page.evaluate(() =>
(window as any).next.router.push("/posts/43", undefined, {
shallow: true,
}),
);

await expect(page).toHaveURL(`${BASE}/posts/43`);
await expect(page.locator('[data-testid="post-title"]')).toHaveText("Post: 42");
await expect(page.locator('[data-testid="query"]')).toHaveText("Query ID: 43");
await expect(page.locator('[data-testid="pathname"]')).toHaveText("Pathname: /posts/[id]");
await expect(page.locator('[data-testid="as-path"]')).toHaveText("As Path: /posts/43");
});

test("router.query preserves repeated search params and router.asPath preserves hash", async ({
page,
}) => {
Expand Down
73 changes: 73 additions & 0 deletions tests/shims.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10405,6 +10405,79 @@ describe("Pages Router router helpers", () => {
}
});

it("updates dynamic route params from the URL after shallow navigation", async () => {
// Ported from Next.js: test/e2e/middleware-rewrites/test/index.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/middleware-rewrites/test/index.test.ts
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const { useRouter: useCompatRouter } =
await import("../packages/vinext/src/shims/compat-router.js");
const routerModule = await import("../packages/vinext/src/shims/router.js");

const previousWindow = (globalThis as any).window;
const win = {
location: {
pathname: "/posts/42",
search: "",
hash: "",
href: "http://localhost/posts/42",
hostname: "localhost",
assign: vi.fn(),
replace: vi.fn(),
reload: vi.fn(),
},
history: {
state: null,
pushState: vi.fn((_state: unknown, _title: string, url: string) => {
const nextUrl = new URL(url, win.location.href);
win.location.pathname = nextUrl.pathname;
win.location.search = nextUrl.search;
win.location.hash = nextUrl.hash;
win.location.href = nextUrl.href;
}),
replaceState: vi.fn(),
back: vi.fn(),
},
dispatchEvent: vi.fn(),
scrollTo: vi.fn(),
scrollX: 0,
scrollY: 0,
__NEXT_DATA__: {
page: "/posts/[id]",
query: { id: "42" },
isFallback: false,
},
Comment on lines +10445 to +10449
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.

Nice detail: the test intentionally keeps __NEXT_DATA__.query stale at { id: "42" } and asserts it stays unchanged (line 10397), which proves the fix derives params from the URL rather than mutating the hydration payload. This is exactly the right thing to verify.

__VINEXT_LOCALE__: undefined,
__VINEXT_LOCALES__: undefined,
__VINEXT_DEFAULT_LOCALE__: undefined,
};
(globalThis as any).window = win;

try {
await routerModule.default.push("/posts/43", undefined, { shallow: true });

let captured: unknown = "NOT_SET";
function Probe() {
captured = useCompatRouter();
return React.createElement("div", null, "probe");
}

renderToStaticMarkup(routerModule.wrapWithRouterContext(React.createElement(Probe)));

expect(captured).not.toBeNull();
expect((captured as any).pathname).toBe("/posts/[id]");
expect((captured as any).asPath).toBe("/posts/43");
expect((captured as any).query).toEqual({ id: "43" });
expect(win.__NEXT_DATA__.query).toEqual({ id: "42" });
} finally {
if (previousWindow === undefined) {
delete (globalThis as any).window;
} else {
(globalThis as any).window = previousWindow;
}
}
});

it("exposes beforePopState on both the Router singleton and wrapped router context", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
Expand Down
Loading