From b9c6d56f747861a287bd69f5959164d3b4dc1e7e Mon Sep 17 00:00:00 2001
From: Dream <1012377328@qq.com>
Date: Fri, 24 Jul 2026 16:58:39 +0800
Subject: [PATCH] fix(@vben/layouts): restore internal scroll navigation
---
.../basic/__tests__/use-layout-scroll.test.ts | 151 ++++++++++++++++++
packages/effects/layouts/src/basic/layout.vue | 7 +-
.../layouts/src/basic/use-layout-scroll.ts | 85 ++++++++++
3 files changed, 242 insertions(+), 1 deletion(-)
create mode 100644 packages/effects/layouts/src/basic/__tests__/use-layout-scroll.test.ts
create mode 100644 packages/effects/layouts/src/basic/use-layout-scroll.ts
diff --git a/packages/effects/layouts/src/basic/__tests__/use-layout-scroll.test.ts b/packages/effects/layouts/src/basic/__tests__/use-layout-scroll.test.ts
new file mode 100644
index 00000000..f4a12e3e
--- /dev/null
+++ b/packages/effects/layouts/src/basic/__tests__/use-layout-scroll.test.ts
@@ -0,0 +1,151 @@
+import type { App } from 'vue';
+import type { NavigationGuard, NavigationHookAfter, Router } from 'vue-router';
+
+import { createApp } from 'vue';
+
+import { ELEMENT_ID_LAYOUT_SCROLL } from '@vben-core/shared/constants';
+
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import { useLayoutScroll } from '../use-layout-scroll';
+
+let activeApp: App | undefined;
+
+function createRouterMock() {
+ let afterHook: NavigationHookAfter | undefined;
+ let beforeGuard: NavigationGuard | undefined;
+ const removeAfterHook = vi.fn();
+ const removeBeforeGuard = vi.fn();
+
+ const router = {
+ afterEach: vi.fn((hook: NavigationHookAfter) => {
+ afterHook = hook;
+ return removeAfterHook;
+ }),
+ beforeEach: vi.fn((guard: NavigationGuard) => {
+ beforeGuard = guard;
+ return removeBeforeGuard;
+ }),
+ } as unknown as Router;
+
+ function getHooks() {
+ if (!afterHook || !beforeGuard) {
+ throw new Error('Router hooks were not registered');
+ }
+ return { afterHook, beforeGuard };
+ }
+
+ return {
+ getHooks,
+ removeAfterHook,
+ removeBeforeGuard,
+ router,
+ };
+}
+
+function createScrollElement() {
+ const element = document.createElement('div');
+ element.id = ELEMENT_ID_LAYOUT_SCROLL;
+ element.scrollTo = vi.fn();
+ document.body.append(element);
+ return element;
+}
+
+function mountLayoutScroll(router: Router) {
+ const host = document.createElement('div');
+ document.body.append(host);
+ activeApp = createApp({
+ setup() {
+ useLayoutScroll(router);
+ return () => null;
+ },
+ });
+ activeApp.mount(host);
+}
+
+async function runBeforeGuard(guard: NavigationGuard) {
+ await guard({} as never, {} as never, vi.fn());
+}
+
+async function runAfterHook(hook: NavigationHookAfter, hash = '') {
+ await hook({ hash } as never, {} as never, undefined);
+}
+
+afterEach(() => {
+ activeApp?.unmount();
+ activeApp = undefined;
+ document.body.innerHTML = '';
+ window.history.replaceState({}, '');
+ vi.restoreAllMocks();
+});
+
+describe('useLayoutScroll', () => {
+ it('should scroll to top after a normal navigation', async () => {
+ window.history.replaceState({ position: 0 }, '');
+ const element = createScrollElement();
+ element.scrollTop = 240;
+ const routerMock = createRouterMock();
+ mountLayoutScroll(routerMock.router);
+ const { afterHook, beforeGuard } = routerMock.getHooks();
+
+ await runBeforeGuard(beforeGuard);
+ window.history.replaceState({ position: 1 }, '');
+ await runAfterHook(afterHook);
+
+ expect(element.scrollTo).toHaveBeenCalledWith({ top: 0 });
+ });
+
+ it('should scroll a hash target into view', async () => {
+ window.history.replaceState({ position: 0 }, '');
+ const element = createScrollElement();
+ const hashTarget = document.createElement('div');
+ hashTarget.id = 'section';
+ hashTarget.scrollIntoView = vi.fn();
+ element.append(hashTarget);
+ const routerMock = createRouterMock();
+ mountLayoutScroll(routerMock.router);
+ const { afterHook } = routerMock.getHooks();
+
+ window.history.replaceState({ position: 1 }, '');
+ await runAfterHook(afterHook, '#section');
+
+ expect(hashTarget.scrollIntoView).toHaveBeenCalledWith({
+ behavior: 'smooth',
+ block: 'start',
+ });
+ expect(element.scrollTo).not.toHaveBeenCalled();
+ });
+
+ it('should restore a saved position on history navigation', async () => {
+ window.history.replaceState({ position: 0 }, '');
+ const element = createScrollElement();
+ element.scrollTop = 240;
+ const routerMock = createRouterMock();
+ mountLayoutScroll(routerMock.router);
+ const { afterHook, beforeGuard } = routerMock.getHooks();
+
+ await runBeforeGuard(beforeGuard);
+ window.history.replaceState({ position: 1 }, '');
+ await runAfterHook(afterHook);
+
+ element.scrollTop = 80;
+ window.history.replaceState({ position: 0 }, '');
+ await runBeforeGuard(beforeGuard);
+ await runAfterHook(afterHook);
+
+ expect(element.scrollTo).toHaveBeenLastCalledWith({ top: 240 });
+ });
+
+ it('should remove router hooks when the scope is disposed', () => {
+ window.history.replaceState({ position: 0 }, '');
+ createScrollElement();
+ const routerMock = createRouterMock();
+ mountLayoutScroll(routerMock.router);
+
+ activeApp?.unmount();
+ activeApp = undefined;
+
+ expect(routerMock.removeBeforeGuard).toHaveBeenCalledOnce();
+ expect(routerMock.removeAfterHook).toHaveBeenCalledOnce();
+ });
+});
diff --git a/packages/effects/layouts/src/basic/layout.vue b/packages/effects/layouts/src/basic/layout.vue
index d8646a6c..59b45e99 100644
--- a/packages/effects/layouts/src/basic/layout.vue
+++ b/packages/effects/layouts/src/basic/layout.vue
@@ -19,6 +19,7 @@ import { cloneDeep, mapTree } from '@vben/utils';
import { VbenAdminLayout } from '@vben-core/layout-ui';
import { VbenBackTop, VbenLogo } from '@vben-core/shadcn-ui';
+import { ELEMENT_ID_LAYOUT_SCROLL } from '@vben-core/shared/constants';
import { Breadcrumb, CheckUpdates, Preferences } from '../widgets';
import { LayoutContent, LayoutContentSpinner } from './content';
@@ -33,6 +34,7 @@ import {
useMixedMenu,
} from './menu';
import { LayoutTabbar } from './tabbar';
+import { useLayoutScroll } from './use-layout-scroll';
defineOptions({ name: 'BasicLayout' });
@@ -69,6 +71,9 @@ const {
const accessStore = useAccessStore();
const timezoneStore = useTimezoneStore();
const { refresh } = useRefresh();
+const layoutScrollTarget = `#${ELEMENT_ID_LAYOUT_SCROLL}`;
+
+useLayoutScroll();
const sidebarTheme = computed(() => {
const dark = isDark.value || preferences.theme.semiDarkSidebar;
@@ -468,7 +473,7 @@ const headerSlots = computed(() => {
@clear-preferences-and-logout="clearPreferencesAndLogout"
/>
-
+
diff --git a/packages/effects/layouts/src/basic/use-layout-scroll.ts b/packages/effects/layouts/src/basic/use-layout-scroll.ts
new file mode 100644
index 00000000..d4ee372c
--- /dev/null
+++ b/packages/effects/layouts/src/basic/use-layout-scroll.ts
@@ -0,0 +1,85 @@
+import type { Router } from 'vue-router';
+
+import { nextTick, onScopeDispose } from 'vue';
+import { useRouter } from 'vue-router';
+
+import { getLayoutScrollElement } from '@vben-core/shared/utils';
+
+type LayoutScrollRouter = Pick;
+
+function getHistoryPosition() {
+ if (typeof window === 'undefined') {
+ return undefined;
+ }
+ const position = (window.history.state as null | { position?: unknown })
+ ?.position;
+ return typeof position === 'number' ? position : undefined;
+}
+
+function getHashTarget(hash: string) {
+ if (typeof document === 'undefined' || !hash.startsWith('#')) {
+ return null;
+ }
+
+ const id = hash.slice(1);
+ try {
+ return document.querySelector(
+ `#${CSS.escape(decodeURIComponent(id))}`,
+ );
+ } catch {
+ return document.querySelector(`#${CSS.escape(id)}`);
+ }
+}
+
+export function useLayoutScroll(router: LayoutScrollRouter = useRouter()) {
+ const scrollPositions = new Map();
+ let currentHistoryPosition = getHistoryPosition();
+ let isHistoryNavigation = false;
+
+ const removeBeforeGuard = router.beforeEach(() => {
+ const scrollElement = getLayoutScrollElement();
+ if (scrollElement && currentHistoryPosition !== undefined) {
+ scrollPositions.set(currentHistoryPosition, scrollElement.scrollTop);
+ }
+
+ const nextHistoryPosition = getHistoryPosition();
+ isHistoryNavigation =
+ currentHistoryPosition !== undefined &&
+ nextHistoryPosition !== undefined &&
+ currentHistoryPosition !== nextHistoryPosition;
+ });
+
+ const removeAfterHook = router.afterEach(async (to, _from, failure) => {
+ const nextHistoryPosition = getHistoryPosition();
+
+ if (!failure) {
+ await nextTick();
+ const scrollElement = getLayoutScrollElement();
+ if (scrollElement) {
+ const savedPosition =
+ isHistoryNavigation && nextHistoryPosition !== undefined
+ ? scrollPositions.get(nextHistoryPosition)
+ : undefined;
+
+ if (savedPosition === undefined) {
+ const hashTarget = getHashTarget(to.hash);
+ if (hashTarget) {
+ hashTarget.scrollIntoView({ behavior: 'smooth', block: 'start' });
+ } else {
+ scrollElement.scrollTo({ top: 0 });
+ }
+ } else {
+ scrollElement.scrollTo({ top: savedPosition });
+ }
+ }
+ }
+
+ currentHistoryPosition = nextHistoryPosition;
+ isHistoryNavigation = false;
+ });
+
+ onScopeDispose(() => {
+ removeBeforeGuard();
+ removeAfterHook();
+ });
+}