diff --git a/.changeset/bright-spinners-wait.md b/.changeset/bright-spinners-wait.md new file mode 100644 index 000000000..5cdf2576c --- /dev/null +++ b/.changeset/bright-spinners-wait.md @@ -0,0 +1,5 @@ +--- +'@vben/layouts': patch +--- + +fix route spinner timing during fast and overlapping navigation diff --git a/packages/effects/layouts/src/basic/content/__tests__/use-content-spinner.test.ts b/packages/effects/layouts/src/basic/content/__tests__/use-content-spinner.test.ts new file mode 100644 index 000000000..e13487d96 --- /dev/null +++ b/packages/effects/layouts/src/basic/content/__tests__/use-content-spinner.test.ts @@ -0,0 +1,270 @@ +import type { App } from 'vue'; +import type { NavigationGuard, NavigationHookAfter, Router } from 'vue-router'; + +import { createApp, nextTick } from 'vue'; + +import { describe, expect, it, vi } from 'vitest'; + +import { useContentSpinner } from '../use-content-spinner'; + +// preferences 是模块级单例,测试里用可变 mock 控制开关 +const mockPreferences = vi.hoisted(() => ({ transition: { loading: true } })); + +vi.mock('@vben/preferences', () => ({ + preferences: mockPreferences, +})); + +// useRouter 从实例注入取 router,测试里用 holder 直接供给 +const routerHolder = vi.hoisted(() => ({ + current: undefined as undefined | { afterEach: unknown; beforeEach: unknown }, +})); + +vi.mock('vue-router', () => ({ + useRouter: () => routerHolder.current, +})); + +const SHOW_DELAY = 200; +const MIN_SHOW_TIME = 500; + +function createRouterMock() { + let afterHook: NavigationHookAfter | undefined; + let beforeGuard: NavigationGuard | undefined; + const router = { + afterEach: vi.fn((hook: NavigationHookAfter) => { + afterHook = hook; + }), + beforeEach: vi.fn((guard: NavigationGuard) => { + beforeGuard = guard; + }), + } as unknown as Router; + + function getHooks() { + if (!afterHook || !beforeGuard) { + throw new Error('Router hooks were not registered'); + } + return { afterHook, beforeGuard }; + } + + return { getHooks, router }; +} + +let activeApp: App | undefined; + +/** 挂载组合式函数并返回对 spinning 的实时读取 */ +function mountSpinner(router: Router) { + routerHolder.current = router as never; + const host = document.createElement('div'); + document.body.append(host); + let spinningRef: undefined | { value: boolean }; + activeApp = createApp({ + setup() { + const { spinning } = useContentSpinner(); + spinningRef = spinning; + return () => null; + }, + }); + activeApp.mount(host); + return () => spinningRef?.value ?? false; +} + +function makeRoute(meta: Record = {}) { + return { meta } as never; +} + +function runBefore(guard: NavigationGuard, route = makeRoute()) { + return guard(route, makeRoute(), vi.fn()); +} + +function runAfter(hook: NavigationHookAfter, route = makeRoute()) { + return hook(route, makeRoute(), undefined); +} + +function cleanup() { + activeApp?.unmount(); + activeApp = undefined; + document.body.innerHTML = ''; + routerHolder.current = undefined; +} + +describe('useContentSpinner', () => { + it('does not show spinner when navigation finishes within showDelay', async () => { + vi.useFakeTimers(); + try { + const routerMock = createRouterMock(); + const isSpinning = mountSpinner(routerMock.router); + const timerBaseline = vi.getTimerCount(); + const { afterHook, beforeGuard } = routerMock.getHooks(); + const route = makeRoute(); + + await runBefore(beforeGuard, route); + // 在 showDelay 之前完成导航 + vi.advanceTimersByTime(SHOW_DELAY - 50); + await runAfter(afterHook, route); + vi.advanceTimersByTime(SHOW_DELAY * 2); + await nextTick(); + + expect(isSpinning()).toBe(false); + // 快速导航不应遗留任何净新增定时器(环境基线之上) + expect(vi.getTimerCount()).toBe(timerBaseline); + } finally { + vi.useRealTimers(); + cleanup(); + } + }); + + it('shows spinner after showDelay and hides it after navigation ends', async () => { + vi.useFakeTimers(); + try { + const routerMock = createRouterMock(); + const isSpinning = mountSpinner(routerMock.router); + const { afterHook, beforeGuard } = routerMock.getHooks(); + const route = makeRoute(); + + await runBefore(beforeGuard, route); + vi.advanceTimersByTime(SHOW_DELAY); + await nextTick(); + expect(isSpinning()).toBe(true); + + // 导航耗时 800ms > minShowTime,afterEach 时立即隐藏 + vi.advanceTimersByTime(800 - SHOW_DELAY); + await runAfter(afterHook, route); + await nextTick(); + expect(isSpinning()).toBe(false); + } finally { + vi.useRealTimers(); + cleanup(); + } + }); + + it('keeps spinner for minShowTime when navigation is slightly slow', async () => { + vi.useFakeTimers(); + try { + const routerMock = createRouterMock(); + const isSpinning = mountSpinner(routerMock.router); + const { afterHook, beforeGuard } = routerMock.getHooks(); + const route = makeRoute(); + + await runBefore(beforeGuard, route); + vi.advanceTimersByTime(SHOW_DELAY); + await nextTick(); + expect(isSpinning()).toBe(true); + + // 导航耗时 300ms,spinner 从 200ms 开始显示,应保留到 700ms + vi.advanceTimersByTime(300 - SHOW_DELAY); + await runAfter(afterHook, route); + await nextTick(); + expect(isSpinning()).toBe(true); + + vi.advanceTimersByTime(MIN_SHOW_TIME - (300 - SHOW_DELAY) - 1); + await nextTick(); + expect(isSpinning()).toBe(true); + + vi.advanceTimersByTime(1); + await nextTick(); + expect(isSpinning()).toBe(false); + } finally { + vi.useRealTimers(); + cleanup(); + } + }); + + it('does not let an older navigation cancel the current show timer', async () => { + vi.useFakeTimers(); + try { + const routerMock = createRouterMock(); + const isSpinning = mountSpinner(routerMock.router); + const { afterHook, beforeGuard } = routerMock.getHooks(); + const from = makeRoute(); + const firstRoute = makeRoute(); + const secondRoute = makeRoute(); + + await beforeGuard(firstRoute, from, vi.fn()); + vi.advanceTimersByTime(SHOW_DELAY / 2); + await beforeGuard(secondRoute, firstRoute, vi.fn()); + + // Vue Router can complete the cancelled navigation after the newer + // navigation has already installed its own show timer. + await afterHook(firstRoute, from, undefined); + vi.advanceTimersByTime(SHOW_DELAY); + await nextTick(); + + expect(isSpinning()).toBe(true); + } finally { + vi.useRealTimers(); + cleanup(); + } + }); + + it('never shows spinner for rapid consecutive navigations', async () => { + vi.useFakeTimers(); + try { + const routerMock = createRouterMock(); + const isSpinning = mountSpinner(routerMock.router); + const { afterHook, beforeGuard } = routerMock.getHooks(); + + // 连续三次快速导航,任意时刻都不应显示 + let route = makeRoute(); + await runBefore(beforeGuard, route); + vi.advanceTimersByTime(100); + await runAfter(afterHook, route); + route = makeRoute(); + await runBefore(beforeGuard, route); + vi.advanceTimersByTime(150); + await runAfter(afterHook, route); + route = makeRoute(); + await runBefore(beforeGuard, route); + vi.advanceTimersByTime(180); + await runAfter(afterHook, route); + vi.advanceTimersByTime(1000); + await nextTick(); + + expect(isSpinning()).toBe(false); + expect(vi.getTimerCount()).toBe(0); + } finally { + vi.useRealTimers(); + cleanup(); + } + }); + + it('skips spinner entirely for routes marked as loaded', async () => { + vi.useFakeTimers(); + try { + const routerMock = createRouterMock(); + const isSpinning = mountSpinner(routerMock.router); + const { afterHook, beforeGuard } = routerMock.getHooks(); + const route = makeRoute({ loaded: true }); + + await runBefore(beforeGuard, route); + vi.advanceTimersByTime(SHOW_DELAY * 2); + await runAfter(afterHook, route); + await nextTick(); + + expect(isSpinning()).toBe(false); + } finally { + vi.useRealTimers(); + cleanup(); + } + }); + + it('skips spinner when transition loading is disabled', async () => { + vi.useFakeTimers(); + try { + mockPreferences.transition.loading = false; + const routerMock = createRouterMock(); + const isSpinning = mountSpinner(routerMock.router); + const { afterHook, beforeGuard } = routerMock.getHooks(); + const route = makeRoute(); + + await runBefore(beforeGuard, route); + vi.advanceTimersByTime(SHOW_DELAY * 2); + await runAfter(afterHook, route); + await nextTick(); + + expect(isSpinning()).toBe(false); + } finally { + mockPreferences.transition.loading = true; + vi.useRealTimers(); + cleanup(); + } + }); +}); diff --git a/packages/effects/layouts/src/basic/content/use-content-spinner.ts b/packages/effects/layouts/src/basic/content/use-content-spinner.ts index dfe3c5366..5382f0520 100644 --- a/packages/effects/layouts/src/basic/content/use-content-spinner.ts +++ b/packages/effects/layouts/src/basic/content/use-content-spinner.ts @@ -3,21 +3,56 @@ import { useRouter } from 'vue-router'; import { preferences } from '@vben/preferences'; +/** + * 内容切换 loading: + * - 延迟显示:导航在 showDelay 内完成时不渲染 spinner, + * 避免快速跳转时闪现半遮罩造成卡顿感(issue #8289); + * - 最小展示:spinner 一旦显示,至少保留 minShowTime,避免一闪而过。 + */ function useContentSpinner() { const spinning = ref(false); const startTime = ref(0); const router = useRouter(); + const showDelay = 200; // 延迟显示时间:更快的导航不显示 loading const minShowTime = 500; // 最小显示时间 const enableLoading = computed(() => preferences.transition.loading); + let hideTimer: null | ReturnType = null; + let navSeq = 0; + const routeSeq = new WeakMap(); + let showTimer: null | { + id: ReturnType; + seq: number; + } = null; + + const clearTimers = () => { + if (hideTimer) { + clearTimeout(hideTimer); + hideTimer = null; + } + if (showTimer) { + clearTimeout(showTimer.id); + showTimer = null; + } + }; + // 结束加载动画 - const onEnd = () => { - if (!enableLoading.value) { + const onEnd = (seq: number | undefined) => { + if (!enableLoading.value || seq !== navSeq) { + return; + } + if (showTimer?.seq === seq) { + clearTimeout(showTimer.id); + showTimer = null; + } + // spinner 尚未显示过(快速导航):直接结束,不闪现 + if (!spinning.value) { return; } const processTime = performance.now() - startTime.value; if (processTime < minShowTime) { - setTimeout(() => { + hideTimer = setTimeout(() => { + hideTimer = null; spinning.value = false; }, minShowTime - processTime); } else { @@ -30,8 +65,21 @@ function useContentSpinner() { if (to.meta.loaded || !enableLoading.value || to.meta.iframeSrc) { return true; } - startTime.value = performance.now(); - spinning.value = true; + clearTimers(); + navSeq += 1; + const seq = navSeq; + routeSeq.set(to, seq); + const id = setTimeout(() => { + if (showTimer?.seq === seq) { + showTimer = null; + } + // 仅当仍是本次导航时才显示,避免陈旧定时器闪现 + if (seq === navSeq && !spinning.value) { + startTime.value = performance.now(); + spinning.value = true; + } + }, showDelay); + showTimer = { id, seq }; return true; }); @@ -40,7 +88,7 @@ function useContentSpinner() { if (to.meta.loaded || !enableLoading.value || to.meta.iframeSrc) { return true; } - onEnd(); + onEnd(routeSeq.get(to)); return true; });