From 6a82f4b5b0cab49512ac2e73e65c5fd4c44bfca9 Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 19 May 2026 23:09:22 +0200 Subject: [PATCH] fix(transcription): cancel pending @mention debounce in onExit Without this, a closed dropdown's trailing runSearch could fire against the next dropdown's state and silently overwrite its items before its own fetch resolved. Felix #1 on PR #629. Co-Authored-By: Claude Opus 4.7 --- .../discussion/PersonMentionEditor.svelte | 7 ++++ .../PersonMentionEditor.svelte.spec.ts | 40 +++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/frontend/src/lib/shared/discussion/PersonMentionEditor.svelte b/frontend/src/lib/shared/discussion/PersonMentionEditor.svelte index c37c3893..6512f2fd 100644 --- a/frontend/src/lib/shared/discussion/PersonMentionEditor.svelte +++ b/frontend/src/lib/shared/discussion/PersonMentionEditor.svelte @@ -268,6 +268,13 @@ onMount(() => { return exports?.onKeyDown(event) ?? false; }, onExit() { + // Cancel any pending debounce so a closed dropdown's trailing + // runSearch cannot fire against the *next* dropdown's state. + // The hoisted `cancelPendingSearch` would be overwritten by + // the next render()'s onStart before the trailing call fires, + // so we cancel locally via the closure-scoped debouncedSearch. + // Felix #1 on PR #629. + debouncedSearch.cancel(); if (mountedDropdown) { unmount(mountedDropdown); mountedDropdown = null; diff --git a/frontend/src/lib/shared/discussion/PersonMentionEditor.svelte.spec.ts b/frontend/src/lib/shared/discussion/PersonMentionEditor.svelte.spec.ts index 02f66852..cbbf692f 100644 --- a/frontend/src/lib/shared/discussion/PersonMentionEditor.svelte.spec.ts +++ b/frontend/src/lib/shared/discussion/PersonMentionEditor.svelte.spec.ts @@ -309,6 +309,46 @@ describe('PersonMentionEditor — stale-response race', () => { }); }); +// ─── onExit cancels pending debounce (Felix #1 on PR #629) ─────────────────── + +describe('PersonMentionEditor — onExit cancels pending debounce', () => { + it('cancels the pending debounced fetch when Escape closes the dropdown before the debounce fires', async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([]) }); + vi.stubGlobal('fetch', fetchMock); + renderHost(); + + // Open the dropdown by typing @ + a query in the editor. + await userEvent.type(page.getByRole('textbox'), '@A'); + await vi.waitFor(async () => { + await expect.element(page.getByRole('searchbox')).toBeVisible(); + }); + + // Wait for any in-flight fetch from opening the dropdown to settle. + await new Promise((r) => setTimeout(r, SEARCH_DEBOUNCE_MS + POST_DEBOUNCE_SLACK_MS)); + const fetchesBeforeEscape = fetchMock.mock.calls.length; + + // Trigger a new debounced search (queues runSearch after 150 ms), then + // immediately Escape *while focus is back in the editor* so Tiptap's + // suggestion-plugin Escape handler fires onExit before the debounce. + // Without onExit cancelling the pending debounce, runSearch executes + // against the now-unmounted dropdown's state. + await page.getByRole('searchbox').fill('Walter'); + // Focus the editor so the Escape lands on Tiptap's suggestion handler. + (page.getByRole('textbox').element() as HTMLElement).focus(); + await userEvent.keyboard('{Escape}'); + + // Wait past the debounce window. If onExit did not cancel the pending + // debounce, a fetch with q=Walter would still fire here. + await new Promise((r) => setTimeout(r, SEARCH_DEBOUNCE_MS + POST_DEBOUNCE_SLACK_MS)); + + const newFetches = fetchMock.mock.calls.slice(fetchesBeforeEscape); + const walterFetches = newFetches.filter( + ([url]) => typeof url === 'string' && url.includes('q=Walter') + ); + expect(walterFetches.length).toBe(0); + }); +}); + // ─── AC-1: search input prefilled with text typed after @ ─────────────────── describe('PersonMentionEditor — AC-1: search input prefill', () => {