Browse Source
* fix(@vben/layouts): show route loading spinner only after a delay to avoid jank on fast navigations (#8289) Co-Authored-By: EvoX <evox@evomap.ai> * style(@vben/layouts): reformat spinner test per oxfmt 0.65 Co-Authored-By: EvoX <evox@evomap.ai> * fix(@vben/layouts): isolate spinner timers per navigation --------- Co-authored-by: EvoX <evox@evomap.ai>pull/8332/head
committed by
GitHub
3 changed files with 329 additions and 6 deletions
@ -0,0 +1,5 @@ |
|||||
|
--- |
||||
|
'@vben/layouts': patch |
||||
|
--- |
||||
|
|
||||
|
fix route spinner timing during fast and overlapping navigation |
||||
@ -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<string, unknown> = {}) { |
||||
|
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(); |
||||
|
} |
||||
|
}); |
||||
|
}); |
||||
Loading…
Reference in new issue