Browse Source

fix(@vben/layouts): show route loading spinner only after a delay to avoid jank on fast navigations (#8325)

* 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
俞可 · YU KE 1 month ago
committed by GitHub
parent
commit
384ab70008
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 5
      .changeset/bright-spinners-wait.md
  2. 270
      packages/effects/layouts/src/basic/content/__tests__/use-content-spinner.test.ts
  3. 60
      packages/effects/layouts/src/basic/content/use-content-spinner.ts

5
.changeset/bright-spinners-wait.md

@ -0,0 +1,5 @@
---
'@vben/layouts': patch
---
fix route spinner timing during fast and overlapping navigation

270
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<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();
}
});
});

60
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<typeof setTimeout> = null;
let navSeq = 0;
const routeSeq = new WeakMap<object, number>();
let showTimer: null | {
id: ReturnType<typeof setTimeout>;
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;
});

Loading…
Cancel
Save