From 4aa2cfb8587bdf0526cf6e9036c6bc58e3e5ba25 Mon Sep 17 00:00:00 2001 From: Estrella Pan Date: Tue, 7 Jul 2026 22:15:52 +0200 Subject: [PATCH] fix(webui): stop leaked search streams duplicating results and spinning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit open() never closed the previous EventSource, so re-searching leaked the old stream: it kept appending the same results (duplicate variants/tags in the list), its onerror closed the NEW stream's handle, and the leaked stream auto-reconnected forever — an idle spinner even with an empty input. - close the previous stream before opening a new one - stale-stream handlers close themselves and never touch shared state - de-duplicate appended results by rss_link as a safety net - clearing the input stops an in-flight search - eventSource must be a shallowRef: ref() proxies the EventSource and breaks the same-stream identity check Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Pz1EmcTPzjgEpkGQWzb8AQ --- webui/src/api/__tests__/search.test.ts | 80 ++++++++++++++++++++++++++ webui/src/api/search.ts | 25 +++++++- webui/src/store/search.ts | 7 +++ 3 files changed, 110 insertions(+), 2 deletions(-) diff --git a/webui/src/api/__tests__/search.test.ts b/webui/src/api/__tests__/search.test.ts index 914cb0b9..713b8439 100644 --- a/webui/src/api/__tests__/search.test.ts +++ b/webui/src/api/__tests__/search.test.ts @@ -26,3 +26,83 @@ describe('Search API contract (path + HTTP method)', () => { expect(axios.get).toHaveBeenCalledWith('api/v1/search/provider'); }); }); + +/** + * EventSource lifecycle: a re-search must replace the previous stream. + * The old behaviour leaked the previous EventSource — leaked streams kept + * appending duplicate results, and their onerror closed the NEW stream + * while the leaked one auto-reconnected forever (idle spinner). + */ +class FakeEventSource { + static instances: FakeEventSource[] = []; + url: string; + closed = false; + onopen: (() => void) | null = null; + onmessage: ((e: { data: string }) => void) | null = null; + onerror: ((e: unknown) => void) | null = null; + + constructor(url: string, _opts?: unknown) { + this.url = url; + FakeEventSource.instances.push(this); + } + + close() { + this.closed = true; + } +} + +const payload = JSON.stringify({ + official_title: 'Test Anime', + title_raw: 'Test Anime Raw', + filter: '720', + rss_link: 'https://mikanani.me/RSS/Search?searchstr=test', +}); + +describe('apiSearch.get() EventSource lifecycle', () => { + beforeEach(() => { + FakeEventSource.instances = []; + vi.stubGlobal('EventSource', FakeEventSource); + }); + + it('should close the previous stream when a new search opens', () => { + const search = apiSearch.get(); + search.keyword.value = 'foo'; + search.provider.value = 'mikan'; + + search.open(); + search.open(); + + expect(FakeEventSource.instances).toHaveLength(2); + expect(FakeEventSource.instances[0].closed).toBe(true); + expect(FakeEventSource.instances[1].closed).toBe(false); + }); + + it('should ignore stale stream events when a stale stream errors', () => { + const search = apiSearch.get(); + search.keyword.value = 'foo'; + + search.open(); + const first = FakeEventSource.instances[0]; + search.open(); + const second = FakeEventSource.instances[1]; + second.onopen?.(); + + first.onerror?.(new Event('error')); + + expect(second.closed).toBe(false); + expect(search.error.value).toBe(false); + expect(search.status.value).toBe('OPEN'); + }); + + it('should not append the same result twice', () => { + const search = apiSearch.get(); + search.keyword.value = 'foo'; + + search.open(); + const es = FakeEventSource.instances[0]; + es.onmessage?.({ data: payload }); + es.onmessage?.({ data: payload }); + + expect(search.data.value).toHaveLength(1); + }); +}); diff --git a/webui/src/api/search.ts b/webui/src/api/search.ts index f26d2537..971b5c6c 100644 --- a/webui/src/api/search.ts +++ b/webui/src/api/search.ts @@ -1,4 +1,3 @@ -import type { Ref } from 'vue'; import { omit } from 'radash'; import type { BangumiAPI, BangumiRule } from '#/bangumi'; @@ -6,7 +5,9 @@ type EventSourceStatus = 'OPEN' | 'CONNECTING' | 'CLOSED'; export const apiSearch = { get() { - const eventSource = ref(null) as Ref; + // shallowRef:ref() 会把 EventSource 包成 reactive 代理, + // 导致下面 `eventSource.value !== es` 的同流判定永远为真 + const eventSource = shallowRef(null); const status = ref('CLOSED'); const data = ref([]); const error = ref(false); @@ -32,18 +33,35 @@ export const apiSearch = { const es = new EventSource(url, { withCredentials: true }); eventSource.value = es; es.onopen = () => { + if (eventSource.value !== es) return; status.value = 'OPEN'; }; es.onmessage = (e) => { + // 已被新一次搜索替换的旧流:关掉自己,不再往结果里追加 + if (eventSource.value !== es) { + es.close(); + return; + } const _data = JSON.parse(e.data) as BangumiAPI; const newData: BangumiRule = { ...omit(_data, ['filter', 'rss_link']), filter: _data.filter.split(','), rss_link: _data.rss_link.split(','), }; + // 兜底去重:服务端每条流内已按订阅链接去重,这里防异常场景的重复项 + const id = newData.rss_link?.[0] || newData.title_raw; + if (data.value.some((d) => (d.rss_link?.[0] || d.title_raw) === id)) { + return; + } data.value = [...data.value, newData]; }; es.onerror = (err) => { + // 旧流的 onerror 绝不能碰共享状态(会把新流关掉);但必须 close + // 自己——不关的话浏览器会对已结束的流无限自动重连(空转) + if (eventSource.value !== es) { + es.close(); + return; + } console.error('EventSource error:', err); // onerror also fires when the server closes the stream after a // successful search — only count it as a failure when the connection @@ -56,6 +74,9 @@ export const apiSearch = { }; const open = () => { + // 先关上一条流再开新流:不关的话旧流泄漏——结果被重复追加 + // (重复的条目/tag),且服务端关闭后旧流会无限自动重连(空转) + close(); data.value = []; error.value = false; _init(); diff --git a/webui/src/store/search.ts b/webui/src/store/search.ts index d9bba593..7017047f 100644 --- a/webui/src/store/search.ts +++ b/webui/src/store/search.ts @@ -117,6 +117,13 @@ export const useSearchStore = defineStore('search', () => { openSearch(); } + // 输入被清空时停掉进行中的搜索流:空输入框不该继续转圈 + watch(keyword, (val) => { + if (!val.trim()) { + closeSearch(); + } + }); + return { // State inputValue: keyword,