feat(transcription): drive @mention fetch through the dropdown search input

For issue #380 (AC-2, AC-3, AC-4 + NFR debounce).

The search input is now the single fetch trigger. The dropdown's
searchQuery reactivity calls onSearch on every change — whether sourced
from the editor mirror or the user's own input. PersonMentionEditor
debounces these calls at 150 ms, short-circuits on empty queries (no
fetch, items cleared), and tears down pending timers on destroy.

The Tiptap suggestion plugin's items() now returns [] — per-keystroke
fetches in the editor are gone. The same /api/persons?q= endpoint is
used; the difference is in when and how often the request fires.

Adds a cancel() method to the debounce utility so destroyed editors
don't leave trailing fetches alive (which previously polluted the test
ledger and would have wasted bandwidth in production tab-close races).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-05-19 21:20:06 +02:00
parent e1b5c1b15c
commit a4e184d939
4 changed files with 136 additions and 15 deletions

View File

@@ -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 // 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). // 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 // 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" 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()} placeholder={m.person_mention_search_prompt()}
bind:value={searchQuery} bind:value={searchQuery}
oninput={(e) => { oninput={() => {
userHasEdited = true; userHasEdited = true;
onSearch(e.currentTarget.value);
}} }}
onmousedown={(e) => e.stopPropagation()} onmousedown={(e) => e.stopPropagation()}
/> />

View File

@@ -7,8 +7,12 @@ import { m } from '$lib/paraglide/messages.js';
import type { components } from '$lib/generated/api'; import type { components } from '$lib/generated/api';
import type { PersonMention } from '$lib/shared/types'; import type { PersonMention } from '$lib/shared/types';
import { deserialize, serialize } from '$lib/shared/discussion/mentionSerializer'; import { deserialize, serialize } from '$lib/shared/discussion/mentionSerializer';
import { debounce } from '$lib/shared/utils/debounce';
import MentionDropdown from './MentionDropdown.svelte'; import MentionDropdown from './MentionDropdown.svelte';
const SEARCH_DEBOUNCE_MS = 150;
const SEARCH_RESULT_LIMIT = 5;
type Person = components['schemas']['Person']; type Person = components['schemas']['Person'];
type Props = { type Props = {
@@ -33,6 +37,13 @@ let {
let editorEl: HTMLDivElement; let editorEl: HTMLDivElement;
let editor: Editor | null = null; 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 // Single reactive state object shared with MentionDropdown. Mutating these
// fields propagates to the mounted dropdown via Svelte's $state proxy — // fields propagates to the mounted dropdown via Svelte's $state proxy —
@@ -167,7 +178,6 @@ onMount(() => {
.run(); .run();
}, },
render() { render() {
let component: object | null = null;
let exports: DropdownExports | null = null; let exports: DropdownExports | null = null;
// Tiptap's SuggestionProps types `command` against the default // Tiptap's SuggestionProps types `command` against the default
@@ -180,8 +190,33 @@ onMount(() => {
clientRect?: (() => DOMRect | null) | null; 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) => { const updateState = (renderProps: LooseRenderProps) => {
dropdownState.items = renderProps.items as Person[];
// AC-1: pass typed query as displayName, not person.displayName // AC-1: pass typed query as displayName, not person.displayName
dropdownState.command = (item: Person) => dropdownState.command = (item: Person) =>
renderProps.command({ renderProps.command({
@@ -208,10 +243,10 @@ onMount(() => {
get editorQuery() { get editorQuery() {
return dropdownState.editorQuery; return dropdownState.editorQuery;
}, },
onSearch: () => {} onSearch
} }
}); });
component = mounted as object; mountedDropdown = mounted as object;
exports = mounted as unknown as DropdownExports; exports = mounted as unknown as DropdownExports;
}, },
onUpdate(renderProps) { onUpdate(renderProps) {
@@ -223,9 +258,9 @@ onMount(() => {
return exports?.onKeyDown(event) ?? false; return exports?.onKeyDown(event) ?? false;
}, },
onExit() { onExit() {
if (component) { if (mountedDropdown) {
unmount(component); unmount(mountedDropdown);
component = null; mountedDropdown = null;
exports = null; exports = null;
} }
} }
@@ -268,7 +303,15 @@ onMount(() => {
}); });
onDestroy(() => { onDestroy(() => {
cancelPendingSearch?.();
editor?.destroy(); 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 // Keep the data-placeholder attribute in sync with actual emptiness so the

View File

@@ -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 @ ─────────────────── // ─── AC-1: search input prefilled with text typed after @ ───────────────────
describe('PersonMentionEditor — AC-1: search input prefill', () => { describe('PersonMentionEditor — AC-1: search input prefill', () => {

View File

@@ -1,12 +1,25 @@
/** /**
* Returns a debounced version of fn that delays invocation until after * 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 // eslint-disable-next-line @typescript-eslint/no-explicit-any
export function debounce<T extends (...args: any[]) => void>(fn: T, delay: number): T { export function debounce<T extends (...args: any[]) => void>(
let timer: ReturnType<typeof setTimeout>; fn: T,
return ((...args: Parameters<T>) => { delay: number
clearTimeout(timer); ): T & { cancel: () => void } {
let timer: ReturnType<typeof setTimeout> | undefined;
const wrapped = ((...args: Parameters<T>) => {
if (timer !== undefined) clearTimeout(timer);
timer = setTimeout(() => fn(...args), delay); timer = setTimeout(() => fn(...args), delay);
}) as T; }) as T & { cancel: () => void };
wrapped.cancel = () => {
if (timer !== undefined) {
clearTimeout(timer);
timer = undefined;
}
};
return wrapped;
} }