Skip to content

Commit e633400

Browse files
committed
fix(pages-router): preserve dynamic href history state
1 parent ccf42ba commit e633400

3 files changed

Lines changed: 69 additions & 27 deletions

File tree

packages/vinext/src/shims/router.ts

Lines changed: 33 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -600,11 +600,13 @@ function normalizeHydrationNavigationUrl(url: string): string {
600600

601601
class HrefInterpolationError extends Error {}
602602

603-
function interpolateCurrentDynamicRoute(resolved: string): string {
604-
if (typeof window === "undefined") return resolved;
603+
type CurrentDynamicRoute = { href: string; as: string };
604+
605+
function resolveCurrentDynamicRoute(resolved: string): CurrentDynamicRoute | null {
606+
if (typeof window === "undefined") return null;
605607

606608
const routePattern = window.__NEXT_DATA__?.page;
607-
if (!routePattern || extractRouteParamNames(routePattern).length === 0) return resolved;
609+
if (!routePattern || extractRouteParamNames(routePattern).length === 0) return null;
608610

609611
try {
610612
const target = new URL(resolved, "http://vinext.local");
@@ -614,14 +616,14 @@ function interpolateCurrentDynamicRoute(resolved: string): string {
614616
target.origin !== "http://vinext.local" &&
615617
target.origin !== currentOrigin
616618
) {
617-
return resolved;
619+
return null;
618620
}
619621
const visiblePath = stripBasePath(window.location.pathname, __basePath);
620622
const visibleLocale = getLocalePathPrefix(visiblePath, window.__VINEXT_LOCALES__);
621623
const routePath = visibleLocale
622624
? visiblePath.slice(visibleLocale.length + 1) || "/"
623625
: visiblePath;
624-
if (extractRouteParamsFromPath(routePattern, routePath) === null) return resolved;
626+
if (extractRouteParamsFromPath(routePattern, routePath) === null) return null;
625627

626628
const query = parseQueryString(target.search);
627629
const missingParams = routePatternParts(routePattern)
@@ -641,7 +643,7 @@ function interpolateCurrentDynamicRoute(resolved: string): string {
641643
}
642644

643645
const routeParams = getRouteParamsFromQuery(routePattern, query);
644-
if (!routeParams) return resolved;
646+
if (!routeParams) return null;
645647

646648
const encodedRouteParams = Object.fromEntries(
647649
Object.entries(routeParams).map(([key, value]) => [
@@ -650,19 +652,21 @@ function interpolateCurrentDynamicRoute(resolved: string): string {
650652
]),
651653
);
652654
const pathname = fillRoutePatternSegments(routePattern, encodedRouteParams);
653-
if (!pathname) return resolved;
655+
if (!pathname) return null;
654656

655657
const targetLocale = getLocalePathPrefix(target.pathname, window.__VINEXT_LOCALES__);
658+
const hrefPathname = targetLocale ? `/${targetLocale}${routePattern}` : routePattern;
659+
const href = hrefPathname + target.search + target.hash;
656660
target.pathname = targetLocale ? `/${targetLocale}${pathname}` : pathname;
657661
for (const paramName of extractRouteParamNames(routePattern)) {
658662
target.searchParams.delete(paramName);
659663
}
660-
return target.href.slice(target.origin.length);
664+
return { href, as: target.href.slice(target.origin.length) };
661665
} catch (error) {
662666
if (error instanceof HrefInterpolationError) {
663667
throw error;
664668
}
665-
return resolved;
669+
return null;
666670
}
667671
}
668672

@@ -3235,9 +3239,19 @@ async function performNavigation(
32353239
typeof url.query === "object" &&
32363240
Object.keys(url.query).length > 0) ||
32373241
(typeof url.search === "string" && url.search.length > 0))));
3242+
let inheritedRouteHref: string | undefined;
32383243
if (inheritsCurrentPath) {
3239-
resolved = interpolateCurrentDynamicRoute(resolved);
3240-
resolvedRoute = interpolateCurrentDynamicRoute(resolvedRoute);
3244+
const routeMatchesTarget = resolvedRoute === resolved;
3245+
const resolvedDynamicRoute = resolveCurrentDynamicRoute(resolved);
3246+
if (resolvedDynamicRoute) resolved = resolvedDynamicRoute.as;
3247+
3248+
const internalDynamicRoute = routeMatchesTarget
3249+
? resolvedDynamicRoute
3250+
: resolveCurrentDynamicRoute(resolvedRoute);
3251+
if (internalDynamicRoute) {
3252+
resolvedRoute = internalDynamicRoute.as;
3253+
inheritedRouteHref = internalDynamicRoute.href;
3254+
}
32413255
}
32423256

32433257
// External URLs — delegate to browser (unless same-origin)
@@ -3435,7 +3449,11 @@ async function performNavigation(
34353449
const navStateOptions: { locale?: string; shallow: boolean } = { shallow };
34363450
if (navigationLocale !== undefined) navStateOptions.locale = navigationLocale;
34373451
const resolvedNoHash = stripHash(resolved);
3438-
const resolvedRouteNoHash = stripHash(interpolatedRoute);
3452+
const resolvedRouteNoHash = stripHash(
3453+
inheritedRouteHref
3454+
? normalizePathTrailingSlash(inheritedRouteHref, __trailingSlash)
3455+
: interpolatedRoute,
3456+
);
34393457
const navState = {
34403458
url: resolvedRouteNoHash,
34413459
as: resolvedNoHash,
@@ -3991,7 +4009,9 @@ function handlePagesRouterPopState(e: PopStateEvent): void {
39914009
typeof state.as === "string" &&
39924010
state.url !== state.as
39934011
) {
3994-
return normalizePathTrailingSlash(withBasePath(state.url, __basePath), __trailingSlash);
4012+
const dynamicRoute = interpolateDynamicRouteHref(state.url, state.as);
4013+
const routeUrl = dynamicRoute?.href || state.url;
4014+
return normalizePathTrailingSlash(withBasePath(routeUrl, __basePath), __trailingSlash);
39954015
}
39964016
return browserUrl;
39974017
})();

tests/e2e/pages-router/link-advanced.spec.ts

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -446,30 +446,31 @@ test.describe("Link href/as bracket-pattern interpolation (Pages Router)", () =>
446446
expect(sawConcrete).toBe(true);
447447
});
448448

449-
// popstate (back/forward) reads `state.url` (set on push) as the route URL
450-
// to fetch. If push stamped the bracket pattern into history state, forward
451-
// traversal would re-issue the unservable URL even though the original push
452-
// worked. Asserts state.url was interpolated, not the raw pattern.
453-
test("forward popstate after dynamic Router.push fetches concrete URL", async ({ page }) => {
454-
await page.goto(`${BASE}/link-test`);
449+
// Query-only navigation preserves Next.js' bracket-pattern `state.url`, while
450+
// popstate still has to fetch and render the concrete route on traversal.
451+
test("forward popstate resolves query-only dynamic route history", async ({ page }) => {
452+
await page.goto(`${BASE}/posts/1`);
455453
await page.waitForFunction(() => (window as any).__VINEXT_ROOT__);
456454

457-
// Push the dynamic destination, then go back to /link-test.
455+
// Push the query-only destination, then traverse away from it.
458456
await page.evaluate(() => {
459457
const router = (window as any).next?.router;
460-
return router?.push("/posts/[id]", "/posts/11");
458+
return router?.push({ query: { id: "2" } });
461459
});
462-
await expect(page.locator('[data-testid="post-title"]')).toHaveText("Post: 11");
460+
await expect(page.locator('[data-testid="post-title"]')).toHaveText("Post: 2");
461+
await expect
462+
.poll(() => page.evaluate(() => ({ url: history.state?.url, as: history.state?.as })))
463+
.toEqual({ url: "/posts/[id]?id=2", as: "/posts/2" });
463464
await page.goBack();
464-
await expect(page).toHaveURL(`${BASE}/link-test`);
465+
await expect(page.locator('[data-testid="post-title"]')).toHaveText("Post: 1");
465466

466467
// Start capturing AFTER the back, so requests are scoped to the forward.
467468
const requests: string[] = [];
468469
page.on("request", (req) => requests.push(req.url()));
469470
await page.goForward();
470471

471-
await expect(page.locator('[data-testid="post-title"]')).toHaveText("Post: 11");
472-
expect(page.url()).toBe(`${BASE}/posts/11`);
472+
await expect(page.locator('[data-testid="post-title"]')).toHaveText("Post: 2");
473+
expect(page.url()).toBe(`${BASE}/posts/2`);
473474

474475
const routingRequests = requests.filter(
475476
(u) => /\/_next\/data\//.test(u) || /\/posts\/(?!.*\.tsx)/.test(u),

tests/shims.test.ts

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17939,14 +17939,23 @@ describe("Pages Router concurrent navigation", () => {
1793917939
async function expectPagesRouterPushTrailingSlashNormalization({
1794017940
target,
1794117941
expectedBrowserUrl,
17942+
dynamicRoute,
17943+
expectedStateUrl,
1794217944
}: {
17943-
target: string;
17945+
target: string | { query: Record<string, string> };
1794417946
expectedBrowserUrl: string;
17947+
dynamicRoute?: { page: string; pathname: string };
17948+
expectedStateUrl?: string;
1794517949
}): Promise<void> {
1794617950
const previousWindow = (globalThis as any).window;
1794717951
const previousTrailingSlash = process.env.__VINEXT_TRAILING_SLASH;
1794817952
const originalFetch = globalThis.fetch;
1794917953
const { win } = createNavWindow();
17954+
if (dynamicRoute) {
17955+
win.location.pathname = dynamicRoute.pathname;
17956+
win.location.href = `http://localhost${dynamicRoute.pathname}`;
17957+
win.__NEXT_DATA__.page = dynamicRoute.page;
17958+
}
1795017959
(globalThis as any).window = win;
1795117960
process.env.__VINEXT_TRAILING_SLASH = "true";
1795217961
vi.resetModules();
@@ -17965,7 +17974,10 @@ describe("Pages Router concurrent navigation", () => {
1796517974
// History state now follows Next.js shape ({ url, as, options, __N, key });
1796617975
// assert via partial match so test stays focused on URL normalization.
1796717976
expect(win.history.pushState).toHaveBeenCalledWith(
17968-
expect.objectContaining({ __N: true }),
17977+
expect.objectContaining({
17978+
__N: true,
17979+
...(expectedStateUrl ? { url: expectedStateUrl } : {}),
17980+
}),
1796917981
"",
1797017982
expectedBrowserUrl,
1797117983
);
@@ -17999,6 +18011,15 @@ describe("Pages Router concurrent navigation", () => {
1799918011
});
1800018012
});
1800118013

18014+
it("normalizes query-only dynamic route history when trailingSlash is true", async () => {
18015+
await expectPagesRouterPushTrailingSlashNormalization({
18016+
target: { query: { id: "2" } },
18017+
dynamicRoute: { page: "/posts/[id]", pathname: "/posts/1" },
18018+
expectedBrowserUrl: "/posts/2/",
18019+
expectedStateUrl: "/posts/[id]/?id=2",
18020+
});
18021+
});
18022+
1800218023
it("Pages Router push strips file-looking path slashes when trailingSlash is true", async () => {
1800318024
await expectPagesRouterPushTrailingSlashNormalization({
1800418025
target: "/catch-all/hello.world/",

0 commit comments

Comments
 (0)