fix(webui): stop leaked search streams duplicating results and spinning

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pz1EmcTPzjgEpkGQWzb8AQ
This commit is contained in:
Estrella Pan
2026-07-07 22:15:52 +02:00
parent 96e33619f2
commit 4aa2cfb858
3 changed files with 110 additions and 2 deletions

View File

@@ -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);
});
});

View File

@@ -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<EventSource | null>;
// shallowRef:ref() 会把 EventSource 包成 reactive 代理,
// 导致下面 `eventSource.value !== es` 的同流判定永远为真
const eventSource = shallowRef<EventSource | null>(null);
const status = ref<EventSourceStatus>('CLOSED');
const data = ref<BangumiRule[]>([]);
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();

View File

@@ -117,6 +117,13 @@ export const useSearchStore = defineStore('search', () => {
openSearch();
}
// 输入被清空时停掉进行中的搜索流:空输入框不该继续转圈
watch(keyword, (val) => {
if (!val.trim()) {
closeSearch();
}
});
return {
// State
inputValue: keyword,