fix: preserve pending KeepAlive refresh context (#699)

This commit is contained in:
InfinityPacer
2026-08-23 18:25:58 +08:00
committed by GitHub
parent ca2f813810
commit abdf1bf29c
3 changed files with 288 additions and 6 deletions
@@ -0,0 +1,274 @@
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { KeepAlive, defineComponent, h, nextTick, onActivated, ref, type Ref } from 'vue'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { useKeepAliveRefresh, type KeepAliveRefreshContext } from '@/composables/useKeepAliveRefresh'
type RefreshHandler = (context?: KeepAliveRefreshContext) => void | Promise<void>
interface RefreshHarnessOptions {
active?: boolean
refreshOnActivated?: boolean
refreshOnTabActivated?: boolean
refresh?: RefreshHandler
activateSetsActive?: boolean
}
interface RefreshHarness {
wrapper: VueWrapper
active: Ref<boolean>
show: Ref<boolean>
refresh: ReturnType<typeof vi.fn>
manualRefresh: (context?: KeepAliveRefreshContext) => Promise<void>
}
const wrappers: VueWrapper[] = []
afterEach(() => {
for (const wrapper of wrappers.splice(0)) wrapper.unmount()
})
async function settle() {
await nextTick()
await flushPromises()
await nextTick()
await flushPromises()
}
function createRefreshHarness(options: RefreshHarnessOptions = {}): RefreshHarness {
const active = ref(options.active ?? true)
const show = ref(true)
const refresh = vi.fn(options.refresh ?? (() => undefined))
let manualRefresh: RefreshHarness['manualRefresh'] = async () => undefined
const Page = defineComponent({
name: 'KeepAliveRefreshPage',
setup() {
manualRefresh = useKeepAliveRefresh(refresh, {
active: options.active === undefined ? undefined : active,
refreshOnActivated: options.refreshOnActivated,
refreshOnTabActivated: options.refreshOnTabActivated,
}).refresh
if (options.activateSetsActive) {
let activationCount = 0
onActivated(() => {
activationCount += 1
if (activationCount === 2) active.value = true
})
}
return () => h('div', 'keep-alive-refresh-page')
},
})
const Host = defineComponent({
name: 'KeepAliveRefreshHost',
setup() {
return () => h(KeepAlive, null, { default: () => (show.value ? h(Page) : null) })
},
})
const wrapper = mount(Host)
wrappers.push(wrapper)
return { wrapper, active, show, refresh, manualRefresh }
}
async function deactivateAndReactivate(show: Ref<boolean>) {
show.value = false
await nextTick()
show.value = true
}
function deferred<T = void>() {
let resolve!: (value: T | PromiseLike<T>) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((promiseResolve, promiseReject) => {
resolve = promiseResolve
reject = promiseReject
})
return { promise, resolve, reject }
}
describe('useKeepAliveRefresh', () => {
it('does not refresh during ordinary KeepAlive mount or its first activation', async () => {
const harness = createRefreshHarness()
await settle()
expect(harness.refresh).not.toHaveBeenCalled()
})
it('refreshes on the second KeepAlive activation with the activated context', async () => {
const harness = createRefreshHarness()
await settle()
await deactivateAndReactivate(harness.show)
await settle()
expect(harness.refresh).toHaveBeenCalledTimes(1)
expect(harness.refresh).toHaveBeenCalledWith({ silent: true, source: 'activated' })
})
it('blocks inactive refreshes and refreshes once when a mounted tab becomes active', async () => {
const harness = createRefreshHarness({ active: false })
await settle()
await harness.manualRefresh()
await deactivateAndReactivate(harness.show)
await settle()
expect(harness.refresh).not.toHaveBeenCalled()
harness.active.value = true
await settle()
expect(harness.refresh).toHaveBeenCalledTimes(1)
expect(harness.refresh).toHaveBeenCalledWith({ silent: true, source: 'tab' })
})
it('can disable refreshes on KeepAlive activation', async () => {
const harness = createRefreshHarness({ refreshOnActivated: false })
await settle()
await deactivateAndReactivate(harness.show)
await settle()
expect(harness.refresh).not.toHaveBeenCalled()
})
it('can disable refreshes on tab activation', async () => {
const harness = createRefreshHarness({ active: false, refreshOnTabActivated: false })
await settle()
harness.active.value = true
await settle()
expect(harness.refresh).not.toHaveBeenCalled()
})
it('coalesces KeepAlive and tab activation in the same tick', async () => {
const harness = createRefreshHarness({ active: false, activateSetsActive: true })
await settle()
await deactivateAndReactivate(harness.show)
await settle()
expect(harness.refresh).toHaveBeenCalledTimes(1)
expect(harness.refresh).toHaveBeenCalledWith({ silent: true, source: 'activated' })
})
it('exposes manual refresh with a default context and preserves an explicit context', async () => {
const harness = createRefreshHarness()
await settle()
await harness.manualRefresh()
await harness.manualRefresh({ silent: false, source: 'tab' })
expect(harness.refresh.mock.calls).toEqual([
[{ silent: true, source: 'manual' }],
[{ silent: false, source: 'tab' }],
])
})
it('coalesces concurrent refreshes into at most one pending follow-up', async () => {
const first = deferred()
const second = deferred()
const harness = createRefreshHarness({
refresh: vi
.fn<RefreshHandler>()
.mockImplementationOnce(() => first.promise)
.mockImplementationOnce(() => second.promise),
})
await settle()
const initialRefresh = harness.manualRefresh({ silent: true, source: 'activated' })
const pendingRefresh = harness.manualRefresh({ silent: false, source: 'tab' })
const duplicatePendingRefresh = harness.manualRefresh({ silent: true, source: 'manual' })
await pendingRefresh
await duplicatePendingRefresh
expect(harness.refresh).toHaveBeenCalledTimes(1)
first.resolve()
await vi.waitFor(() => expect(harness.refresh).toHaveBeenCalledTimes(2))
expect(harness.refresh).toHaveBeenCalledTimes(2)
expect(harness.refresh).toHaveBeenNthCalledWith(2, { silent: true, source: 'manual' })
second.resolve()
await initialRefresh
await settle()
expect(harness.refresh).toHaveBeenCalledTimes(2)
})
it('uses the context that triggered a pending follow-up', async () => {
const first = deferred()
const second = deferred()
const harness = createRefreshHarness({
refresh: vi
.fn<RefreshHandler>()
.mockImplementationOnce(() => first.promise)
.mockImplementationOnce(() => second.promise),
})
await settle()
const initialRefresh = harness.manualRefresh({ silent: true, source: 'activated' })
await Promise.resolve()
const pendingRefresh = harness.manualRefresh({ silent: false, source: 'tab' })
first.resolve()
await vi.waitFor(() => expect(harness.refresh).toHaveBeenCalledTimes(2))
expect(harness.refresh).toHaveBeenNthCalledWith(2, { silent: false, source: 'tab' })
second.resolve()
await pendingRefresh
await initialRefresh
})
it('does not permanently retain pending state after a failed follow-up', async () => {
const first = deferred()
const second = deferred()
const harness = createRefreshHarness({
refresh: vi
.fn<RefreshHandler>()
.mockImplementationOnce(() => first.promise)
.mockImplementationOnce(() => second.promise)
.mockImplementationOnce(() => undefined),
})
await settle()
const initialRefresh = harness.manualRefresh({ silent: true, source: 'activated' })
const pendingRefresh = harness.manualRefresh({ silent: false, source: 'tab' })
first.resolve()
await vi.waitFor(() => expect(harness.refresh).toHaveBeenCalledTimes(2))
second.reject(new Error('follow-up failed'))
await expect(initialRefresh).rejects.toThrow('follow-up failed')
await pendingRefresh
await harness.manualRefresh({ silent: true, source: 'manual' })
expect(harness.refresh).toHaveBeenCalledTimes(3)
expect(harness.refresh).toHaveBeenNthCalledWith(3, { silent: true, source: 'manual' })
})
it('skips a pending follow-up when the tab becomes inactive first', async () => {
const first = deferred()
const harness = createRefreshHarness({
active: true,
refresh: vi.fn<RefreshHandler>().mockImplementationOnce(() => first.promise),
})
await settle()
const initialRefresh = harness.manualRefresh({ silent: true, source: 'manual' })
const pendingRefresh = harness.manualRefresh({ silent: true, source: 'tab' })
harness.active.value = false
first.resolve()
await initialRefresh
await pendingRefresh
await settle()
expect(harness.refresh).toHaveBeenCalledTimes(1)
})
})
+7 -6
View File
@@ -27,7 +27,7 @@ export function useKeepAliveRefresh(refresh: RefreshHandler, options: KeepAliveR
let mounted = false
let activatedCount = 0
let refreshing = false
let pendingRefresh = false
let pendingRefreshContext: KeepAliveRefreshContext | undefined
let refreshScheduled = false
const isActive = () => options.active === undefined || Boolean(toValue(options.active))
@@ -35,9 +35,9 @@ export function useKeepAliveRefresh(refresh: RefreshHandler, options: KeepAliveR
async function runRefresh(context: KeepAliveRefreshContext = { silent: true, source: 'manual' }) {
if (!isActive()) return
// 避免路由激活和 tab 激活在同一轮里叠加出并发请求
// 并发期间最多保留一个后续刷新;重复触发以最新 context 为准,避免沿用过期的 loading/source 语义
if (refreshing) {
pendingRefresh = true
pendingRefreshContext = context
return
}
@@ -47,9 +47,10 @@ export function useKeepAliveRefresh(refresh: RefreshHandler, options: KeepAliveR
} finally {
refreshing = false
if (pendingRefresh) {
pendingRefresh = false
await runRefresh(context)
const pendingContext = pendingRefreshContext
pendingRefreshContext = undefined
if (pendingContext) {
await runRefresh(pendingContext)
}
}
}
+7
View File
@@ -344,6 +344,7 @@ export default defineConfig(({ command, mode, isPreview }) => ({
'src/composables/useMediaSubscribe.ts',
'src/composables/useLlmProviderDirectory.ts',
'src/composables/useDashboardMediaGridCapacity.ts',
'src/composables/useKeepAliveRefresh.ts',
'src/composables/useOfflineStatus.ts',
'src/composables/useScheduleProgress.ts',
'src/composables/useServerConnectionProbe.ts',
@@ -444,6 +445,12 @@ export default defineConfig(({ command, mode, isPreview }) => ({
lines: 90,
statements: 90,
},
'src/composables/useKeepAliveRefresh.ts': {
branches: 85,
functions: 90,
lines: 90,
statements: 90,
},
'src/composables/useOfflineStatus.ts': {
branches: 85,
functions: 90,