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:
@@ -268,6 +268,13 @@ onMount(() => {
|
|||||||
return exports?.onKeyDown(event) ?? false;
|
return exports?.onKeyDown(event) ?? false;
|
||||||
},
|
},
|
||||||
onExit() {
|
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) {
|
if (mountedDropdown) {
|
||||||
unmount(mountedDropdown);
|
unmount(mountedDropdown);
|
||||||
mountedDropdown = null;
|
mountedDropdown = null;
|
||||||
|
|||||||
@@ -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 @ ───────────────────
|
// ─── AC-1: search input prefilled with text typed after @ ───────────────────
|
||||||
|
|
||||||
describe('PersonMentionEditor — AC-1: search input prefill', () => {
|
describe('PersonMentionEditor — AC-1: search input prefill', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user