fix(transcription): guard @mention fetch against stale responses
Tag each runSearch with an incrementing requestId; discard responses whose id no longer matches the latest onSearch. Prevents a slow fetch from repopulating the dropdown after the user has cleared the search. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -187,24 +187,33 @@ onMount(() => {
|
|||||||
clientRect?: (() => DOMRect | null) | null;
|
clientRect?: (() => DOMRect | null) | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Request-token guard: every onSearch invocation bumps `requestId`;
|
||||||
|
// runSearch captures the id active when its fetch starts and discards
|
||||||
|
// the response if a newer onSearch has fired since. Without this, a
|
||||||
|
// late response can repopulate the dropdown after the user cleared
|
||||||
|
// the search input. Sara on PR #629.
|
||||||
|
let requestId = 0;
|
||||||
const runSearch = async (query: string) => {
|
const runSearch = async (query: string) => {
|
||||||
|
const id = requestId;
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/persons?q=${encodeURIComponent(query)}`);
|
const res = await fetch(`/api/persons?q=${encodeURIComponent(query)}`);
|
||||||
|
if (id !== requestId) return;
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
dropdownState.items = [];
|
dropdownState.items = [];
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
dropdownState.items = ((await res.json()) as Person[]).slice(
|
const data = (await res.json()) as Person[];
|
||||||
0,
|
if (id !== requestId) return;
|
||||||
SEARCH_RESULT_LIMIT
|
dropdownState.items = data.slice(0, SEARCH_RESULT_LIMIT);
|
||||||
);
|
|
||||||
} catch {
|
} catch {
|
||||||
|
if (id !== requestId) return;
|
||||||
dropdownState.items = [];
|
dropdownState.items = [];
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const debouncedSearch = debounce(runSearch, SEARCH_DEBOUNCE_MS);
|
const debouncedSearch = debounce(runSearch, SEARCH_DEBOUNCE_MS);
|
||||||
cancelPendingSearch = () => debouncedSearch.cancel();
|
cancelPendingSearch = () => debouncedSearch.cancel();
|
||||||
const onSearch = (query: string) => {
|
const onSearch = (query: string) => {
|
||||||
|
requestId++;
|
||||||
if (query.trim() === '') {
|
if (query.trim() === '') {
|
||||||
debouncedSearch.cancel();
|
debouncedSearch.cancel();
|
||||||
dropdownState.items = [];
|
dropdownState.items = [];
|
||||||
|
|||||||
@@ -237,6 +237,36 @@ describe('PersonMentionEditor — AC-2/3: search input drives fetch', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── Stale-response race (Sara on PR #629) ───────────────────────────────────
|
||||||
|
|
||||||
|
describe('PersonMentionEditor — stale-response race', () => {
|
||||||
|
it('discards a stale response that resolves after the search has been cleared', async () => {
|
||||||
|
let resolveFetch!: (v: { ok: boolean; json: () => Promise<Person[]> }) => void;
|
||||||
|
const pendingResponse = new Promise<{ ok: boolean; json: () => Promise<Person[]> }>((r) => {
|
||||||
|
resolveFetch = r;
|
||||||
|
});
|
||||||
|
const fetchMock = vi.fn().mockReturnValue(pendingResponse);
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
renderHost();
|
||||||
|
|
||||||
|
// Open the dropdown and let the debounce fire so a fetch is in flight.
|
||||||
|
await userEvent.type(page.getByRole('textbox'), '@Aug');
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/persons?q=Aug'));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear the search input *before* the fetch resolves.
|
||||||
|
await userEvent.clear(page.getByRole('searchbox'));
|
||||||
|
await expect.element(page.getByRole('searchbox')).toHaveValue('');
|
||||||
|
|
||||||
|
// The stale fetch now resolves with persons. The dropdown must stay empty.
|
||||||
|
resolveFetch({ ok: true, json: () => Promise.resolve([AUGUSTE]) });
|
||||||
|
await new Promise((r) => setTimeout(r, 50));
|
||||||
|
|
||||||
|
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', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user