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
30 changes: 2 additions & 28 deletions packages/vinext/src/server/app-browser-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
}
Expand Down
32 changes: 32 additions & 0 deletions packages/vinext/src/shims/hash-scroll.ts
Original file line number Diff line number Diff line change
@@ -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" });
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: Next.js calls scrollIntoView() (no args) rather than scrollIntoView({ behavior: "auto" }). They're functionally equivalent per spec, so this is fine — just noting the difference in case someone ever diffs against Next.js source and wonders.

}

export function scrollToHashTargetOnNextFrame(hash: string): void {
requestAnimationFrame(() => {
scrollToHashTarget(hash);
});
}
20 changes: 3 additions & 17 deletions packages/vinext/src/shims/navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -1312,7 +1298,7 @@ export async function navigateClientSide(
}
commitClientNavigationState();
if (scroll) {
scrollToHash(hash);
scrollToHashTarget(hash);
}
return;
}
Expand Down Expand Up @@ -1349,7 +1335,7 @@ export async function navigateClientSide(

if (scroll) {
if (hash) {
scrollToHash(hash);
scrollToHashTarget(hash);
} else {
window.scrollTo(0, 0);
}
Expand Down
69 changes: 38 additions & 31 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 { 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 ?? "";
Expand Down Expand Up @@ -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 ?? {};
Expand Down Expand Up @@ -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,
Expand All @@ -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;
Expand Down Expand Up @@ -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,
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
});
Expand All @@ -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;
Expand Down Expand Up @@ -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,
});
Expand All @@ -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;
Expand Down
Loading
Loading