fix(search): isolate debounced queries by session and lifecycle
This commit is contained in:
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 { searchApi } from '@@/apis/search'
|
||||||
import Icon from '@@/components/Icon/index.vue'
|
import Icon from '@@/components/Icon/index.vue'
|
||||||
import { toast } from '@@/utils/notifications'
|
import { reportError } from '@@/utils/notifications'
|
||||||
|
|
||||||
import { dirname, noteTitle } from '@@/utils/paths'
|
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'
|
import { openAt, ws } from '../workspace'
|
||||||
|
|
||||||
const SEARCH_DELAY = 150
|
const SEARCH_DELAY = 150
|
||||||
|
const user = useUserStore()
|
||||||
|
|
||||||
const input = ref<HTMLInputElement>()
|
const input = ref<HTMLInputElement>()
|
||||||
const query = ref('')
|
const query = ref('')
|
||||||
@@ -21,23 +23,28 @@ const terms = computed(() => query.value.split(/\s+/).filter(Boolean))
|
|||||||
let seq = 0
|
let seq = 0
|
||||||
let timer: ReturnType<typeof setTimeout> | undefined
|
let timer: ReturnType<typeof setTimeout> | undefined
|
||||||
watch(query, (q) => {
|
watch(query, (q) => {
|
||||||
|
const id = ++seq
|
||||||
|
const version = user.getSessionVersion()
|
||||||
clearTimeout(timer)
|
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) {
|
async function run(q: string, id: number, version: number) {
|
||||||
const id = ++seq
|
if (!q.trim()) return
|
||||||
if (!q.trim()) {
|
|
||||||
results.value = null
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
|
user.assertSession(version)
|
||||||
const r = (await searchApi({ q })).data.results
|
const r = (await searchApi({ q })).data.results
|
||||||
|
user.assertSession(version)
|
||||||
if (id !== seq) return
|
if (id !== seq) return
|
||||||
results.value = r
|
results.value = r
|
||||||
selected.value = 0
|
selected.value = 0
|
||||||
} catch {
|
} catch (cause) {
|
||||||
if (id === seq) toast('搜索失败')
|
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)
|
||||||
|
})
|
||||||
Reference in new issue
Block a user