fix(search): isolate debounced queries by session and lifecycle

This commit is contained in:
eezd committed 2026-10-26 13:55:40 +08:00
1 parent 4ef792a6f4
commit b561b398b4
2 files changed
+92 -11

No files matched your search

@@ -3,13 +3,15 @@ import type { SearchResult } from '@@/apis/search/type'
import { searchApi } from '@@/apis/search'
import Icon from '@@/components/Icon/index.vue'
import { toast } from '@@/utils/notifications'
import { reportError } from '@@/utils/notifications'
import { dirname, noteTitle } from '@@/utils/paths'
import { computed, onMounted, ref, watch } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useUserStore } from '@/pinia/stores/user'
import { openAt, ws } from '../workspace'
const SEARCH_DELAY = 150
const user = useUserStore()
const input = ref<HTMLInputElement>()
const query = ref('')
@@ -21,23 +23,28 @@ const terms = computed(() => query.value.split(/\s+/).filter(Boolean))
let seq = 0
let timer: ReturnType<typeof setTimeout> | undefined
watch(query, (q) => {
const id = ++seq
const version = user.getSessionVersion()
clearTimeout(timer)
timer = setTimeout(() => void run(q), SEARCH_DELAY)
results.value = null
timer = setTimeout(() => void run(q, id, version), SEARCH_DELAY)
}, { flush: 'sync' })
onBeforeUnmount(() => {
clearTimeout(timer)
seq++
})
async function run(q: string) {
const id = ++seq
if (!q.trim()) {
results.value = null
return
}
async function run(q: string, id: number, version: number) {
if (!q.trim()) return
try {
user.assertSession(version)
const r = (await searchApi({ q })).data.results
user.assertSession(version)
if (id !== seq) return
results.value = r
selected.value = 0
} catch {
if (id === seq) toast('搜索失败')
} catch (cause) {
if (id === seq) reportError(cause)
}
}
@@ -0,0 +1,74 @@
// @vitest-environment happy-dom
import type { SearchResponseData } from '@@/apis/search/type'
import type { VueWrapper } from '@vue/test-utils'
import * as searchApi from '@@/apis/search'
import { toasts } from '@@/utils/notifications'
import { flushPromises, mount } from '@vue/test-utils'
import { CanceledError } from 'axios'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { pinia } from '@/pinia'
import { useUserStore } from '@/pinia/stores/user'
import { apiSuccess } from '../../../tests/api-response'
import SearchPanel from './components/SearchPanel.vue'
vi.mock('./workspace', () => ({ ws: { searchOpen: true, active: '' }, openAt: vi.fn() }))
const user = useUserStore(pinia)
let wrapper: VueWrapper
beforeEach(() => {
vi.useFakeTimers()
toasts.splice(0)
user.setToken('rms_search')
wrapper = mount(SearchPanel, { global: { plugins: [pinia] } })
})
afterEach(() => {
wrapper.unmount()
vi.restoreAllMocks()
vi.useRealTimers()
user.setToken('')
})
it('会话取消不提示,真实搜索失败保留原始原因', async () => {
const search = vi.spyOn(searchApi, 'searchApi').mockRejectedValueOnce(new CanceledError('会话已变化'))
await wrapper.get('input').setValue('旧查询')
await vi.advanceTimersByTimeAsync(150)
expect(toasts).toHaveLength(0)
search.mockRejectedValueOnce(new Error('正在维护,请稍后重试'))
await wrapper.get('input').setValue('新查询')
await vi.advanceTimersByTimeAsync(150)
expect(toasts.map(item => item.text)).toEqual(['正在维护,请稍后重试'])
})
it('组件卸载或会话切换后,等待中的搜索不再发送', async () => {
const search = vi.spyOn(searchApi, 'searchApi')
await wrapper.get('input').setValue('旧查询')
user.setToken('rms_new_search')
await vi.advanceTimersByTimeAsync(150)
expect(search).not.toHaveBeenCalled()
expect(toasts).toHaveLength(0)
await wrapper.get('input').setValue('卸载查询')
wrapper.unmount()
await vi.advanceTimersByTimeAsync(150)
expect(search).not.toHaveBeenCalled()
})
it('输入变化立即使旧结果失效,卸载后到达的错误不提示', async () => {
let resolve!: (response: SearchResponseData) => void
let reject!: (cause: Error) => void
vi.spyOn(searchApi, 'searchApi').mockImplementationOnce(() => new Promise((done) => {
resolve = done
})).mockImplementationOnce(() => new Promise((_, fail) => {
reject = fail
}))
await wrapper.get('input').setValue('旧查询')
await vi.advanceTimersByTimeAsync(150)
await wrapper.get('input').setValue('新查询')
resolve(apiSuccess({ results: [{ path: '旧结果.md', line: 1, snippet: '旧内容' }] }))
await flushPromises()
expect(wrapper.findAll('.rm-search-item')).toHaveLength(0)
await vi.advanceTimersByTimeAsync(150)
wrapper.unmount()
reject(new Error('旧页面搜索失败'))
await flushPromises()
expect(toasts).toHaveLength(0)
})