From ecc4d1aa67a0769bfcbde019fd7a0d463e7b40cb Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 19 May 2026 22:11:17 +0200 Subject: [PATCH] fix(transcription): neutralize legacy items() to dedupe @mention fetch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tiptap's suggestion items() callback fired a fetch on every keystroke after `@`, in parallel with the debounced search-input fetch. Its result was discarded by updateState, so it was pure waste — doubling the load on /api/persons and confusing the debounce. Returning [] from items() routes the entire fetch flow through the search-input -> debounced onSearch path. New test pins @Walter to exactly one fetch. Co-Authored-By: Claude Opus 4.7 --- .../discussion/PersonMentionEditor.svelte | 17 +++++++---------- .../PersonMentionEditor.svelte.spec.ts | 18 ++++++++++++++++++ 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/frontend/src/lib/shared/discussion/PersonMentionEditor.svelte b/frontend/src/lib/shared/discussion/PersonMentionEditor.svelte index a552c404..ed512c65 100644 --- a/frontend/src/lib/shared/discussion/PersonMentionEditor.svelte +++ b/frontend/src/lib/shared/discussion/PersonMentionEditor.svelte @@ -151,16 +151,13 @@ onMount(() => { // Nora #5618 #3 — separate issue tracks the GET /api/persons // response-shape audit (PersonSummaryDTO leaks `notes`). // ───────────────────────────────────────────────────────────── - items: async ({ query }: { query: string }) => { - if (!query) return []; - try { - const res = await fetch(`/api/persons?q=${encodeURIComponent(query)}`); - if (!res.ok) return []; - return ((await res.json()) as Person[]).slice(0, 5); - } catch { - return []; - } - }, + // Tiptap's suggestion plugin requires an `items()` callback to keep + // the dropdown alive, but the actual fetch is owned by `runSearch` + // below — routed through the dropdown's search input via the + // debounced `onSearch` channel. Returning `[]` here keeps Tiptap + // happy without firing a duplicate per-keystroke fetch. + // Markus #5616 / Felix / Nora / Sara on PR #629. + items: async () => [], // AC-1 fix: insert the typed query as displayName, not person.displayName. command({ editor: ed, range, props }) { const p = props as unknown as { personId: string; displayName: string }; diff --git a/frontend/src/lib/shared/discussion/PersonMentionEditor.svelte.spec.ts b/frontend/src/lib/shared/discussion/PersonMentionEditor.svelte.spec.ts index 7d580f09..ddbde152 100644 --- a/frontend/src/lib/shared/discussion/PersonMentionEditor.svelte.spec.ts +++ b/frontend/src/lib/shared/discussion/PersonMentionEditor.svelte.spec.ts @@ -195,6 +195,24 @@ describe('PersonMentionEditor — AC-2/3: search input drives fetch', () => { expect(fetchesAfterSearch).toBe(1); }); + it('fires exactly one /api/persons fetch when the user types @Walter (debounced)', async () => { + const fetchMock = vi + .fn() + .mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([AUGUSTE]) }); + vi.stubGlobal('fetch', fetchMock); + renderHost(); + + await userEvent.type(page.getByRole('textbox'), '@Walter'); + + // Wait beyond the 150 ms debounce window so the trailing call has flushed. + await new Promise((r) => setTimeout(r, 300)); + + const personsFetches = fetchMock.mock.calls.filter( + ([url]) => typeof url === 'string' && url.startsWith('/api/persons') + ); + expect(personsFetches.length).toBe(1); + }); + it('clearing the search input clears the list without firing a fetch', async () => { const fetchMock = vi .fn()