fix(transcription): neutralize legacy items() to dedupe @mention fetch
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 <noreply@anthropic.com>
This commit is contained in:
@@ -151,16 +151,13 @@ onMount(() => {
|
|||||||
// Nora #5618 #3 — separate issue tracks the GET /api/persons
|
// Nora #5618 #3 — separate issue tracks the GET /api/persons
|
||||||
// response-shape audit (PersonSummaryDTO leaks `notes`).
|
// response-shape audit (PersonSummaryDTO leaks `notes`).
|
||||||
// ─────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────
|
||||||
items: async ({ query }: { query: string }) => {
|
// Tiptap's suggestion plugin requires an `items()` callback to keep
|
||||||
if (!query) return [];
|
// the dropdown alive, but the actual fetch is owned by `runSearch`
|
||||||
try {
|
// below — routed through the dropdown's search input via the
|
||||||
const res = await fetch(`/api/persons?q=${encodeURIComponent(query)}`);
|
// debounced `onSearch` channel. Returning `[]` here keeps Tiptap
|
||||||
if (!res.ok) return [];
|
// happy without firing a duplicate per-keystroke fetch.
|
||||||
return ((await res.json()) as Person[]).slice(0, 5);
|
// Markus #5616 / Felix / Nora / Sara on PR #629.
|
||||||
} catch {
|
items: async () => [],
|
||||||
return [];
|
|
||||||
}
|
|
||||||
},
|
|
||||||
// AC-1 fix: insert the typed query as displayName, not person.displayName.
|
// AC-1 fix: insert the typed query as displayName, not person.displayName.
|
||||||
command({ editor: ed, range, props }) {
|
command({ editor: ed, range, props }) {
|
||||||
const p = props as unknown as { personId: string; displayName: string };
|
const p = props as unknown as { personId: string; displayName: string };
|
||||||
|
|||||||
@@ -195,6 +195,24 @@ describe('PersonMentionEditor — AC-2/3: search input drives fetch', () => {
|
|||||||
expect(fetchesAfterSearch).toBe(1);
|
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 () => {
|
it('clearing the search input clears the list without firing a fetch', async () => {
|
||||||
const fetchMock = vi
|
const fetchMock = vi
|
||||||
.fn()
|
.fn()
|
||||||
|
|||||||
Reference in New Issue
Block a user