diff --git a/packages/vinext/src/shims/router.ts b/packages/vinext/src/shims/router.ts index 937035313..c9f8ca8d6 100644 --- a/packages/vinext/src/shims/router.ts +++ b/packages/vinext/src/shims/router.ts @@ -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 */ @@ -291,6 +292,46 @@ function extractRouteParamNames(pattern: string): string[] { return names; } +type RouteQueryNextData = { + page?: string; + query?: Record; +}; + +function splitPathSegments(pathname: string): string[] { + return pathname.split("/").filter(Boolean); +} + +function extractRouteParamsFromPath( + pattern: string, + pathname: string, +): Record | null { + return matchRoutePattern(splitPathSegments(pathname), routePatternParts(pattern)); +} + +function getRouteQueryFromNextData( + nextData: RouteQueryNextData | undefined, + resolvedPath: string, +): Record { + const routeQuery: Record = {}; + 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; @@ -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 = {}; - // 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 = {}; const params = new URLSearchParams(window.location.search); diff --git a/tests/e2e/pages-router/shallow-routing.spec.ts b/tests/e2e/pages-router/shallow-routing.spec.ts index 44b17b39f..767ddcc6d 100644 --- a/tests/e2e/pages-router/shallow-routing.spec.ts +++ b/tests/e2e/pages-router/shallow-routing.spec.ts @@ -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, }) => { diff --git a/tests/shims.test.ts b/tests/shims.test.ts index 87eeadcc9..aeeb4503e 100644 --- a/tests/shims.test.ts +++ b/tests/shims.test.ts @@ -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, + }, + __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");