mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-05 23:56:42 +08:00
fix: preserve pending KeepAlive refresh context (#699)
This commit is contained in:
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -27,7 +27,7 @@ export function useKeepAliveRefresh(refresh: RefreshHandler, options: KeepAliveR
|
|||||||
let mounted = false
|
let mounted = false
|
||||||
let activatedCount = 0
|
let activatedCount = 0
|
||||||
let refreshing = false
|
let refreshing = false
|
||||||
let pendingRefresh = false
|
let pendingRefreshContext: KeepAliveRefreshContext | undefined
|
||||||
let refreshScheduled = false
|
let refreshScheduled = false
|
||||||
|
|
||||||
const isActive = () => options.active === undefined || Boolean(toValue(options.active))
|
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' }) {
|
async function runRefresh(context: KeepAliveRefreshContext = { silent: true, source: 'manual' }) {
|
||||||
if (!isActive()) return
|
if (!isActive()) return
|
||||||
|
|
||||||
// 避免路由激活和 tab 激活在同一轮里叠加出并发请求。
|
// 并发期间最多保留一个后续刷新;重复触发以最新 context 为准,避免沿用过期的 loading/source 语义。
|
||||||
if (refreshing) {
|
if (refreshing) {
|
||||||
pendingRefresh = true
|
pendingRefreshContext = context
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,9 +47,10 @@ export function useKeepAliveRefresh(refresh: RefreshHandler, options: KeepAliveR
|
|||||||
} finally {
|
} finally {
|
||||||
refreshing = false
|
refreshing = false
|
||||||
|
|
||||||
if (pendingRefresh) {
|
const pendingContext = pendingRefreshContext
|
||||||
pendingRefresh = false
|
pendingRefreshContext = undefined
|
||||||
await runRefresh(context)
|
if (pendingContext) {
|
||||||
|
await runRefresh(pendingContext)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -344,6 +344,7 @@ export default defineConfig(({ command, mode, isPreview }) => ({
|
|||||||
'src/composables/useMediaSubscribe.ts',
|
'src/composables/useMediaSubscribe.ts',
|
||||||
'src/composables/useLlmProviderDirectory.ts',
|
'src/composables/useLlmProviderDirectory.ts',
|
||||||
'src/composables/useDashboardMediaGridCapacity.ts',
|
'src/composables/useDashboardMediaGridCapacity.ts',
|
||||||
|
'src/composables/useKeepAliveRefresh.ts',
|
||||||
'src/composables/useOfflineStatus.ts',
|
'src/composables/useOfflineStatus.ts',
|
||||||
'src/composables/useScheduleProgress.ts',
|
'src/composables/useScheduleProgress.ts',
|
||||||
'src/composables/useServerConnectionProbe.ts',
|
'src/composables/useServerConnectionProbe.ts',
|
||||||
@@ -444,6 +445,12 @@ export default defineConfig(({ command, mode, isPreview }) => ({
|
|||||||
lines: 90,
|
lines: 90,
|
||||||
statements: 90,
|
statements: 90,
|
||||||
},
|
},
|
||||||
|
'src/composables/useKeepAliveRefresh.ts': {
|
||||||
|
branches: 85,
|
||||||
|
functions: 90,
|
||||||
|
lines: 90,
|
||||||
|
statements: 90,
|
||||||
|
},
|
||||||
'src/composables/useOfflineStatus.ts': {
|
'src/composables/useOfflineStatus.ts': {
|
||||||
branches: 85,
|
branches: 85,
|
||||||
functions: 90,
|
functions: 90,
|
||||||
|
|||||||
Reference in New Issue
Block a user