mirror of
https://github.com/EstrellaXD/Auto_Bangumi.git
synced 2026-11-03 14:16:03 +08:00
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -117,6 +117,13 @@ export const useSearchStore = defineStore('search', () => {
|
||||
openSearch();
|
||||
}
|
||||
|
||||
// 输入被清空时停掉进行中的搜索流:空输入框不该继续转圈
|
||||
watch(keyword, (val) => {
|
||||
if (!val.trim()) {
|
||||
closeSearch();
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
// State
|
||||
inputValue: keyword,
|
||||
|
||||
Reference in New Issue
Block a user