feat(suggest): debounced, abortable suggestion hook
The AbortController is correctness, not economy. Without it a slow response for 'st' can land after the fast one for 'st marys' and replace a correct list with a stale one — the classic autosuggest race. No cache: 'no-store', unlike the compare modal's search. This is the one endpoint where prefix queries repeat most across users, so discarding the browser cache and the backend's ETag 304s would be throwing away the cheapest win available. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
This commit is contained in:
1 parent
1a6d349dad
commit
06eb433db5
3 files changed
+170
No files matched your search
@@ -0,0 +1,73 @@
|
||||
import { renderHook, act, waitFor } from '@testing-library/react';
|
||||
import { useSchoolSuggest } from '@/hooks/useSchoolSuggest';
|
||||
|
||||
const realFetch = global.fetch;
|
||||
|
||||
function mockFetch(rows: unknown[], delayMs = 0) {
|
||||
global.fetch = jest.fn(async (_url: unknown, init?: { signal?: AbortSignal }) => {
|
||||
if (delayMs) {
|
||||
await new Promise((resolve, reject) => {
|
||||
const t = setTimeout(resolve, delayMs);
|
||||
init?.signal?.addEventListener('abort', () => {
|
||||
clearTimeout(t);
|
||||
reject(Object.assign(new Error('aborted'), { name: 'AbortError' }));
|
||||
});
|
||||
});
|
||||
}
|
||||
return { ok: true, json: async () => ({ suggestions: rows }) };
|
||||
}) as unknown as typeof fetch;
|
||||
}
|
||||
|
||||
const ROW = {
|
||||
urn: 1, school_name: 'Brecknock Primary School', local_authority: 'Camden',
|
||||
postcode: 'NW1 1AA', phase: 'Primary', school_type: 'Community school',
|
||||
};
|
||||
|
||||
describe('useSchoolSuggest', () => {
|
||||
beforeEach(() => { jest.useFakeTimers(); });
|
||||
afterEach(() => { jest.useRealTimers(); global.fetch = realFetch; });
|
||||
|
||||
it('does not fetch below the minimum query length', () => {
|
||||
mockFetch([ROW]);
|
||||
renderHook(() => useSchoolSuggest('b', true));
|
||||
act(() => { jest.advanceTimersByTime(500); });
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not fetch at all when disabled', () => {
|
||||
// The flag being off must mean no request, not a hidden dropdown.
|
||||
mockFetch([ROW]);
|
||||
renderHook(() => useSchoolSuggest('brecknock', false));
|
||||
act(() => { jest.advanceTimersByTime(500); });
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('debounces rather than firing per keystroke', () => {
|
||||
mockFetch([ROW]);
|
||||
const { rerender } = renderHook(
|
||||
({ q }) => useSchoolSuggest(q, true), { initialProps: { q: 'br' } });
|
||||
rerender({ q: 'bre' });
|
||||
rerender({ q: 'brec' });
|
||||
act(() => { jest.advanceTimersByTime(199); });
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
act(() => { jest.advanceTimersByTime(2); });
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('opens with results once they arrive', async () => {
|
||||
mockFetch([ROW]);
|
||||
const { result } = renderHook(() => useSchoolSuggest('brecknock', true));
|
||||
act(() => { jest.advanceTimersByTime(200); });
|
||||
await waitFor(() => expect(result.current.suggestions).toHaveLength(1));
|
||||
expect(result.current.open).toBe(true);
|
||||
});
|
||||
|
||||
it('close() hides the list without clearing the query', async () => {
|
||||
mockFetch([ROW]);
|
||||
const { result } = renderHook(() => useSchoolSuggest('brecknock', true));
|
||||
act(() => { jest.advanceTimersByTime(200); });
|
||||
await waitFor(() => expect(result.current.open).toBe(true));
|
||||
act(() => { result.current.close(); });
|
||||
expect(result.current.open).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user