From b561b398b4a97e677021f292189c578ce7ad1095 Mon Sep 17 00:00:00 2001 From: eezd Date: Mon, 26 Oct 2026 13:55:40 +0800 Subject: [PATCH] fix(search): isolate debounced queries by session and lifecycle --- .../workspace/components/SearchPanel.vue | 29 +++++--- web/src/pages/workspace/search-panel.test.ts | 74 +++++++++++++++++++ 2 files changed, 92 insertions(+), 11 deletions(-) create mode 100644 web/src/pages/workspace/search-panel.test.ts diff --git a/web/src/pages/workspace/components/SearchPanel.vue b/web/src/pages/workspace/components/SearchPanel.vue index 1690f3a..a2c71bf 100644 --- a/web/src/pages/workspace/components/SearchPanel.vue +++ b/web/src/pages/workspace/components/SearchPanel.vue @@ -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() const query = ref('') @@ -21,23 +23,28 @@ const terms = computed(() => query.value.split(/\s+/).filter(Boolean)) let seq = 0 let timer: ReturnType | 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) } } diff --git a/web/src/pages/workspace/search-panel.test.ts b/web/src/pages/workspace/search-panel.test.ts new file mode 100644 index 0000000..16e02d2 --- /dev/null +++ b/web/src/pages/workspace/search-panel.test.ts @@ -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) +})