From 7d2c98c5ae8ebbba9ee63b8aaa57c4e00625e5da Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Fri, 17 Jul 2026 18:25:05 +0800 Subject: [PATCH] fix(layout): register cleanup hook synchronously (#543) --- .../default/components/DefaultLayout.vue | 12 +- .../__tests__/DefaultLayout.spec.ts | 125 ++++++++++++++++++ 2 files changed, 131 insertions(+), 6 deletions(-) create mode 100644 src/layouts/default/components/__tests__/DefaultLayout.spec.ts diff --git a/src/layouts/default/components/DefaultLayout.vue b/src/layouts/default/components/DefaultLayout.vue index 21de8554..f531d4f4 100644 --- a/src/layouts/default/components/DefaultLayout.vue +++ b/src/layouts/default/components/DefaultLayout.vue @@ -422,6 +422,12 @@ function handlePluginClick() { showPluginQuickAccess.value = false } +// 组件卸载时清理监听 +onBeforeUnmount(() => { + window.removeEventListener(THEME_CUSTOMIZER_CHANGE_EVENT, handleThemeCustomizerChange) + window.removeEventListener(THEME_CUSTOMIZER_OPEN_EVENT, handleThemeCustomizerOpen) +}) + /** 将插件侧边栏菜单合并到对应的导航分组中。 */ function appendPluginSidebarMenus() { for (const { navMenu, section } of filterPluginSidebarNavEntries( @@ -464,12 +470,6 @@ onMounted(async () => { await pluginSidebarNavStore.ensureSidebarNav() appendPluginSidebarMenus() - - // 组件卸载时清理监听 - onBeforeUnmount(() => { - window.removeEventListener(THEME_CUSTOMIZER_CHANGE_EVENT, handleThemeCustomizerChange) - window.removeEventListener(THEME_CUSTOMIZER_OPEN_EVENT, handleThemeCustomizerOpen) - }) }) diff --git a/src/layouts/default/components/__tests__/DefaultLayout.spec.ts b/src/layouts/default/components/__tests__/DefaultLayout.spec.ts new file mode 100644 index 00000000..e06a09e5 --- /dev/null +++ b/src/layouts/default/components/__tests__/DefaultLayout.spec.ts @@ -0,0 +1,125 @@ +import DefaultLayout from '@/layouts/default/components/DefaultLayout.vue' +import { + THEME_CUSTOMIZER_CHANGE_EVENT, + THEME_CUSTOMIZER_OPEN_EVENT, +} from '@/composables/useThemeCustomizer' +import { flushPromises, shallowMount } from '@vue/test-utils' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + emptyComponent: { template: '
' }, + ensureSidebarNav: vi.fn(), +})) + +vi.mock('@layouts/components/VerticalNavLayout.vue', () => ({ default: mocks.emptyComponent })) +vi.mock('@layouts/components/VerticalNavLink.vue', () => ({ default: mocks.emptyComponent })) +vi.mock('@layouts/components/VerticalNavSectionTitle.vue', () => ({ default: mocks.emptyComponent })) +vi.mock('@/components/agent/AgentAssistantWidget.vue', () => ({ default: mocks.emptyComponent })) +vi.mock('@/components/misc/ThemeLogoMark.vue', () => ({ default: mocks.emptyComponent })) +vi.mock('@/components/theme/ThemeCustomizer.vue', () => ({ default: mocks.emptyComponent })) +vi.mock('@/layouts/default/components/Footer.vue', () => ({ default: mocks.emptyComponent })) +vi.mock('@/layouts/default/components/HeaderTab.vue', () => ({ default: mocks.emptyComponent })) +vi.mock('@/layouts/default/components/OfflinePage.vue', () => ({ default: mocks.emptyComponent })) +vi.mock('@/layouts/default/components/QuickAccess.vue', () => ({ default: mocks.emptyComponent })) +vi.mock('@/layouts/default/components/SearchBar.vue', () => ({ default: mocks.emptyComponent })) +vi.mock('@/layouts/default/components/ShortcutBar.vue', () => ({ default: mocks.emptyComponent })) +vi.mock('@/layouts/default/components/UserNotification.vue', () => ({ default: mocks.emptyComponent })) +vi.mock('@/layouts/default/components/UserProfile.vue', () => ({ default: mocks.emptyComponent })) + +vi.mock('@/stores', () => ({ + useGlobalSettingsStore: () => ({ get: vi.fn(() => false) }), + usePluginSidebarNavStore: () => ({ + ensureSidebarNav: mocks.ensureSidebarNav, + items: [], + }), + useUserStore: () => ({ permissions: [], superUser: true }), +})) + +vi.mock('@/router/i18n-menu', () => ({ getNavMenus: () => [] })) +vi.mock('@/utils/pluginSidebarNav', () => ({ filterPluginSidebarNavEntries: () => [] })) +vi.mock('@/utils/permission', async importOriginal => ({ + ...(await importOriginal()), + buildUserPermissionContext: () => ({}), + filterItemsByPermission: (items: T[]) => items, + filterMenusByPermission: () => [], + hasItemPermission: () => true, + hasPermission: () => true, +})) +vi.mock('@/composables/useOfflineStatus', () => ({ + useGlobalOfflineStatus: () => ({ isOffline: { value: false } }), +})) +vi.mock('@/composables/usePullDownGesture', () => ({ + usePullDownGesture: () => ({ + config: { + MAX_PULL_DISTANCE: 220, + SHOW_INDICATOR: 80, + TRIGGER_THRESHOLD: 140, + }, + contentTransform: { value: '' }, + contentTransition: { value: '' }, + indicatorOpacity: { value: 0 }, + indicatorRotation: { value: 0 }, + indicatorTransform: { value: '' }, + pullDistance: { value: 0 }, + showPullIndicator: { value: false }, + }), +})) +vi.mock('@/composables/usePWA', () => ({ usePWA: () => ({ appMode: { value: false } }) })) +vi.mock('vue-i18n', async importOriginal => ({ + ...(await importOriginal()), + useI18n: () => ({ t: (key: string) => key }), +})) +vi.mock('vue-router', async importOriginal => ({ + ...(await importOriginal()), + useRoute: () => ({ path: '/' }), + useRouter: () => ({ push: vi.fn() }), +})) +vi.mock('vuetify', async importOriginal => ({ + ...(await importOriginal()), + useDisplay: () => ({ mdAndDown: { value: false } }), +})) + +describe('DefaultLayout', () => { + beforeEach(() => { + mocks.ensureSidebarNav.mockReset() + }) + + it('removes theme customizer listeners while sidebar loading is still pending', async () => { + let resolveSidebarNav!: () => void + mocks.ensureSidebarNav.mockImplementation( + () => + new Promise(resolve => { + resolveSidebarNav = resolve + }), + ) + const addEventListener = vi.spyOn(window, 'addEventListener') + const removeEventListener = vi.spyOn(window, 'removeEventListener') + const wrapper = shallowMount(DefaultLayout, { + global: { + stubs: { + IconBtn: mocks.emptyComponent, + RouterLink: mocks.emptyComponent, + }, + }, + }) + await flushPromises() + + const changeHandler = addEventListener.mock.calls.find( + ([event]) => String(event) === THEME_CUSTOMIZER_CHANGE_EVENT, + )?.[1] + const openHandler = addEventListener.mock.calls.find( + ([event]) => String(event) === THEME_CUSTOMIZER_OPEN_EVENT, + )?.[1] + + expect(changeHandler).toBeTypeOf('function') + expect(openHandler).toBeTypeOf('function') + + wrapper.unmount() + + expect(removeEventListener).toHaveBeenCalledWith(THEME_CUSTOMIZER_CHANGE_EVENT, changeHandler) + expect(removeEventListener).toHaveBeenCalledWith(THEME_CUSTOMIZER_OPEN_EVENT, openHandler) + + resolveSidebarNav() + await flushPromises() + }) +})