diff --git a/frontend/src/lib/shared/discussion/MentionDropdown.svelte b/frontend/src/lib/shared/discussion/MentionDropdown.svelte index 921d9e6f..43cd254a 100644 --- a/frontend/src/lib/shared/discussion/MentionDropdown.svelte +++ b/frontend/src/lib/shared/discussion/MentionDropdown.svelte @@ -40,6 +40,14 @@ $effect(() => { } }); +// Fire onSearch whenever the effective query changes — covers both the +// editor mirror and direct input edits. This is the only place onSearch +// fires; when the dropdown is unmounted, the effect is disposed and no +// further fetches occur. +$effect(() => { + onSearch(searchQuery); +}); + // highlightedIndex must be both writable (keyboard handler mutates it) and // reset when `items` changes (so it never points past the end of a new list). // A pure $derived is read-only and cannot serve both needs, so $state + $effect @@ -161,9 +169,8 @@ function selectItem(item: Person) { class="min-h-[44px] w-full bg-transparent font-sans text-sm text-ink placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-inset" placeholder={m.person_mention_search_prompt()} bind:value={searchQuery} - oninput={(e) => { + oninput={() => { userHasEdited = true; - onSearch(e.currentTarget.value); }} onmousedown={(e) => e.stopPropagation()} /> diff --git a/frontend/src/lib/shared/discussion/PersonMentionEditor.svelte b/frontend/src/lib/shared/discussion/PersonMentionEditor.svelte index 8f27b9b0..a552c404 100644 --- a/frontend/src/lib/shared/discussion/PersonMentionEditor.svelte +++ b/frontend/src/lib/shared/discussion/PersonMentionEditor.svelte @@ -7,8 +7,12 @@ import { m } from '$lib/paraglide/messages.js'; import type { components } from '$lib/generated/api'; import type { PersonMention } from '$lib/shared/types'; import { deserialize, serialize } from '$lib/shared/discussion/mentionSerializer'; +import { debounce } from '$lib/shared/utils/debounce'; import MentionDropdown from './MentionDropdown.svelte'; +const SEARCH_DEBOUNCE_MS = 150; +const SEARCH_RESULT_LIMIT = 5; + type Person = components['schemas']['Person']; type Props = { @@ -33,6 +37,13 @@ let { let editorEl: HTMLDivElement; let editor: Editor | null = null; +// Hoisted so onDestroy can guarantee the imperatively-mounted dropdown is +// torn down even if Tiptap's suggestion plugin onExit didn't fire (e.g. when +// the host component is unmounted while the dropdown is still open). +let mountedDropdown: object | null = null; +// Hoisted so onDestroy can cancel any pending fetch — otherwise a trailing +// debounced search can fire after the editor is gone and pollute later tests. +let cancelPendingSearch: (() => void) | null = null; // Single reactive state object shared with MentionDropdown. Mutating these // fields propagates to the mounted dropdown via Svelte's $state proxy — @@ -167,7 +178,6 @@ onMount(() => { .run(); }, render() { - let component: object | null = null; let exports: DropdownExports | null = null; // Tiptap's SuggestionProps types `command` against the default @@ -180,8 +190,33 @@ onMount(() => { clientRect?: (() => DOMRect | null) | null; }; + const runSearch = async (query: string) => { + try { + const res = await fetch(`/api/persons?q=${encodeURIComponent(query)}`); + if (!res.ok) { + dropdownState.items = []; + return; + } + dropdownState.items = ((await res.json()) as Person[]).slice( + 0, + SEARCH_RESULT_LIMIT + ); + } catch { + dropdownState.items = []; + } + }; + const debouncedSearch = debounce(runSearch, SEARCH_DEBOUNCE_MS); + cancelPendingSearch = () => debouncedSearch.cancel(); + const onSearch = (query: string) => { + if (query.trim() === '') { + debouncedSearch.cancel(); + dropdownState.items = []; + return; + } + debouncedSearch(query); + }; + const updateState = (renderProps: LooseRenderProps) => { - dropdownState.items = renderProps.items as Person[]; // AC-1: pass typed query as displayName, not person.displayName dropdownState.command = (item: Person) => renderProps.command({ @@ -208,10 +243,10 @@ onMount(() => { get editorQuery() { return dropdownState.editorQuery; }, - onSearch: () => {} + onSearch } }); - component = mounted as object; + mountedDropdown = mounted as object; exports = mounted as unknown as DropdownExports; }, onUpdate(renderProps) { @@ -223,9 +258,9 @@ onMount(() => { return exports?.onKeyDown(event) ?? false; }, onExit() { - if (component) { - unmount(component); - component = null; + if (mountedDropdown) { + unmount(mountedDropdown); + mountedDropdown = null; exports = null; } } @@ -268,7 +303,15 @@ onMount(() => { }); onDestroy(() => { + cancelPendingSearch?.(); editor?.destroy(); + // Tiptap suggestion onExit usually unmounts the dropdown, but if the host + // component is destroyed while a suggestion is active the dropdown can + // outlive the editor — clean it up explicitly. + if (mountedDropdown) { + unmount(mountedDropdown); + mountedDropdown = null; + } }); // Keep the data-placeholder attribute in sync with actual emptiness so the diff --git a/frontend/src/lib/shared/discussion/PersonMentionEditor.svelte.spec.ts b/frontend/src/lib/shared/discussion/PersonMentionEditor.svelte.spec.ts index 9ce43319..7d580f09 100644 --- a/frontend/src/lib/shared/discussion/PersonMentionEditor.svelte.spec.ts +++ b/frontend/src/lib/shared/discussion/PersonMentionEditor.svelte.spec.ts @@ -161,6 +161,64 @@ describe('PersonMentionEditor — typeahead', () => { }); }); +// ─── AC-2/3: search input drives the person fetch (debounced) ─────────────── + +describe('PersonMentionEditor — AC-2/3: search input drives fetch', () => { + it('editing the search input fires a debounced fetch with the new query', async () => { + const fetchMock = vi + .fn() + .mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([AUGUSTE]) }); + vi.stubGlobal('fetch', fetchMock); + renderHost(); + + // Open the dropdown so the search input is reachable. + await userEvent.type(page.getByRole('textbox'), '@'); + await vi.waitFor(async () => { + await expect.element(page.getByRole('searchbox')).toBeVisible(); + }); + + const fetchesBeforeSearch = fetchMock.mock.calls.length; + + // `fill` simulates a single input event with the final value — sidesteps + // per-keystroke timing of userEvent.type so the test can deterministically + // assert that one input event collapses into one debounced fetch. + await page.getByRole('searchbox').fill('Walter'); + + await vi.waitFor( + () => { + expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('q=Walter')); + }, + { timeout: 1000 } + ); + + const fetchesAfterSearch = fetchMock.mock.calls.length - fetchesBeforeSearch; + expect(fetchesAfterSearch).toBe(1); + }); + + it('clearing the search input clears the list without firing a fetch', async () => { + const fetchMock = vi + .fn() + .mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([AUGUSTE]) }); + vi.stubGlobal('fetch', fetchMock); + renderHost(); + + await userEvent.type(page.getByRole('textbox'), '@Aug'); + await vi.waitFor(async () => { + await expect.element(page.getByText('Auguste Raddatz')).toBeInTheDocument(); + }); + + const fetchesBeforeClear = fetchMock.mock.calls.length; + + await userEvent.clear(page.getByRole('searchbox')); + + // Wait beyond the debounce window to confirm no fetch was scheduled. + await new Promise((r) => setTimeout(r, 250)); + + expect(fetchMock.mock.calls.length).toBe(fetchesBeforeClear); + 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', () => { diff --git a/frontend/src/lib/shared/utils/debounce.ts b/frontend/src/lib/shared/utils/debounce.ts index b8958e45..fb3eca9e 100644 --- a/frontend/src/lib/shared/utils/debounce.ts +++ b/frontend/src/lib/shared/utils/debounce.ts @@ -1,12 +1,25 @@ /** * Returns a debounced version of fn that delays invocation until after - * `delay` ms have elapsed since the last call. + * `delay` ms have elapsed since the last call. The returned function + * exposes a `cancel()` method that clears any pending invocation — + * essential when the host context (a destroyed component, an unmounted + * editor) shouldn't fire the trailing call. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any -export function debounce void>(fn: T, delay: number): T { - let timer: ReturnType; - return ((...args: Parameters) => { - clearTimeout(timer); +export function debounce void>( + fn: T, + delay: number +): T & { cancel: () => void } { + let timer: ReturnType | undefined; + const wrapped = ((...args: Parameters) => { + if (timer !== undefined) clearTimeout(timer); timer = setTimeout(() => fn(...args), delay); - }) as T; + }) as T & { cancel: () => void }; + wrapped.cancel = () => { + if (timer !== undefined) { + clearTimeout(timer); + timer = undefined; + } + }; + return wrapped; }