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
committed by marcel
parent f46ae2658f
commit 96e8a07a8c
4 changed files with 136 additions and 15 deletions

View File

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