From 5d19dc91cb1193e7dbbe44097de9b3066a2f84d1 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Sat, 16 May 2026 23:14:04 +1000 Subject: [PATCH 1/2] fix(router): derive shallow dynamic params from the URL Pages Router shallow navigation across one dynamic route template updated the browser URL but kept router.query params from the original __NEXT_DATA__ payload. That left client state stale after transitions such as /posts/42 to /posts/43. The router now matches the current pathname against the hydrated route pattern before falling back to __NEXT_DATA__.query. This preserves shallow data-fetch skipping while keeping dynamic params aligned with the visible URL. Tests cover the router shim boundary and the browser-visible Pages Router shallow transition. --- packages/vinext/src/shims/router.ts | 126 ++++++++++++++++-- .../e2e/pages-router/shallow-routing.spec.ts | 22 +++ tests/shims.test.ts | 73 ++++++++++ 3 files changed, 207 insertions(+), 14 deletions(-) diff --git a/packages/vinext/src/shims/router.ts b/packages/vinext/src/shims/router.ts index 6f8d77c65..d0afbcb4b 100644 --- a/packages/vinext/src/shims/router.ts +++ b/packages/vinext/src/shims/router.ts @@ -300,6 +300,117 @@ function extractRouteParamNames(pattern: string): string[] { return names; } +type RoutePatternPart = + | { kind: "static"; value: string } + | { kind: "single"; key: string } + | { kind: "catchAll"; key: string; optional: boolean }; + +type RouteQueryNextData = { + page?: string; + query?: Record; +}; + +function decodePathSegment(segment: string): string { + try { + return decodeURIComponent(segment); + } catch { + return segment; + } +} + +function splitPathSegments(pathname: string): string[] { + return pathname.split("/").filter(Boolean); +} + +function parseRoutePatternPart(segment: string): RoutePatternPart { + if (segment.startsWith("[[...") && segment.endsWith("]]") && segment.length > 7) { + return { kind: "catchAll", key: segment.slice(5, -2), optional: true }; + } + if (segment.startsWith("[...") && segment.endsWith("]") && segment.length > 5) { + return { kind: "catchAll", key: segment.slice(4, -1), optional: false }; + } + if (segment.startsWith("[") && segment.endsWith("]") && segment.length > 2) { + return { kind: "single", key: segment.slice(1, -1) }; + } + if (segment.startsWith(":") && segment.length > 1) { + const marker = segment.at(-1); + if (marker === "+" || marker === "*") { + return { kind: "catchAll", key: segment.slice(1, -1), optional: marker === "*" }; + } + return { kind: "single", key: segment.slice(1) }; + } + return { kind: "static", value: segment }; +} + +function extractRouteParamsFromPath( + pattern: string, + pathname: string, +): Record | null { + const patternParts = splitPathSegments(pattern).map(parseRoutePatternPart); + const pathSegments = splitPathSegments(pathname); + const params: Record = {}; + let pathIndex = 0; + + for (let patternIndex = 0; patternIndex < patternParts.length; patternIndex++) { + const part = patternParts[patternIndex]; + const currentPathSegment = pathSegments[pathIndex]; + + if (part.kind === "static") { + if ( + currentPathSegment === undefined || + decodePathSegment(currentPathSegment) !== part.value + ) { + return null; + } + pathIndex++; + continue; + } + + if (part.kind === "single") { + if (currentPathSegment === undefined) return null; + params[part.key] = decodePathSegment(currentPathSegment); + pathIndex++; + continue; + } + + const remainingPatternParts = patternParts.length - patternIndex - 1; + const captureCount = pathSegments.length - pathIndex - remainingPatternParts; + if (captureCount < 0 || (!part.optional && captureCount === 0)) return null; + if (captureCount > 0) { + params[part.key] = pathSegments + .slice(pathIndex, pathIndex + captureCount) + .map(decodePathSegment); + pathIndex += captureCount; + } + } + + return pathIndex === pathSegments.length ? params : null; +} + +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; @@ -321,21 +432,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 d845a428c..1b2a9a71a 100644 --- a/tests/shims.test.ts +++ b/tests/shims.test.ts @@ -10331,6 +10331,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"); From 22d041000858cc0221dcd37f148a0444bfb442d5 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Sun, 17 May 2026 00:05:11 +1000 Subject: [PATCH 2/2] refactor(router): reuse route-pattern matching for shallow params The shallow dynamic params fix originally duplicated route-pattern parsing inside the router shim. Reuse the existing routePatternParts and matchRoutePattern helpers instead, keeping the __NEXT_DATA__ fallback behavior unchanged. --- packages/vinext/src/shims/router.ts | 74 +---------------------------- 1 file changed, 2 insertions(+), 72 deletions(-) diff --git a/packages/vinext/src/shims/router.ts b/packages/vinext/src/shims/router.ts index d0afbcb4b..7cfb2287e 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"; /** basePath from next.config.js, injected by the plugin at build time */ const __basePath: string = process.env.__NEXT_ROUTER_BASEPATH ?? ""; @@ -300,91 +301,20 @@ function extractRouteParamNames(pattern: string): string[] { return names; } -type RoutePatternPart = - | { kind: "static"; value: string } - | { kind: "single"; key: string } - | { kind: "catchAll"; key: string; optional: boolean }; - type RouteQueryNextData = { page?: string; query?: Record; }; -function decodePathSegment(segment: string): string { - try { - return decodeURIComponent(segment); - } catch { - return segment; - } -} - function splitPathSegments(pathname: string): string[] { return pathname.split("/").filter(Boolean); } -function parseRoutePatternPart(segment: string): RoutePatternPart { - if (segment.startsWith("[[...") && segment.endsWith("]]") && segment.length > 7) { - return { kind: "catchAll", key: segment.slice(5, -2), optional: true }; - } - if (segment.startsWith("[...") && segment.endsWith("]") && segment.length > 5) { - return { kind: "catchAll", key: segment.slice(4, -1), optional: false }; - } - if (segment.startsWith("[") && segment.endsWith("]") && segment.length > 2) { - return { kind: "single", key: segment.slice(1, -1) }; - } - if (segment.startsWith(":") && segment.length > 1) { - const marker = segment.at(-1); - if (marker === "+" || marker === "*") { - return { kind: "catchAll", key: segment.slice(1, -1), optional: marker === "*" }; - } - return { kind: "single", key: segment.slice(1) }; - } - return { kind: "static", value: segment }; -} - function extractRouteParamsFromPath( pattern: string, pathname: string, ): Record | null { - const patternParts = splitPathSegments(pattern).map(parseRoutePatternPart); - const pathSegments = splitPathSegments(pathname); - const params: Record = {}; - let pathIndex = 0; - - for (let patternIndex = 0; patternIndex < patternParts.length; patternIndex++) { - const part = patternParts[patternIndex]; - const currentPathSegment = pathSegments[pathIndex]; - - if (part.kind === "static") { - if ( - currentPathSegment === undefined || - decodePathSegment(currentPathSegment) !== part.value - ) { - return null; - } - pathIndex++; - continue; - } - - if (part.kind === "single") { - if (currentPathSegment === undefined) return null; - params[part.key] = decodePathSegment(currentPathSegment); - pathIndex++; - continue; - } - - const remainingPatternParts = patternParts.length - patternIndex - 1; - const captureCount = pathSegments.length - pathIndex - remainingPatternParts; - if (captureCount < 0 || (!part.optional && captureCount === 0)) return null; - if (captureCount > 0) { - params[part.key] = pathSegments - .slice(pathIndex, pathIndex + captureCount) - .map(decodePathSegment); - pathIndex += captureCount; - } - } - - return pathIndex === pathSegments.length ? params : null; + return matchRoutePattern(splitPathSegments(pathname), routePatternParts(pattern)); } function getRouteQueryFromNextData(