From f2ecc6af732efdf41b2a5d2b926e1015ac422e54 Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Sun, 23 Aug 2026 19:22:50 +0800 Subject: [PATCH] fix: prevent background timer registration after stop (#701) --- .../__tests__/useBackgroundRefresh.spec.ts | 327 +++++++++++++++ .../__tests__/useBackgroundSse.spec.ts | 372 ++++++++++++++++++ src/composables/useBackground.ts | 13 +- vite.config.ts | 7 + 4 files changed, 716 insertions(+), 3 deletions(-) create mode 100644 src/composables/__tests__/useBackgroundRefresh.spec.ts create mode 100644 src/composables/__tests__/useBackgroundSse.spec.ts diff --git a/src/composables/__tests__/useBackgroundRefresh.spec.ts b/src/composables/__tests__/useBackgroundRefresh.spec.ts new file mode 100644 index 00000000..df25bd5d --- /dev/null +++ b/src/composables/__tests__/useBackgroundRefresh.spec.ts @@ -0,0 +1,327 @@ +import { useBackground } from '@/composables/useBackground' +import { flushPromises, mount, type VueWrapper } from '@vue/test-utils' +import { defineComponent, h, nextTick, ref } from 'vue' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + addBackgroundTimer: vi.fn(), + removeBackgroundTimer: vi.fn(), +})) + +vi.mock('@/utils/backgroundManager', () => ({ + addBackgroundTimer: mocks.addBackgroundTimer, + removeBackgroundTimer: mocks.removeBackgroundTimer, +})) + +const wrappers: VueWrapper[] = [] + +function deferred() { + let resolve!: (value: T | PromiseLike) => void + let reject!: (reason?: unknown) => void + const promise = new Promise((promiseResolve, promiseReject) => { + resolve = promiseResolve + reject = promiseReject + }) + + return { promise, resolve, reject } +} + +async function settle() { + await nextTick() + await flushPromises() + await nextTick() +} + +function mountComposable(createComposable: () => T) { + let result!: T + const Harness = defineComponent({ + name: 'UseBackgroundRefreshHarness', + setup() { + result = createComposable() + return () => h('div') + }, + }) + const wrapper = mount(Harness) + wrappers.push(wrapper) + + return { result, wrapper } +} + +describe('useBackground useTimer', () => { + beforeEach(() => { + mocks.addBackgroundTimer.mockReset() + mocks.removeBackgroundTimer.mockReset() + }) + + afterEach(() => { + for (const wrapper of wrappers.splice(0)) wrapper.unmount() + }) + + it('在组件挂载后注册带完整选项的后台定时器', async () => { + const callback = vi.fn() + mountComposable(() => + useBackground().useTimer('timer-a', callback, 1000, { + runInBackground: true, + skipInitialRun: true, + }), + ) + + await nextTick() + + expect(mocks.addBackgroundTimer).toHaveBeenCalledOnce() + expect(mocks.addBackgroundTimer).toHaveBeenCalledWith('timer-a', callback, 1000, { + runInBackground: true, + skipInitialRun: true, + }) + }) + + it('在组件卸载时移除后台定时器', async () => { + const { wrapper } = mountComposable(() => useBackground().useTimer('timer-b', vi.fn(), 2000)) + await nextTick() + mocks.removeBackgroundTimer.mockClear() + + wrapper.unmount() + + expect(mocks.removeBackgroundTimer).toHaveBeenCalledOnce() + expect(mocks.removeBackgroundTimer).toHaveBeenCalledWith('timer-b') + }) + + it('暴露手动移除定时器的操作', async () => { + const { result } = mountComposable(() => useBackground().useTimer('timer-c', vi.fn(), 3000)) + await nextTick() + mocks.removeBackgroundTimer.mockClear() + + result.remove() + + expect(mocks.removeBackgroundTimer).toHaveBeenCalledOnce() + expect(mocks.removeBackgroundTimer).toHaveBeenCalledWith('timer-c') + }) +}) + +describe('useBackground useDataRefresh', () => { + beforeEach(() => { + mocks.addBackgroundTimer.mockReset() + mocks.removeBackgroundTimer.mockReset() + }) + + afterEach(() => { + for (const wrapper of wrappers.splice(0)) wrapper.unmount() + }) + + it('immediate 模式先完成首轮加载,再注册跳过初始执行的定时器', async () => { + const loadData = vi.fn().mockResolvedValue(undefined) + mountComposable(() => useBackground().useDataRefresh('data-a', loadData, 4000, true)) + + expect(loadData).toHaveBeenCalledOnce() + expect(mocks.addBackgroundTimer).not.toHaveBeenCalled() + + await settle() + + expect(mocks.addBackgroundTimer).toHaveBeenCalledWith('data-a', expect.any(Function), 4000, { + runInBackground: false, + skipInitialRun: true, + }) + }) + + it('非 immediate 模式直接注册定时器且不执行首轮加载', async () => { + const loadData = vi.fn().mockResolvedValue(undefined) + mountComposable(() => useBackground().useDataRefresh('data-b', loadData, 5000, false)) + + await nextTick() + + expect(loadData).not.toHaveBeenCalled() + expect(mocks.addBackgroundTimer).toHaveBeenCalledWith('data-b', expect.any(Function), 5000, { + runInBackground: false, + skipInitialRun: true, + }) + }) + + it('并发 refresh 只允许一个加载执行,并在完成后释放 loading', async () => { + const pending = deferred() + const loadData = vi.fn(() => pending.promise) + const { result } = mountComposable(() => useBackground().useDataRefresh('data-c', loadData, 6000, false)) + await nextTick() + + const firstRefresh = result.refresh() + const secondRefresh = result.refresh() + + expect(loadData).toHaveBeenCalledOnce() + expect(result.loading.value).toBe(true) + + pending.resolve() + await Promise.all([firstRefresh, secondRefresh]) + + expect(result.loading.value).toBe(false) + }) + + it('收口加载错误并恢复 loading 状态', async () => { + const error = new Error('load failed') + const loadData = vi.fn().mockRejectedValue(error) + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined) + const { result } = mountComposable(() => useBackground().useDataRefresh('data-d', loadData, 7000, false)) + await nextTick() + + await expect(result.refresh()).resolves.toBeUndefined() + + expect(consoleError).toHaveBeenCalledWith('数据刷新失败 [data-d]:', error) + expect(result.loading.value).toBe(false) + }) + + it('暴露 stop 操作并在卸载时清理定时器', async () => { + const { result, wrapper } = mountComposable(() => useBackground().useDataRefresh('data-e', vi.fn(), 8000, false)) + await nextTick() + mocks.removeBackgroundTimer.mockClear() + + result.stop() + expect(mocks.removeBackgroundTimer).toHaveBeenCalledWith('data-e') + + mocks.removeBackgroundTimer.mockClear() + wrapper.unmount() + expect(mocks.removeBackgroundTimer).toHaveBeenCalledOnce() + expect(mocks.removeBackgroundTimer).toHaveBeenCalledWith('data-e') + }) + + it('首轮异步加载未完成时卸载,完成后不得重新注册定时器', async () => { + const initialLoad = deferred() + const loadData = vi.fn(() => initialLoad.promise) + const { wrapper } = mountComposable(() => useBackground().useDataRefresh('data-race', loadData, 9000, true)) + + expect(loadData).toHaveBeenCalledOnce() + wrapper.unmount() + + initialLoad.resolve() + await settle() + + expect(mocks.addBackgroundTimer).not.toHaveBeenCalled() + }) + + it('首轮异步加载未完成时 stop,完成后不得重新注册定时器', async () => { + const initialLoad = deferred() + const loadData = vi.fn(() => initialLoad.promise) + const { result } = mountComposable(() => useBackground().useDataRefresh('data-stop-race', loadData, 9000, true)) + + expect(loadData).toHaveBeenCalledOnce() + result.stop() + + initialLoad.resolve() + await settle() + + expect(mocks.addBackgroundTimer).not.toHaveBeenCalled() + }) +}) + +describe('useBackground useConditionalDataRefresh', () => { + beforeEach(() => { + mocks.addBackgroundTimer.mockReset() + mocks.removeBackgroundTimer.mockReset() + }) + + afterEach(() => { + for (const wrapper of wrappers.splice(0)) wrapper.unmount() + }) + + it.each([ + [true, true, true], + [true, false, true], + [false, true, false], + [false, false, false], + ])('根据初始 condition=%s 决定是否注册定时器(immediate=%s)', async (conditionValue, immediate, shouldStart) => { + const condition = ref(conditionValue) + const { result } = mountComposable(() => + useBackground().useConditionalDataRefresh('conditional-initial', vi.fn(), condition, 1000, immediate), + ) + + await nextTick() + + expect(mocks.addBackgroundTimer).toHaveBeenCalledTimes(shouldStart ? 1 : 0) + expect(result.isActive.value).toBe(shouldStart) + if (shouldStart) { + expect(mocks.addBackgroundTimer).toHaveBeenCalledWith('conditional-initial', expect.any(Function), 1000, { + runInBackground: false, + skipInitialRun: !immediate, + }) + } + }) + + it('随 condition 切换启动和停止定时器', async () => { + const condition = ref(false) + const { result } = mountComposable(() => + useBackground().useConditionalDataRefresh('conditional-toggle', vi.fn(), condition), + ) + await nextTick() + + condition.value = true + await nextTick() + expect(mocks.addBackgroundTimer).toHaveBeenCalledOnce() + expect(result.isActive.value).toBe(true) + + condition.value = false + await nextTick() + expect(mocks.removeBackgroundTimer).toHaveBeenCalledOnce() + expect(mocks.removeBackgroundTimer).toHaveBeenCalledWith('conditional-toggle') + expect(result.isActive.value).toBe(false) + }) + + it('start 和 stop 具有幂等性', async () => { + const condition = ref(true) + const { result } = mountComposable(() => + useBackground().useConditionalDataRefresh('conditional-idempotent', vi.fn(), condition), + ) + await nextTick() + + result.stop() + mocks.addBackgroundTimer.mockClear() + mocks.removeBackgroundTimer.mockClear() + + result.start() + result.start() + expect(mocks.addBackgroundTimer).toHaveBeenCalledOnce() + + result.stop() + result.stop() + expect(mocks.removeBackgroundTimer).toHaveBeenCalledOnce() + }) + + it('仅在 condition 为 true 时允许 refresh,并保持单飞行为', async () => { + const condition = ref(false) + const pending = deferred() + const loadData = vi.fn(() => pending.promise) + const { result } = mountComposable(() => + useBackground().useConditionalDataRefresh('conditional-refresh', loadData, condition, 1000, false), + ) + await nextTick() + + await result.refresh() + expect(loadData).not.toHaveBeenCalled() + + condition.value = true + const firstRefresh = result.refresh() + const secondRefresh = result.refresh() + expect(loadData).toHaveBeenCalledOnce() + expect(result.loading.value).toBe(true) + + pending.resolve() + await Promise.all([firstRefresh, secondRefresh]) + expect(result.loading.value).toBe(false) + }) + + it('收口错误并在卸载时清理活动定时器', async () => { + const condition = ref(true) + const error = new Error('conditional load failed') + const loadData = vi.fn().mockRejectedValue(error) + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined) + const { result, wrapper } = mountComposable(() => + useBackground().useConditionalDataRefresh('conditional-error', loadData, condition), + ) + await nextTick() + + await expect(result.refresh()).resolves.toBeUndefined() + expect(consoleError).toHaveBeenCalledWith('条件数据刷新失败 [conditional-error]:', error) + expect(result.loading.value).toBe(false) + + mocks.removeBackgroundTimer.mockClear() + wrapper.unmount() + expect(mocks.removeBackgroundTimer).toHaveBeenCalledOnce() + expect(mocks.removeBackgroundTimer).toHaveBeenCalledWith('conditional-error') + }) +}) diff --git a/src/composables/__tests__/useBackgroundSse.spec.ts b/src/composables/__tests__/useBackgroundSse.spec.ts new file mode 100644 index 00000000..89e3f391 --- /dev/null +++ b/src/composables/__tests__/useBackgroundSse.spec.ts @@ -0,0 +1,372 @@ +import { mount, type VueWrapper } from '@vue/test-utils' +import { defineComponent, h, ref, type Ref } from 'vue' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useBackground } from '@/composables/useBackground' + +type SSEStatus = 'idle' | 'connecting' | 'open' | 'error' | 'closed' +type StatusListener = (status: SSEStatus) => void +type MessageListener = (event: MessageEvent) => void + +interface MockManager { + url: string + options?: Record + readyState: number + addStatusListener: ReturnType + removeStatusListener: ReturnType + addMessageListener: ReturnType + removeMessageListener: ReturnType + forceReconnect: ReturnType + emitStatus: (status: SSEStatus) => void + closed: boolean +} + +const { closeIndependentManager, getCurrentLocaleMock, getIndependentManager, managers } = vi.hoisted(() => { + const managers = new Map() + + const createManager = (url: string, options?: Record): MockManager => { + let status: SSEStatus = 'idle' + let statusListener: StatusListener | undefined + let messageListener: MessageListener | undefined + + const manager = { + url, + options, + readyState: 0, + closed: false, + addStatusListener: vi.fn((_id: string, listener: StatusListener, emitCurrent = true) => { + statusListener = listener + if (emitCurrent) listener(status) + }), + removeStatusListener: vi.fn(() => { + statusListener = undefined + }), + addMessageListener: vi.fn((_id: string, listener: MessageListener) => { + messageListener = listener + }), + removeMessageListener: vi.fn(() => { + messageListener = undefined + }), + forceReconnect: vi.fn(), + emitStatus: (nextStatus: SSEStatus) => { + status = nextStatus + statusListener?.(nextStatus) + }, + } + + void messageListener + return manager + } + + const getIndependentManager = vi.fn((url: string, listenerId: string, options?: Record) => { + const key = `${url}::${listenerId}` + const existing = managers.get(key) + if (existing) return existing + + const manager = createManager(url, options) + managers.set(key, manager) + return manager + }) + + const closeIndependentManager = vi.fn((url: string, listenerId: string) => { + const key = `${url}::${listenerId}` + const manager = managers.get(key) + if (manager) manager.closed = true + managers.delete(key) + }) + + return { + closeIndependentManager, + getCurrentLocaleMock: vi.fn(() => 'zh-CN'), + getIndependentManager, + managers, + } +}) + +vi.mock('@/utils/sseManager', () => ({ + sseManagerSingleton: { + closeIndependentManager, + getIndependentManager, + }, +})) + +vi.mock('@/plugins/i18n', () => ({ + getCurrentLocale: getCurrentLocaleMock, +})) + +type Background = ReturnType +type UseSSEResult = ReturnType +type UseDelayedSSEResult = ReturnType +type UseProgressSSEResult = ReturnType + +const wrappers: VueWrapper[] = [] + +function managerKey(url: string, listenerId: string) { + return `${url}::${listenerId}` +} + +function mountSSE(url = '/api/v1/events', listenerId = 'sse-listener', options?: Parameters[3]) { + let result!: UseSSEResult + const messageHandler = vi.fn<(event: MessageEvent) => void>() + const Component = defineComponent({ + setup() { + result = useBackground().useSSE(url, messageHandler, listenerId, options) + return () => h('div') + }, + }) + + const wrapper = mount(Component) + wrappers.push(wrapper) + return { messageHandler, result, wrapper } +} + +function mountDelayedSSE(url = '/api/v1/delayed-events', listenerId = 'delayed-listener', delay = 3_000) { + let result!: UseDelayedSSEResult + const messageHandler = vi.fn<(event: MessageEvent) => void>() + const Component = defineComponent({ + setup() { + result = useBackground().useDelayedSSE(url, messageHandler, listenerId, delay) + return () => h('div') + }, + }) + + const wrapper = mount(Component) + wrappers.push(wrapper) + return { messageHandler, result, wrapper } +} + +function mountProgressSSE(active: Ref, url = '/api/v1/progress', listenerId = 'progress-listener') { + let result!: UseProgressSSEResult + const messageHandler = vi.fn<(event: MessageEvent) => void>() + const Component = defineComponent({ + setup() { + result = useBackground().useProgressSSE(url, messageHandler, listenerId, active) + return () => h('div') + }, + }) + + const wrapper = mount(Component) + wrappers.push(wrapper) + return { messageHandler, result, wrapper } +} + +function unmount(wrapper: VueWrapper) { + const index = wrappers.indexOf(wrapper) + if (index >= 0) wrappers.splice(index, 1) + wrapper.unmount() +} + +beforeEach(() => { + vi.useFakeTimers() + vi.clearAllMocks() + managers.clear() + getCurrentLocaleMock.mockReturnValue('zh-CN') +}) + +afterEach(() => { + for (const wrapper of wrappers.splice(0)) wrapper.unmount() + vi.useRealTimers() + vi.restoreAllMocks() +}) + +describe('useBackground SSE composables', () => { + describe('useSSE', () => { + it('延迟注册消息监听器并透传 manager options', async () => { + const options = { connectDelay: 25, reconnectDelay: 800 } + const { messageHandler, result } = mountSSE('/api/v1/events', 'events', options) + const manager = managers.get(managerKey('/api/v1/events', 'events'))! + + expect(getIndependentManager).toHaveBeenCalledWith('/api/v1/events', 'events', options) + expect(manager.addMessageListener).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(24) + expect(manager.addMessageListener).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(1) + expect(manager.addMessageListener).toHaveBeenCalledWith('events', messageHandler) + unmount(wrappers[0]) + expect(result.isConnected.value).toBe(false) + }) + + it('映射连接状态并透传 readyState 与 forceReconnect', () => { + const { result, wrapper } = mountSSE() + const manager = managers.get(managerKey('/api/v1/events', 'sse-listener'))! + + manager.emitStatus('connecting') + expect(result.isConnected.value).toBe(false) + manager.emitStatus('open') + expect(result.isConnected.value).toBe(true) + manager.emitStatus('closed') + expect(result.isConnected.value).toBe(false) + + manager.readyState = 1 + expect(result.readyState()).toBe(1) + result.forceReconnect() + expect(manager.forceReconnect).toHaveBeenCalledOnce() + + unmount(wrapper) + }) + + it('隔离延迟连接时的 manager 异常', async () => { + const { result, wrapper } = mountSSE('/api/v1/events', 'throwing-listener', { connectDelay: 1 }) + const manager = managers.get(managerKey('/api/v1/events', 'throwing-listener'))! + const error = new Error('connect failed') + manager.addMessageListener.mockImplementationOnce(() => { + throw error + }) + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined) + + await vi.advanceTimersByTimeAsync(1) + + expect(consoleError).toHaveBeenCalledWith('SSE连接建立失败:', error) + expect(result.isConnected.value).toBe(false) + unmount(wrapper) + }) + + it('close 是幂等的', () => { + const { result, wrapper } = mountSSE() + const manager = managers.get(managerKey('/api/v1/events', 'sse-listener'))! + + result.close() + result.close() + + expect(manager.removeStatusListener).toHaveBeenCalledOnce() + expect(manager.removeMessageListener).toHaveBeenCalledOnce() + expect(closeIndependentManager).toHaveBeenCalledOnce() + + unmount(wrapper) + }) + + it('提前卸载会取消延迟连接并清理 manager', async () => { + const { wrapper } = mountSSE('/api/v1/events', 'early-unmount', { connectDelay: 100 }) + const manager = managers.get(managerKey('/api/v1/events', 'early-unmount'))! + + unmount(wrapper) + await vi.advanceTimersByTimeAsync(100) + + expect(manager.addMessageListener).not.toHaveBeenCalled() + expect(manager.removeStatusListener).toHaveBeenCalledOnce() + expect(manager.removeMessageListener).toHaveBeenCalledOnce() + expect(closeIndependentManager).toHaveBeenCalledWith('/api/v1/events', 'early-unmount') + }) + }) + + describe('useDelayedSSE', () => { + it('使用自定义 delay 注册消息监听器', async () => { + const { messageHandler, result, wrapper } = mountDelayedSSE('/api/v1/delayed', 'delayed', 40) + const manager = managers.get(managerKey('/api/v1/delayed', 'delayed'))! + + await vi.advanceTimersByTimeAsync(39) + expect(manager.addMessageListener).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(1) + expect(manager.addMessageListener).toHaveBeenCalledWith('delayed', messageHandler) + expect(result.isConnected.value).toBe(false) + + unmount(wrapper) + }) + + it('映射状态并在卸载时清理监听器和延迟任务', async () => { + const { result, wrapper } = mountDelayedSSE('/api/v1/delayed', 'delayed-state', 100) + const manager = managers.get(managerKey('/api/v1/delayed', 'delayed-state'))! + + manager.emitStatus('open') + expect(result.isConnected.value).toBe(true) + unmount(wrapper) + await vi.advanceTimersByTimeAsync(100) + + expect(manager.addMessageListener).not.toHaveBeenCalled() + expect(manager.removeStatusListener).toHaveBeenCalledOnce() + expect(manager.removeMessageListener).toHaveBeenCalledOnce() + expect(closeIndependentManager).toHaveBeenCalledWith('/api/v1/delayed', 'delayed-state') + expect(result.isConnected.value).toBe(false) + }) + }) + + describe('useProgressSSE', () => { + it('按活动状态惰性创建 manager,并避免重复 start', () => { + const active = ref(false) + const { result, wrapper } = mountProgressSSE(active) + + result.start() + expect(getIndependentManager).not.toHaveBeenCalled() + + active.value = true + result.start() + result.start() + + const managerUrl = new URL('/api/v1/progress', window.location.origin) + managerUrl.searchParams.set('locale', 'zh-CN') + const manager = managers.get(managerKey(managerUrl.toString(), 'progress-listener'))! + expect(getIndependentManager).toHaveBeenCalledOnce() + expect(manager.addMessageListener).toHaveBeenCalledOnce() + expect(result.manager).toBe(manager) + + unmount(wrapper) + }) + + it('为已有 query 的 URL 追加 locale,并传递进度连接选项', () => { + const active = ref(true) + const { result, wrapper } = mountProgressSSE(active, '/api/v1/progress?task=42', 'progress-query') + result.start() + + const expectedUrl = new URL('/api/v1/progress?task=42', window.location.origin) + expectedUrl.searchParams.set('locale', 'zh-CN') + expect(getIndependentManager).toHaveBeenCalledWith(expectedUrl.toString(), 'progress-query', { + backgroundCloseDelay: 1_000, + reconnectDelay: 1_000, + maxReconnectAttempts: 5, + }) + + unmount(wrapper) + }) + + it('URL 无法解析时保留原始地址并使用 query fallback', () => { + const active = ref(true) + const malformedUrl = 'https://[invalid?task=42' + const { result, wrapper } = mountProgressSSE(active, malformedUrl, 'progress-fallback') + result.start() + + expect(getIndependentManager).toHaveBeenCalledWith( + `${malformedUrl}&locale=zh-CN`, + 'progress-fallback', + expect.any(Object), + ) + unmount(wrapper) + }) + + it('stop(false) 保留 manager,stop(true) 销毁 manager', () => { + const active = ref(true) + const { result, wrapper } = mountProgressSSE(active, '/api/v1/progress', 'progress-stop') + result.start() + const manager = result.manager! + const managerUrl = new URL('/api/v1/progress', window.location.origin) + managerUrl.searchParams.set('locale', 'zh-CN') + + result.stop(false) + expect(manager.removeMessageListener).toHaveBeenCalledOnce() + expect(closeIndependentManager).not.toHaveBeenCalled() + expect(result.manager).toBe(manager) + + result.start() + expect(manager.addMessageListener).toHaveBeenCalledTimes(2) + + result.stop(true) + expect(closeIndependentManager).toHaveBeenCalledWith(managerUrl.toString(), 'progress-stop') + expect(result.manager).toBeNull() + + unmount(wrapper) + }) + + it('setup scope 卸载时停止监听并销毁 manager', () => { + const active = ref(true) + const { result, wrapper } = mountProgressSSE(active, '/api/v1/progress', 'progress-unmount') + result.start() + const manager = result.manager! + + unmount(wrapper) + + expect(manager.removeMessageListener).toHaveBeenCalledOnce() + expect(closeIndependentManager).toHaveBeenCalledOnce() + expect(result.manager).toBeNull() + }) + }) +}) diff --git a/src/composables/useBackground.ts b/src/composables/useBackground.ts index 10c1067f..fd59570c 100644 --- a/src/composables/useBackground.ts +++ b/src/composables/useBackground.ts @@ -263,6 +263,7 @@ export function useBackground() { immediate: boolean = true, ) => { const loading = ref(false) + let timerStopped = false const wrappedLoadData = async () => { if (loading.value) return @@ -282,20 +283,26 @@ export function useBackground() { await wrappedLoadData() } + if (timerStopped) return + addBackgroundTimer(id, wrappedLoadData, interval, { runInBackground: false, // 后台不刷新数据 skipInitialRun: true, // 已经手动执行过了 }) }) - onUnmounted(() => { + const stopTimer = () => { + // stop 或卸载后的异步 continuation 不得重新注册全局 timer。 + timerStopped = true removeBackgroundTimer(id) - }) + } + + onUnmounted(stopTimer) return { loading, refresh: wrappedLoadData, - stop: () => removeBackgroundTimer(id), + stop: stopTimer, } } diff --git a/vite.config.ts b/vite.config.ts index 16b93f99..8f273769 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -345,6 +345,7 @@ export default defineConfig(({ command, mode, isPreview }) => ({ 'src/composables/useMediaSubscribe.ts', 'src/composables/useLlmProviderDirectory.ts', 'src/composables/useDashboardMediaGridCapacity.ts', + 'src/composables/useBackground.ts', 'src/composables/useKeepAliveRefresh.ts', 'src/composables/useOfflineStatus.ts', 'src/composables/useScheduleProgress.ts', @@ -446,6 +447,12 @@ export default defineConfig(({ command, mode, isPreview }) => ({ lines: 90, statements: 90, }, + 'src/composables/useBackground.ts': { + branches: 85, + functions: 90, + lines: 90, + statements: 90, + }, 'src/composables/useKeepAliveRefresh.ts': { branches: 85, functions: 90,