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 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-05-19 23:09:22 +02:00
committed by marcel
parent 510e406a5e
commit d56e6eadab
2 changed files with 47 additions and 0 deletions

View File

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

View File

@@ -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', () => {