From 06eb433db5e76f5465e2a112562987eae3af7644 Mon Sep 17 00:00:00 2001 From: Tudor Date: Wed, 26 Aug 2026 20:35:27 +0100 Subject: [PATCH] feat(suggest): debounced, abortable suggestion hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj --- .../__tests__/hooks/useSchoolSuggest.test.tsx | 73 +++++++++++++++++++ nextjs-app/hooks/useSchoolSuggest.ts | 58 +++++++++++++++ nextjs-app/lib/suggest.ts | 39 ++++++++++ 3 files changed, 170 insertions(+) create mode 100644 nextjs-app/__tests__/hooks/useSchoolSuggest.test.tsx create mode 100644 nextjs-app/hooks/useSchoolSuggest.ts create mode 100644 nextjs-app/lib/suggest.ts diff --git a/nextjs-app/__tests__/hooks/useSchoolSuggest.test.tsx b/nextjs-app/__tests__/hooks/useSchoolSuggest.test.tsx new file mode 100644 index 0000000..4681b86 --- /dev/null +++ b/nextjs-app/__tests__/hooks/useSchoolSuggest.test.tsx @@ -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); + }); +}); diff --git a/nextjs-app/hooks/useSchoolSuggest.ts b/nextjs-app/hooks/useSchoolSuggest.ts new file mode 100644 index 0000000..53ba171 --- /dev/null +++ b/nextjs-app/hooks/useSchoolSuggest.ts @@ -0,0 +1,58 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; +import { fetchSuggestions, SUGGEST_MIN_QUERY, type Suggestion } from '@/lib/suggest'; + +/* + * Long enough that a fast typist does not fire a request per character, short + * enough that the list feels attached to the keyboard. + */ +const DEBOUNCE_MS = 200; + +export function useSchoolSuggest(query: string, enabled: boolean) { + const [suggestions, setSuggestions] = useState([]); + const [open, setOpen] = useState(false); + const [activeIndex, setActiveIndex] = useState(-1); + // Set when the user dismisses the list, so a re-render does not reopen it. + const dismissed = useRef(''); + + useEffect(() => { + const q = query.trim(); + if (!enabled || q.length < SUGGEST_MIN_QUERY || dismissed.current === q) { + setSuggestions([]); + setOpen(false); + return; + } + + /* + * Abort the superseded request on every keystroke. This 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. + */ + const controller = new AbortController(); + const timer = setTimeout(async () => { + const rows = await fetchSuggestions(q, controller.signal); + if (controller.signal.aborted) return; + setSuggestions(rows); + setActiveIndex(-1); + setOpen(rows.length > 0); + }, DEBOUNCE_MS); + + return () => { + clearTimeout(timer); + controller.abort(); + }; + }, [query, enabled]); + + return { + suggestions, + open, + activeIndex, + setActiveIndex, + close: () => { + dismissed.current = query.trim(); + setOpen(false); + setActiveIndex(-1); + }, + }; +} diff --git a/nextjs-app/lib/suggest.ts b/nextjs-app/lib/suggest.ts new file mode 100644 index 0000000..5a17d9d --- /dev/null +++ b/nextjs-app/lib/suggest.ts @@ -0,0 +1,39 @@ +/** + * Client for /api/suggest. + * + * No `cache: "no-store"`. The compare modal's search uses it, and copying that + * here would discard both the browser cache and the ETag 304s the backend's + * CacheAndETagMiddleware already provides — on the one endpoint where prefix + * queries repeat most. + */ + +export interface Suggestion { + urn: number; + school_name: string; + local_authority: string; + postcode: string; + phase: string; + school_type: string; +} + +/** Below this the response is thousands of schools and worth no round trip. */ +export const SUGGEST_MIN_QUERY = 2; + +const API = process.env.NEXT_PUBLIC_API_URL || '/api'; + +/** Suggestions for `q`. Never throws: no suggestions is a fine outcome. */ +export async function fetchSuggestions( + q: string, signal?: AbortSignal, +): Promise { + if (q.trim().length < SUGGEST_MIN_QUERY) return []; + try { + const res = await fetch(`${API}/suggest?q=${encodeURIComponent(q.trim())}`, + { signal }); + if (!res.ok) return []; + const body = await res.json(); + return body.suggestions ?? []; + } catch { + // Includes AbortError, which is the normal path on every keystroke. + return []; + } +}