diff --git a/packages/vinext/src/server/app-browser-entry.ts b/packages/vinext/src/server/app-browser-entry.ts index 1c40bcd21..0bfc5ccd5 100644 --- a/packages/vinext/src/server/app-browser-entry.ts +++ b/packages/vinext/src/server/app-browser-entry.ts @@ -33,6 +33,7 @@ import { type CachedRscResponse, type ClientNavigationRenderSnapshot, } from "vinext/shims/navigation"; +import { scrollToHashTargetOnNextFrame } from "vinext/shims/hash-scroll"; import { installWindowNext } from "../client/window-next.js"; import { chunksToReadableStream, @@ -627,37 +628,10 @@ function restoreHydrationNavigationContext( }); } -function decodeHashFragment(fragment: string): string { - try { - return decodeURIComponent(fragment); - } catch { - return fragment; - } -} - -function scrollToHashTarget(hash: string): void { - const fragment = decodeHashFragment(hash.startsWith("#") ? hash.slice(1) : hash); - - requestAnimationFrame(() => { - if (fragment === "" || fragment === "top") { - window.scrollTo(0, 0); - return; - } - - const idElement = document.getElementById(fragment); - if (idElement) { - idElement.scrollIntoView({ behavior: "auto" }); - return; - } - - document.getElementsByName(fragment)[0]?.scrollIntoView({ behavior: "auto" }); - }); -} - function restorePopstateScrollPosition(state: unknown): void { if (!(state && typeof state === "object" && "__vinext_scrollY" in state)) { if (window.location.hash) { - scrollToHashTarget(window.location.hash); + scrollToHashTargetOnNextFrame(window.location.hash); } return; } diff --git a/packages/vinext/src/shims/hash-scroll.ts b/packages/vinext/src/shims/hash-scroll.ts new file mode 100644 index 000000000..e5435f792 --- /dev/null +++ b/packages/vinext/src/shims/hash-scroll.ts @@ -0,0 +1,32 @@ +export function decodeHashFragment(fragment: string): string { + try { + return decodeURIComponent(fragment); + } catch { + // Malformed percent escapes cannot be decoded; keep navigation alive and + // attempt browser-style matching against the raw fragment. + return fragment; + } +} + +export function scrollToHashTarget(hash: string): void { + const fragment = decodeHashFragment(hash.startsWith("#") ? hash.slice(1) : hash); + + if (fragment === "" || fragment === "top") { + window.scrollTo(0, 0); + return; + } + + const idElement = document.getElementById(fragment); + if (idElement) { + idElement.scrollIntoView({ behavior: "auto" }); + return; + } + + document.getElementsByName(fragment)[0]?.scrollIntoView({ behavior: "auto" }); +} + +export function scrollToHashTargetOnNextFrame(hash: string): void { + requestAnimationFrame(() => { + scrollToHashTarget(hash); + }); +} diff --git a/packages/vinext/src/shims/navigation.ts b/packages/vinext/src/shims/navigation.ts index 0a593255e..2fb9dbeed 100644 --- a/packages/vinext/src/shims/navigation.ts +++ b/packages/vinext/src/shims/navigation.ts @@ -29,6 +29,7 @@ import { stripBasePath } from "../utils/base-path.js"; import { ReadonlyURLSearchParams } from "./readonly-url-search-params.js"; import { assertSafeNavigationUrl } from "./url-safety.js"; import { AppRouterContext } from "./internal/app-router-context.js"; +import { scrollToHashTarget } from "./hash-scroll.js"; // ─── Layout segment context ─────────────────────────────────────────────────── // Stores the child segments below the current layout. Each layout wraps its @@ -1101,21 +1102,6 @@ function isHashOnlyChange(href: string): boolean { return isHashOnlyBrowserUrlChange(href, window.location.href, __basePath); } -/** - * Scroll to a hash target element, or to the top if no hash. - */ -function scrollToHash(hash: string): void { - if (!hash || hash === "#") { - window.scrollTo(0, 0); - return; - } - const id = hash.slice(1); - const element = document.getElementById(id); - if (element) { - element.scrollIntoView({ behavior: "auto" }); - } -} - // --------------------------------------------------------------------------- // History method wrappers — suppress notifications for internal updates // --------------------------------------------------------------------------- @@ -1312,7 +1298,7 @@ export async function navigateClientSide( } commitClientNavigationState(); if (scroll) { - scrollToHash(hash); + scrollToHashTarget(hash); } return; } @@ -1349,7 +1335,7 @@ export async function navigateClientSide( if (scroll) { if (hash) { - scrollToHash(hash); + scrollToHashTarget(hash); } else { window.scrollTo(0, 0); } diff --git a/packages/vinext/src/shims/router.ts b/packages/vinext/src/shims/router.ts index 6f8d77c65..e4c766b60 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 { scrollToHashTarget } from "./hash-scroll.js"; /** basePath from next.config.js, injected by the plugin at build time */ const __basePath: string = process.env.__NEXT_ROUTER_BASEPATH ?? ""; @@ -205,16 +206,6 @@ export function isHashOnlyChange(href: string): boolean { return isHashOnlyBrowserUrlChange(href, window.location.href, __basePath); } -/** Scroll to hash target element, or top if no hash */ -function scrollToHash(hash: string): void { - if (!hash || hash === "#") { - window.scrollTo(0, 0); - return; - } - const el = document.getElementById(hash.slice(1)); - if (el) el.scrollIntoView({ behavior: "auto" }); -} - /** Save current scroll position into history state for back/forward restoration */ function saveScrollPosition(): void { const state = window.history.state ?? {}; @@ -684,7 +675,9 @@ export function useRouter(): NextRouter { const hash = resolved.includes("#") ? resolved.slice(resolved.indexOf("#")) : ""; window.history.pushState({}, "", resolved.startsWith("#") ? resolved : full); _lastPathnameAndSearch = window.location.pathname + window.location.search; - scrollToHash(hash); + if (options?.scroll !== false) { + scrollToHashTarget(hash); + } setState(getPathnameAndQuery()); routerEvents.emit("hashChangeComplete", eventUrl, { shallow: options?.shallow ?? false, @@ -708,10 +701,12 @@ export function useRouter(): NextRouter { // Scroll: handle hash target, else scroll to top unless scroll:false const hash = resolved.includes("#") ? resolved.slice(resolved.indexOf("#")) : ""; - if (hash) { - scrollToHash(hash); - } else if (options?.scroll !== false) { - window.scrollTo(0, 0); + if (options?.scroll !== false) { + if (hash) { + scrollToHashTarget(hash); + } else { + window.scrollTo(0, 0); + } } window.dispatchEvent(new CustomEvent("vinext:navigate")); return true; @@ -744,7 +739,9 @@ export function useRouter(): NextRouter { const hash = resolved.includes("#") ? resolved.slice(resolved.indexOf("#")) : ""; window.history.replaceState({}, "", resolved.startsWith("#") ? resolved : full); _lastPathnameAndSearch = window.location.pathname + window.location.search; - scrollToHash(hash); + if (options?.scroll !== false) { + scrollToHashTarget(hash); + } setState(getPathnameAndQuery()); routerEvents.emit("hashChangeComplete", eventUrl, { shallow: options?.shallow ?? false, @@ -767,10 +764,12 @@ export function useRouter(): NextRouter { // Scroll: handle hash target, else scroll to top unless scroll:false const hash = resolved.includes("#") ? resolved.slice(resolved.indexOf("#")) : ""; - if (hash) { - scrollToHash(hash); - } else if (options?.scroll !== false) { - window.scrollTo(0, 0); + if (options?.scroll !== false) { + if (hash) { + scrollToHashTarget(hash); + } else { + window.scrollTo(0, 0); + } } window.dispatchEvent(new CustomEvent("vinext:navigate")); return true; @@ -855,7 +854,7 @@ if (typeof window !== "undefined") { // Hash-only back/forward — no page fetch needed const hashUrl = appUrl + window.location.hash; routerEvents.emit("hashChangeStart", hashUrl, { shallow: false }); - scrollToHash(window.location.hash); + scrollToHashTarget(window.location.hash); routerEvents.emit("hashChangeComplete", hashUrl, { shallow: false }); window.dispatchEvent(new CustomEvent("vinext:navigate")); return; @@ -1009,7 +1008,9 @@ const Router = { const hash = resolved.includes("#") ? resolved.slice(resolved.indexOf("#")) : ""; window.history.pushState({}, "", resolved.startsWith("#") ? resolved : full); _lastPathnameAndSearch = window.location.pathname + window.location.search; - scrollToHash(hash); + if (options?.scroll !== false) { + scrollToHashTarget(hash); + } routerEvents.emit("hashChangeComplete", eventUrl, { shallow: options?.shallow ?? false, }); @@ -1030,10 +1031,12 @@ const Router = { routerEvents.emit("routeChangeComplete", resolved, { shallow: options?.shallow ?? false }); const hash = resolved.includes("#") ? resolved.slice(resolved.indexOf("#")) : ""; - if (hash) { - scrollToHash(hash); - } else if (options?.scroll !== false) { - window.scrollTo(0, 0); + if (options?.scroll !== false) { + if (hash) { + scrollToHashTarget(hash); + } else { + window.scrollTo(0, 0); + } } window.dispatchEvent(new CustomEvent("vinext:navigate")); return true; @@ -1062,7 +1065,9 @@ const Router = { const hash = resolved.includes("#") ? resolved.slice(resolved.indexOf("#")) : ""; window.history.replaceState({}, "", resolved.startsWith("#") ? resolved : full); _lastPathnameAndSearch = window.location.pathname + window.location.search; - scrollToHash(hash); + if (options?.scroll !== false) { + scrollToHashTarget(hash); + } routerEvents.emit("hashChangeComplete", eventUrl, { shallow: options?.shallow ?? false, }); @@ -1082,10 +1087,12 @@ const Router = { routerEvents.emit("routeChangeComplete", resolved, { shallow: options?.shallow ?? false }); const hash = resolved.includes("#") ? resolved.slice(resolved.indexOf("#")) : ""; - if (hash) { - scrollToHash(hash); - } else if (options?.scroll !== false) { - window.scrollTo(0, 0); + if (options?.scroll !== false) { + if (hash) { + scrollToHashTarget(hash); + } else { + window.scrollTo(0, 0); + } } window.dispatchEvent(new CustomEvent("vinext:navigate")); return true; diff --git a/tests/shims.test.ts b/tests/shims.test.ts index d845a428c..2657f9af4 100644 --- a/tests/shims.test.ts +++ b/tests/shims.test.ts @@ -10915,6 +10915,132 @@ describe("Pages Router concurrent navigation", () => { }); }); + it("scrolls hash-only pushes to URI-decoded id targets", async () => { + const previousWindow = (globalThis as any).window; + const previousDocument = (globalThis as any).document; + const originalFetch = globalThis.fetch; + const { win } = createNavWindow(); + (globalThis as any).window = win; + + const target = { scrollIntoView: vi.fn() }; + const getElementById = vi.fn((id: string) => (id === "hello world" ? target : null)); + const getElementsByName = vi.fn(() => []); + (globalThis as any).document = { getElementById, getElementsByName }; + globalThis.fetch = vi.fn(async () => { + throw new Error("hash-only navigations must not fetch page HTML"); + }); + vi.resetModules(); + + try { + const routerModule = await import("../packages/vinext/src/shims/router.js"); + const Router = routerModule.default; + + const result = await Router.push("#hello%20world"); + + expect(result).toBe(true); + expect(getElementById).toHaveBeenCalledWith("hello world"); + expect(getElementsByName).not.toHaveBeenCalled(); + expect(target.scrollIntoView).toHaveBeenCalledWith({ behavior: "auto" }); + } finally { + vi.resetModules(); + if (previousWindow === undefined) { + delete (globalThis as any).window; + } else { + (globalThis as any).window = previousWindow; + } + if (previousDocument === undefined) { + delete (globalThis as any).document; + } else { + (globalThis as any).document = previousDocument; + } + globalThis.fetch = originalFetch; + } + }); + + it("scrolls hash-only pushes to named anchors when no id matches", async () => { + const previousWindow = (globalThis as any).window; + const previousDocument = (globalThis as any).document; + const originalFetch = globalThis.fetch; + const { win } = createNavWindow(); + (globalThis as any).window = win; + + const target = { scrollIntoView: vi.fn() }; + const getElementById = vi.fn(() => null); + const getElementsByName = vi.fn((name: string) => (name === "legacy-anchor" ? [target] : [])); + (globalThis as any).document = { getElementById, getElementsByName }; + globalThis.fetch = vi.fn(async () => { + throw new Error("hash-only navigations must not fetch page HTML"); + }); + vi.resetModules(); + + try { + const routerModule = await import("../packages/vinext/src/shims/router.js"); + const Router = routerModule.default; + + const result = await Router.push("#legacy-anchor"); + + expect(result).toBe(true); + expect(getElementById).toHaveBeenCalledWith("legacy-anchor"); + expect(getElementsByName).toHaveBeenCalledWith("legacy-anchor"); + expect(target.scrollIntoView).toHaveBeenCalledWith({ behavior: "auto" }); + } finally { + vi.resetModules(); + if (previousWindow === undefined) { + delete (globalThis as any).window; + } else { + (globalThis as any).window = previousWindow; + } + if (previousDocument === undefined) { + delete (globalThis as any).document; + } else { + (globalThis as any).document = previousDocument; + } + globalThis.fetch = originalFetch; + } + }); + + it("does not scroll hash-only pushes when scroll is false", async () => { + const previousWindow = (globalThis as any).window; + const previousDocument = (globalThis as any).document; + const originalFetch = globalThis.fetch; + const { win } = createNavWindow(); + (globalThis as any).window = win; + + const target = { scrollIntoView: vi.fn() }; + const getElementById = vi.fn(() => target); + const getElementsByName = vi.fn(() => []); + (globalThis as any).document = { getElementById, getElementsByName }; + globalThis.fetch = vi.fn(async () => { + throw new Error("hash-only navigations must not fetch page HTML"); + }); + vi.resetModules(); + + try { + const routerModule = await import("../packages/vinext/src/shims/router.js"); + const Router = routerModule.default; + + const result = await Router.push("#legacy-anchor", undefined, { scroll: false }); + + expect(result).toBe(true); + expect(getElementById).not.toHaveBeenCalled(); + expect(getElementsByName).not.toHaveBeenCalled(); + expect(target.scrollIntoView).not.toHaveBeenCalled(); + } finally { + vi.resetModules(); + if (previousWindow === undefined) { + delete (globalThis as any).window; + } else { + (globalThis as any).window = previousWindow; + } + if (previousDocument === undefined) { + delete (globalThis as any).document; + } else { + (globalThis as any).document = previousDocument; + } + globalThis.fetch = originalFetch; + } + }); + it("does not strip app-relative targets that start with the basePath segment", async () => { await expectBasePathHashOnlyPush({ browserPath: "/app/app/foo",