fix: preserve background timer lifecycle on replacement (#700)

This commit is contained in:
InfinityPacer
2026-08-23 18:48:27 +08:00
committed by GitHub
parent abdf1bf29c
commit cec2169c13
3 changed files with 298 additions and 1 deletions
@@ -0,0 +1,285 @@
import {
addBackgroundTimer,
BackgroundManager,
getBackgroundTimerStatus,
removeBackgroundTimer,
} from '@/utils/backgroundManager'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
type ActivityState = 'active' | 'idle' | 'passive' | 'suspended'
type ActivityStateListener = (state: ActivityState) => void
const lifecycleMocks = vi.hoisted(() => {
let state: ActivityState = 'active'
let lastActivityAt = 0
const listeners = new Set<ActivityStateListener>()
const releaseFns: Array<ReturnType<typeof vi.fn>> = []
const stopFns: Array<ReturnType<typeof vi.fn>> = []
const acquire = vi.fn(() => {
const release = vi.fn()
releaseFns.push(release)
return release
})
const subscribe = vi.fn((listener: ActivityStateListener) => {
listeners.add(listener)
listener(state)
const stop = vi.fn(() => {
listeners.delete(listener)
})
stopFns.push(stop)
return stop
})
const getLastActivityAt = vi.fn(() => lastActivityAt)
return {
appActivityLifecycle: {
acquire,
subscribe,
getLastActivityAt,
},
acquire,
subscribe,
getLastActivityAt,
releaseFns,
stopFns,
triggerState(nextState: ActivityState) {
state = nextState
Array.from(listeners).forEach(listener => listener(nextState))
},
setLastActivityAt(value: number) {
lastActivityAt = value
},
reset() {
state = 'active'
lastActivityAt = Date.now()
listeners.clear()
releaseFns.length = 0
stopFns.length = 0
acquire.mockClear()
subscribe.mockClear()
getLastActivityAt.mockClear()
},
}
})
vi.mock('@/utils/appActivityLifecycle', () => ({
appActivityLifecycle: lifecycleMocks.appActivityLifecycle,
}))
describe('BackgroundManager', () => {
let manager: BackgroundManager
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-08-23T12:00:00.000Z'))
lifecycleMocks.reset()
vi.spyOn(console, 'log').mockImplementation(() => {})
vi.spyOn(console, 'error').mockImplementation(() => {})
manager = new BackgroundManager()
})
afterEach(() => {
manager.destroy()
vi.useRealTimers()
vi.restoreAllMocks()
})
it('replaces a timer by id without allowing the old callback to run', () => {
const oldCallback = vi.fn()
const newCallback = vi.fn()
manager.addTimer('refresh', oldCallback, 100, { skipInitialRun: true })
manager.addTimer('refresh', newCallback, 100, { skipInitialRun: true })
vi.advanceTimersByTime(100)
expect(oldCallback).not.toHaveBeenCalled()
expect(newCallback).toHaveBeenCalledOnce()
expect(manager.getTimersInfo()).toEqual([
{
id: 'refresh',
interval: 100,
status: 'running',
runInBackground: false,
},
])
})
it('keeps lifecycle ownership when the only timer is replaced', () => {
manager.addTimer('refresh', vi.fn(), 100, { skipInitialRun: true })
manager.addTimer('refresh', vi.fn(), 100, { skipInitialRun: true })
lifecycleMocks.triggerState('suspended')
expect(manager.getTimerStatus('refresh')).toBe('paused')
lifecycleMocks.triggerState('active')
expect(manager.getTimerStatus('refresh')).toBe('running')
})
it('keeps a replacement foreground timer paused while already suspended', () => {
const oldCallback = vi.fn()
const newCallback = vi.fn()
manager.addTimer('refresh', oldCallback, 100, { skipInitialRun: true })
lifecycleMocks.triggerState('suspended')
manager.addTimer('refresh', newCallback, 100, { skipInitialRun: true })
expect(manager.getTimerStatus('refresh')).toBe('paused')
vi.advanceTimersByTime(300)
expect(oldCallback).not.toHaveBeenCalled()
expect(newCallback).not.toHaveBeenCalled()
lifecycleMocks.triggerState('active')
vi.advanceTimersByTime(100)
expect(manager.getTimerStatus('refresh')).toBe('running')
expect(newCallback).toHaveBeenCalledOnce()
})
it('runs immediately by default and honors skipInitialRun', () => {
const immediateCallback = vi.fn()
const delayedCallback = vi.fn()
manager.addTimer('immediate', immediateCallback, 100)
manager.addTimer('delayed', delayedCallback, 100, { skipInitialRun: true })
expect(immediateCallback).toHaveBeenCalledOnce()
expect(delayedCallback).not.toHaveBeenCalled()
vi.advanceTimersByTime(100)
expect(immediateCallback).toHaveBeenCalledTimes(2)
expect(delayedCallback).toHaveBeenCalledOnce()
})
it('isolates synchronous callback failures from timer execution', () => {
const error = new Error('refresh failed')
const failingCallback = vi.fn(() => {
throw error
})
const healthyCallback = vi.fn()
manager.addTimer('failing', failingCallback, 100, { skipInitialRun: true })
manager.addTimer('healthy', healthyCallback, 100, { skipInitialRun: true })
expect(() => vi.advanceTimersByTime(200)).not.toThrow()
expect(failingCallback).toHaveBeenCalledTimes(2)
expect(healthyCallback).toHaveBeenCalledTimes(2)
expect(console.error).toHaveBeenCalledWith('Background: 定时器 failing 执行错误:', error)
})
it('pauses foreground timers while suspended and keeps background timers running', () => {
const foregroundCallback = vi.fn()
const backgroundCallback = vi.fn()
manager.addTimer('foreground', foregroundCallback, 100, { skipInitialRun: true })
manager.addTimer('background', backgroundCallback, 100, {
runInBackground: true,
skipInitialRun: true,
})
vi.advanceTimersByTime(100)
lifecycleMocks.triggerState('suspended')
expect(manager.getTimerStatus('foreground')).toBe('paused')
expect(manager.getTimerStatus('background')).toBe('running')
vi.advanceTimersByTime(300)
expect(foregroundCallback).toHaveBeenCalledOnce()
expect(backgroundCallback).toHaveBeenCalledTimes(4)
lifecycleMocks.triggerState('active')
vi.advanceTimersByTime(100)
expect(manager.getTimerStatus('foreground')).toBe('running')
expect(foregroundCallback).toHaveBeenCalledTimes(2)
})
it('releases lifecycle resources after removing the final timer', () => {
const addEventListener = vi.spyOn(window, 'addEventListener')
const removeEventListener = vi.spyOn(window, 'removeEventListener')
manager.addTimer('first', vi.fn(), 100, { skipInitialRun: true })
manager.addTimer('second', vi.fn(), 100, { skipInitialRun: true })
expect(lifecycleMocks.acquire).toHaveBeenCalledOnce()
expect(lifecycleMocks.subscribe).toHaveBeenCalledOnce()
expect(addEventListener).toHaveBeenCalledWith('beforeunload', expect.any(Function))
manager.removeTimer('first')
expect(lifecycleMocks.releaseFns[0]).not.toHaveBeenCalled()
expect(lifecycleMocks.stopFns[0]).not.toHaveBeenCalled()
manager.removeTimer('second')
expect(lifecycleMocks.releaseFns[0]).toHaveBeenCalledOnce()
expect(lifecycleMocks.stopFns[0]).toHaveBeenCalledOnce()
expect(removeEventListener).toHaveBeenCalledWith('beforeunload', expect.any(Function))
expect(manager.getTimersInfo()).toEqual([])
})
it('reports activity time, timer status and active state', () => {
const lastActivityAt = Date.now() - 1_000
lifecycleMocks.setLastActivityAt(lastActivityAt)
manager.addTimer('refresh', vi.fn(), 100, { skipInitialRun: true })
expect(manager.getLastActivityTime()).toBe(lastActivityAt)
expect(manager.isUserActive(2_000)).toBe(true)
expect(manager.isUserActive(500)).toBe(false)
expect(manager.getTimerStatus('refresh')).toBe('running')
expect(manager.getStatus()).toEqual({
isBackground: false,
isDestroyed: false,
timerCount: 1,
lastActivityTime: lastActivityAt,
isUserActive: true,
})
})
it('destroys timers and lifecycle resources, then rejects new timers', () => {
const callback = vi.fn()
const removeEventListener = vi.spyOn(window, 'removeEventListener')
manager.addTimer('refresh', callback, 100, { skipInitialRun: true })
manager.destroy()
vi.advanceTimersByTime(300)
expect(callback).not.toHaveBeenCalled()
expect(manager.getStatus()).toMatchObject({
isBackground: false,
isDestroyed: true,
timerCount: 0,
})
expect(manager.getTimerStatus('refresh')).toBe('not-found')
expect(lifecycleMocks.releaseFns[0]).toHaveBeenCalledOnce()
expect(lifecycleMocks.stopFns[0]).toHaveBeenCalledOnce()
expect(removeEventListener).toHaveBeenCalledWith('beforeunload', expect.any(Function))
const newCallback = vi.fn()
manager.addTimer('new', newCallback, 100)
vi.advanceTimersByTime(100)
expect(newCallback).not.toHaveBeenCalled()
expect(manager.getTimerStatus('new')).toBe('not-found')
expect(lifecycleMocks.acquire).toHaveBeenCalledOnce()
})
it('exposes the global timer helpers through the public API', () => {
const callback = vi.fn()
addBackgroundTimer('global-refresh', callback, 100, { skipInitialRun: true })
expect(getBackgroundTimerStatus('global-refresh')).toBe('running')
removeBackgroundTimer('global-refresh')
expect(getBackgroundTimerStatus('global-refresh')).toBe('not-found')
})
})
+6 -1
View File
@@ -73,9 +73,10 @@ export class BackgroundManager {
const { runInBackground = false, skipInitialRun = false } = options
if (this.isDestroyed) return
this.ensureInitialized()
// 同名 timer 必须先释放;若它是唯一任务,随后重新获取生命周期,确保替换后的 timer 仍接收活动状态。
this.removeTimer(id)
this.ensureInitialized()
const timerConfig = {
callback,
@@ -101,6 +102,10 @@ export class BackgroundManager {
timerConfig.timer = setInterval(wrappedCallback, interval)
this.timers.set(id, timerConfig)
if (this.isBackground && !runInBackground) {
this.pauseAllTimers()
}
// 如果不跳过初始运行,立即执行一次
if (!skipInitialRun) {
wrappedCallback()
+7
View File
@@ -309,6 +309,7 @@ export default defineConfig(({ command, mode, isPreview }) => ({
'src/utils/permission.ts',
'src/utils/pluginSidebarNav.ts',
'src/utils/requestOptimizer.ts',
'src/utils/backgroundManager.ts',
'src/utils/sseManager.ts',
'src/utils/federationLoader.ts',
'src/utils/federationRuntime.ts',
@@ -907,6 +908,12 @@ export default defineConfig(({ command, mode, isPreview }) => ({
lines: 90,
statements: 90,
},
'src/utils/backgroundManager.ts': {
branches: 85,
functions: 90,
lines: 90,
statements: 90,
},
'src/utils/sseManager.ts': {
branches: 85,
functions: 90,