From 2556e7f5c8e4ab1430febf4a1177fd9bc3705a96 Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 19 May 2026 22:14:51 +0200 Subject: [PATCH] fix(transcription): guard @mention fetch against stale responses Tag each runSearch with an incrementing requestId; discard responses whose id no longer matches the latest onSearch. Prevents a slow fetch from repopulating the dropdown after the user has cleared the search. Co-Authored-By: Claude Opus 4.7 --- .../discussion/PersonMentionEditor.svelte | 17 ++++++++--- .../PersonMentionEditor.svelte.spec.ts | 30 +++++++++++++++++++ 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/frontend/src/lib/shared/discussion/PersonMentionEditor.svelte b/frontend/src/lib/shared/discussion/PersonMentionEditor.svelte index ed512c65..b15c898b 100644 --- a/frontend/src/lib/shared/discussion/PersonMentionEditor.svelte +++ b/frontend/src/lib/shared/discussion/PersonMentionEditor.svelte @@ -187,24 +187,33 @@ onMount(() => { clientRect?: (() => DOMRect | null) | null; }; + // Request-token guard: every onSearch invocation bumps `requestId`; + // runSearch captures the id active when its fetch starts and discards + // the response if a newer onSearch has fired since. Without this, a + // late response can repopulate the dropdown after the user cleared + // the search input. Sara on PR #629. + let requestId = 0; const runSearch = async (query: string) => { + const id = requestId; try { const res = await fetch(`/api/persons?q=${encodeURIComponent(query)}`); + if (id !== requestId) return; if (!res.ok) { dropdownState.items = []; return; } - dropdownState.items = ((await res.json()) as Person[]).slice( - 0, - SEARCH_RESULT_LIMIT - ); + const data = (await res.json()) as Person[]; + if (id !== requestId) return; + dropdownState.items = data.slice(0, SEARCH_RESULT_LIMIT); } catch { + if (id !== requestId) return; dropdownState.items = []; } }; const debouncedSearch = debounce(runSearch, SEARCH_DEBOUNCE_MS); cancelPendingSearch = () => debouncedSearch.cancel(); const onSearch = (query: string) => { + requestId++; if (query.trim() === '') { debouncedSearch.cancel(); dropdownState.items = []; diff --git a/frontend/src/lib/shared/discussion/PersonMentionEditor.svelte.spec.ts b/frontend/src/lib/shared/discussion/PersonMentionEditor.svelte.spec.ts index ddbde152..a705ab0c 100644 --- a/frontend/src/lib/shared/discussion/PersonMentionEditor.svelte.spec.ts +++ b/frontend/src/lib/shared/discussion/PersonMentionEditor.svelte.spec.ts @@ -237,6 +237,36 @@ describe('PersonMentionEditor — AC-2/3: search input drives fetch', () => { }); }); +// ─── Stale-response race (Sara on PR #629) ─────────────────────────────────── + +describe('PersonMentionEditor — stale-response race', () => { + it('discards a stale response that resolves after the search has been cleared', async () => { + let resolveFetch!: (v: { ok: boolean; json: () => Promise }) => void; + const pendingResponse = new Promise<{ ok: boolean; json: () => Promise }>((r) => { + resolveFetch = r; + }); + const fetchMock = vi.fn().mockReturnValue(pendingResponse); + vi.stubGlobal('fetch', fetchMock); + renderHost(); + + // Open the dropdown and let the debounce fire so a fetch is in flight. + await userEvent.type(page.getByRole('textbox'), '@Aug'); + await vi.waitFor(() => { + expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/persons?q=Aug')); + }); + + // Clear the search input *before* the fetch resolves. + await userEvent.clear(page.getByRole('searchbox')); + await expect.element(page.getByRole('searchbox')).toHaveValue(''); + + // The stale fetch now resolves with persons. The dropdown must stay empty. + resolveFetch({ ok: true, json: () => Promise.resolve([AUGUSTE]) }); + await new Promise((r) => setTimeout(r, 50)); + + await expect.element(page.getByText('Auguste Raddatz')).not.toBeInTheDocument(); + }); +}); + // ─── AC-1: search input prefilled with text typed after @ ─────────────────── describe('PersonMentionEditor — AC-1: search input prefill', () => {