From 142cf60345234464374740fd83be0d1df272173f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 24 Jul 2026 05:27:03 +0000 Subject: [PATCH 01/15] perf(ui): speed up search and interactive catalogue surfaces Add differential-search debounce/abort/LRU parity with universal search, defer local catalogue ranking with useDeferredValue, progressively reveal document result cards, memoize related documents during answer streaming, and harden the universal typeahead cache with a larger TTL-backed LRU. Co-authored-by: BigSimmo --- src/components/ClinicalDashboard.tsx | 10 +- .../clinical-dashboard/document-results.tsx | 7 +- .../document-search-results.tsx | 31 ++- .../use-differential-catalog.ts | 187 +++++++++++++----- .../use-universal-search.ts | 33 +++- .../forms/forms-search-results-page.tsx | 7 +- .../formulation/formulation-builder-page.tsx | 7 +- .../formulation/formulation-home-page.tsx | 8 +- .../services/services-navigator-page.tsx | 9 +- src/components/therapy-compass/bindings.tsx | 5 +- tests/use-differential-search.dom.test.tsx | 134 +++++++++++++ .../use-universal-search-stream.dom.test.tsx | 4 +- 12 files changed, 364 insertions(+), 78 deletions(-) create mode 100644 tests/use-differential-search.dom.test.tsx diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index 51a31a707..d369b7b25 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -26,6 +26,7 @@ import { type CSSProperties, type KeyboardEvent as ReactKeyboardEvent, useCallback, + useDeferredValue, useEffect, useMemo, useReducer, @@ -436,13 +437,14 @@ export function ClinicalDashboard({ const registryRecords = useRegistryRecords(searchMode === "forms" ? "form" : "service", { enabled: searchMode === "services" || searchMode === "forms", }); + const deferredQuery = useDeferredValue(query); const serviceSearchMatches = useMemo( - () => (searchMode === "services" ? rankServiceRecords(registryRecords.records, query) : []), - [query, searchMode, registryRecords.records], + () => (searchMode === "services" ? rankServiceRecords(registryRecords.records, deferredQuery) : []), + [deferredQuery, searchMode, registryRecords.records], ); const formSearchMatches = useMemo( - () => (searchMode === "forms" ? rankFormRecords(registryRecords.records, query) : []), - [query, searchMode, registryRecords.records], + () => (searchMode === "forms" ? rankFormRecords(registryRecords.records, deferredQuery) : []), + [deferredQuery, searchMode, registryRecords.records], ); const recordSearchMatches = useMemo( () => (searchMode === "forms" ? formSearchMatches : searchMode === "services" ? serviceSearchMatches : []), diff --git a/src/components/clinical-dashboard/document-results.tsx b/src/components/clinical-dashboard/document-results.tsx index 85b42aa34..e8b734b63 100644 --- a/src/components/clinical-dashboard/document-results.tsx +++ b/src/components/clinical-dashboard/document-results.tsx @@ -1,6 +1,7 @@ "use client"; import Link from "next/link"; +import { memo } from "react"; import { BookOpen, FileImage, Filter, ListChecks } from "lucide-react"; import { DocumentOrganizationBadges, documentDisplayTitle } from "@/components/DocumentOrganizationBadges"; @@ -105,7 +106,7 @@ function RelatedDocumentCard({ ); } -export function RelatedDocumentsPanel({ +function RelatedDocumentsPanelImpl({ documents, onScopeDocument, onTagSearch, @@ -136,3 +137,7 @@ export function RelatedDocumentsPanel({ ); } + +// Memoized so answer SSE progress in ClinicalDashboard does not re-render this +// subtree when documents and callbacks are unchanged. +export const RelatedDocumentsPanel = memo(RelatedDocumentsPanelImpl); diff --git a/src/components/clinical-dashboard/document-search-results.tsx b/src/components/clinical-dashboard/document-search-results.tsx index 2aa050fa9..46c0fe9d6 100644 --- a/src/components/clinical-dashboard/document-search-results.tsx +++ b/src/components/clinical-dashboard/document-search-results.tsx @@ -68,6 +68,10 @@ import { documentRelevancePercent } from "./relevance-score"; type SearchFacet = { value: string; count: number }; type ResultTypeFilter = "all" | "tables" | "images" | "pdfs"; + +/** Initial DOM budget for document result cards; further rows reveal on demand. */ +const DOCUMENT_RESULTS_INITIAL_WINDOW = 25; +const DOCUMENT_RESULTS_PAGE_SIZE = 25; export type SearchFacets = { status?: SearchFacet[]; validation?: SearchFacet[]; @@ -873,6 +877,16 @@ function DocumentSearchResultsPanelImpl({ () => sortResultItems(displayedMatches, sortValue, documentDisplayTitle), [displayedMatches, sortValue], ); + // Progressive reveal so large libraries do not mount every card on first paint. + // Reset the window whenever the sorted result set identity changes (query/filter/sort). + const resultsSignature = `${trimmedQuery}\0${sortValue}\0${effectiveResultType}\0${activeFacetKeys.join(",")}\0${sortedMatches.length}`; + const [visibleCountState, setVisibleCountState] = useState({ signature: resultsSignature, count: DOCUMENT_RESULTS_INITIAL_WINDOW }); + if (visibleCountState.signature !== resultsSignature) { + setVisibleCountState({ signature: resultsSignature, count: DOCUMENT_RESULTS_INITIAL_WINDOW }); + } + const visibleCount = Math.min(visibleCountState.count, sortedMatches.length); + const renderedMatches = sortedMatches.slice(0, visibleCount); + const hasMoreMatches = visibleCount < sortedMatches.length; const selectedDocument = sortedMatches.find((document) => document.document_id === selectedDocumentId) ?? sortedMatches[0] ?? null; const recordMatchCount = recordMatches.length; @@ -1012,7 +1026,7 @@ function DocumentSearchResultsPanelImpl({ No document matches include all selected filters. ) : null} - {sortedMatches.map((document, index) => { + {renderedMatches.map((document, index) => { const relevanceDisplay = relevanceTone(document); const fileKind = documentFileKind(document.file_name, "DOC"); const relevanceVariant = relevanceDisplay.short === "High relevance" ? "high" : "relevant"; @@ -1134,6 +1148,21 @@ function DocumentSearchResultsPanelImpl({ ); })} + {hasMoreMatches ? ( + + ) : null} {selectedDocument ? ( (); + +function differentialCacheKey(requestKey: string, authSignature: string) { + return JSON.stringify([authSignature, requestKey]); +} + +function peekDifferentialCache(key: string): DifferentialSearchCacheEntry | undefined { + const cached = differentialSearchCache.get(key); + if (!cached) return undefined; + if (cached.expiresAt <= Date.now()) { + differentialSearchCache.delete(key); + return undefined; + } + return cached; +} + +function touchDifferentialCache(key: string) { + const cached = differentialSearchCache.get(key); + if (!cached || cached.expiresAt <= Date.now()) { + if (cached) differentialSearchCache.delete(key); + return; + } + differentialSearchCache.delete(key); + differentialSearchCache.set(key, cached); +} + +function writeDifferentialCache(key: string, value: Omit) { + differentialSearchCache.delete(key); + differentialSearchCache.set(key, { ...value, expiresAt: Date.now() + resultCacheTtlMs }); + if (differentialSearchCache.size > resultCacheMax) { + const oldest = differentialSearchCache.keys().next().value; + if (oldest !== undefined) differentialSearchCache.delete(oldest); + } +} + +/** Test-only: clear the module-scoped differential search LRU between cases. */ +export function clearDifferentialSearchCacheForTests() { + differentialSearchCache.clear(); +} + /** Ranked catalogue search for the Differentials search mode: fetches scored * diagnosis and presentation matches in parallel from /api/differentials. - * Empty queries resolve immediately without a request. */ + * Empty queries resolve immediately without a request. Debounced + abortable + * with an auth-keyed client LRU (parity with useUniversalSearch). */ export function useDifferentialSearch(query: string): DifferentialSearchState { const { authorizationHeader, markSessionExpired, status: authStatus } = useAuthSession(); const requestKey = query.trim().toLowerCase(); - const [state, setState] = useState({ - status: "ready", - matches: emptyDifferentialMatches, - demoMode: false, - }); + const authSignature = JSON.stringify(authorizationHeader ?? {}); + const cacheKey = requestKey ? differentialCacheKey(requestKey, authSignature) : null; + const cached = cacheKey ? peekDifferentialCache(cacheKey) : undefined; + + const [state, setState] = useState(() => + cached + ? { status: "ready", matches: cached.matches, demoMode: cached.demoMode } + : { + status: requestKey ? "loading" : "ready", + matches: emptyDifferentialMatches, + demoMode: false, + }, + ); // Reset to loading during render when the query changes (repo pattern — - // avoids react-hooks/set-state-in-effect). + // avoids react-hooks/set-state-in-effect). Prefer a warm cache hit. const [lastRequestKey, setLastRequestKey] = useState(requestKey); if (lastRequestKey !== requestKey) { setLastRequestKey(requestKey); - setState({ - status: requestKey ? "loading" : "ready", - matches: emptyDifferentialMatches, - demoMode: false, - }); + if (!requestKey) { + setState({ status: "ready", matches: emptyDifferentialMatches, demoMode: false }); + } else if (cached) { + setState({ status: "ready", matches: cached.matches, demoMode: cached.demoMode }); + } else { + setState({ status: "loading", matches: emptyDifferentialMatches, demoMode: false }); + } } useEffect(() => { - if (!requestKey) return undefined; - let active = true; - const encoded = encodeURIComponent(requestKey); - Promise.all([ - fetch(`/api/differentials?kind=diagnosis&q=${encoded}&limit=20`, { headers: authorizationHeader }), - fetch(`/api/differentials?kind=presentation&q=${encoded}&limit=10`, { headers: authorizationHeader }), - ]) - .then(async ([diagnosisResponse, presentationResponse]) => { - if (!active) return; - if (diagnosisResponse.status === 401 || presentationResponse.status === 401) { - if (authStatus === "loading") return; - if (authStatus === "authenticated") markSessionExpired(); - setState({ status: "unauthorized", matches: emptyDifferentialMatches, demoMode: false }); - return; - } - if (!diagnosisResponse.ok || !presentationResponse.ok) { - setState({ status: "error", matches: emptyDifferentialMatches, demoMode: false }); - return; - } - const diagnosisPayload = (await diagnosisResponse.json()) as { - matches?: DifferentialSearchMatches["diagnoses"]; - demoMode?: boolean; - }; - const presentationPayload = (await presentationResponse.json()) as { - matches?: DifferentialSearchMatches["presentations"]; - demoMode?: boolean; - }; - if (!active) return; - setState({ - status: "ready", - matches: { + if (!requestKey || !cacheKey) return undefined; + + if (peekDifferentialCache(cacheKey)) { + touchDifferentialCache(cacheKey); + return undefined; + } + + const controller = new AbortController(); + const timer = window.setTimeout(() => { + const encoded = encodeURIComponent(requestKey); + Promise.all([ + fetch(`/api/differentials?kind=diagnosis&q=${encoded}&limit=20`, { + headers: authorizationHeader, + signal: controller.signal, + }), + fetch(`/api/differentials?kind=presentation&q=${encoded}&limit=10`, { + headers: authorizationHeader, + signal: controller.signal, + }), + ]) + .then(async ([diagnosisResponse, presentationResponse]) => { + if (controller.signal.aborted) return; + if (diagnosisResponse.status === 401 || presentationResponse.status === 401) { + if (authStatus === "loading") return; + if (authStatus === "authenticated") markSessionExpired(); + setState({ status: "unauthorized", matches: emptyDifferentialMatches, demoMode: false }); + return; + } + if (!diagnosisResponse.ok || !presentationResponse.ok) { + setState({ status: "error", matches: emptyDifferentialMatches, demoMode: false }); + return; + } + const diagnosisPayload = (await diagnosisResponse.json()) as { + matches?: DifferentialSearchMatches["diagnoses"]; + demoMode?: boolean; + }; + const presentationPayload = (await presentationResponse.json()) as { + matches?: DifferentialSearchMatches["presentations"]; + demoMode?: boolean; + }; + if (controller.signal.aborted) return; + const matches: DifferentialSearchMatches = { diagnoses: diagnosisPayload.matches ?? [], presentations: presentationPayload.matches ?? [], - }, - demoMode: Boolean(diagnosisPayload.demoMode || presentationPayload.demoMode), + }; + const demoMode = Boolean(diagnosisPayload.demoMode || presentationPayload.demoMode); + writeDifferentialCache(cacheKey, { matches, demoMode }); + setState({ status: "ready", matches, demoMode }); + }) + .catch((error: unknown) => { + if (controller.signal.aborted || (error instanceof DOMException && error.name === "AbortError")) return; + setState({ status: "error", matches: emptyDifferentialMatches, demoMode: false }); }); - }) - .catch(() => { - if (active) setState({ status: "error", matches: emptyDifferentialMatches, demoMode: false }); - }); + }, debounceMs); + return () => { - active = false; + window.clearTimeout(timer); + controller.abort(); }; - }, [requestKey, authStatus, authorizationHeader, markSessionExpired]); + }, [requestKey, cacheKey, authStatus, authorizationHeader, markSessionExpired]); + if (!requestKey) { + return { status: "ready", matches: emptyDifferentialMatches, demoMode: false }; + } + if (cached && state.status !== "unauthorized" && state.status !== "error") { + return { status: "ready", matches: cached.matches, demoMode: cached.demoMode }; + } return state; } diff --git a/src/components/clinical-dashboard/use-universal-search.ts b/src/components/clinical-dashboard/use-universal-search.ts index cdca941e9..fdd82387c 100644 --- a/src/components/clinical-dashboard/use-universal-search.ts +++ b/src/components/clinical-dashboard/use-universal-search.ts @@ -44,6 +44,11 @@ type UniversalSearchResult = { preferredDomains?: UniversalSearchDomain[]; }; +type UniversalSearchCacheEntry = { + value: UniversalSearchResult; + expiresAt: number; +}; + const debounceMs = 250; const minQueryLength = 2; @@ -51,8 +56,10 @@ const minQueryLength = 2; // re-hitting the server. Module-scoped so the phone and tablet+ command surfaces share it. The // key includes the auth signature, so one identity's cached results are never served to another // (a signed-out user has a different key than the signed-in session that produced them). -const resultCacheMax = 50; -const resultCache = new Map(); +// Entries expire after resultCacheTtlMs so a long session cannot retain stale typeahead forever. +const resultCacheMax = 100; +const resultCacheTtlMs = 5 * 60 * 1000; +const resultCache = new Map(); function cacheKeyFor( query: string, @@ -68,26 +75,40 @@ function cacheKeyFor( // Non-mutating read used during render (must stay pure — no recency side effect here). function peekResultCache(key: string): UniversalSearchResult | undefined { - return resultCache.get(key); + const cached = resultCache.get(key); + if (!cached) return undefined; + if (cached.expiresAt <= Date.now()) { + resultCache.delete(key); + return undefined; + } + return cached.value; } // Recency bump for a cache hit, called from the effect (not render) to keep render pure. function touchResultCache(key: string) { const cached = resultCache.get(key); - if (!cached) return; + if (!cached || cached.expiresAt <= Date.now()) { + if (cached) resultCache.delete(key); + return; + } resultCache.delete(key); resultCache.set(key, cached); } function writeResultCache(key: string, value: UniversalSearchResult) { resultCache.delete(key); - resultCache.set(key, value); + resultCache.set(key, { value, expiresAt: Date.now() + resultCacheTtlMs }); if (resultCache.size > resultCacheMax) { const oldest = resultCache.keys().next().value; if (oldest !== undefined) resultCache.delete(oldest); } } +/** Test-only: clear the module-scoped universal search LRU between cases. */ +export function clearUniversalSearchCacheForTests() { + resultCache.clear(); +} + /** * Cross-entity typeahead for the command surface: debounced GET * /api/search/universal excluding the active mode's own domain (its results @@ -134,7 +155,7 @@ export function useUniversalSearch(args: { // Instant path: a previously fetched query needs no fetch. The render below reads the cache // directly (setState in an effect body would force a cascading render), so only bump recency // and invalidate any older in-flight request here. - if (resultCache.has(key)) { + if (peekResultCache(key)) { touchResultCache(key); requestSeqRef.current += 1; return undefined; diff --git a/src/components/forms/forms-search-results-page.tsx b/src/components/forms/forms-search-results-page.tsx index eb978d0f6..8fa1990f3 100644 --- a/src/components/forms/forms-search-results-page.tsx +++ b/src/components/forms/forms-search-results-page.tsx @@ -16,7 +16,7 @@ import { Workflow, type LucideIcon, } from "lucide-react"; -import { useId, useMemo, useState } from "react"; +import { useId, useMemo, useState, useDeferredValue } from "react"; import { appModeHomeHref } from "@/lib/app-modes"; import { formCatalogDetails, rankFormRecords, type FormSearchMatch } from "@/lib/form-ranker"; @@ -620,9 +620,10 @@ function FormsSearchResultsPageContent({ query }: FormsSearchResultsPageProps) { const registryReady = registry.status === "ready"; const [refineOpen, setRefineOpen] = useState(false); const refinePanelId = useId(); + const deferredQuery = useDeferredValue(query); const matches = useMemo( - () => (registryReady ? rankFormRecords(registry.records, query) : []), - [registryReady, registry.records, query], + () => (registryReady ? rankFormRecords(registry.records, deferredQuery) : []), + [registryReady, registry.records, deferredQuery], ); const scopedMatches = useMemo(() => { const scopes = command?.commandScopes ?? []; diff --git a/src/components/formulation/formulation-builder-page.tsx b/src/components/formulation/formulation-builder-page.tsx index f5bae5b0f..63ffe1320 100644 --- a/src/components/formulation/formulation-builder-page.tsx +++ b/src/components/formulation/formulation-builder-page.tsx @@ -15,7 +15,7 @@ import { Target, Waypoints, } from "lucide-react"; -import { useMemo, useState } from "react"; +import { useMemo, useState, useDeferredValue } from "react"; import { FormulationBreadcrumbs, @@ -206,6 +206,7 @@ export function FormulationBuilderPage({ const [activeStep, setActiveStep] = useState("select"); const [selectedIds, setSelectedIds] = useState(() => normalizeMechanismSelection(initialMechanisms)); const [query, setQuery] = useState(""); + const deferredQuery = useDeferredValue(query); const [domain, setDomain] = useState("all"); const [templateId, setTemplateId] = useState(validInitialTemplate); const [sectionNotes, setSectionNotes] = useState>({}); @@ -221,8 +222,8 @@ export function FormulationBuilderPage({ [selectedIds], ); const visibleMechanisms = useMemo( - () => searchFormulationMechanisms(query, { domain }).map((result) => result.mechanism), - [domain, query], + () => searchFormulationMechanisms(deferredQuery, { domain }).map((result) => result.mechanism), + [domain, deferredQuery], ); const activeSections = formulationSectionsForTemplate(templateId); const generatedDraft = formulationDraftFor({ diff --git a/src/components/formulation/formulation-home-page.tsx b/src/components/formulation/formulation-home-page.tsx index 400b4cf3c..dc679fd38 100644 --- a/src/components/formulation/formulation-home-page.tsx +++ b/src/components/formulation/formulation-home-page.tsx @@ -1,7 +1,7 @@ "use client"; import Link from "next/link"; -import { useMemo, useState } from "react"; +import { useMemo, useState, useDeferredValue } from "react"; import { ArrowRight, CheckCircle2, @@ -177,7 +177,11 @@ function EmptySearchResults({ query }: { query: string }) { function FormulationResults({ query }: { query: string }) { const [domain, setDomain] = useState("all"); - const results = useMemo(() => searchFormulationMechanisms(query, { domain }), [domain, query]); + const deferredQuery = useDeferredValue(query); + const results = useMemo( + () => searchFormulationMechanisms(deferredQuery, { domain }), + [domain, deferredQuery], + ); return ( diff --git a/src/components/services/services-navigator-page.tsx b/src/components/services/services-navigator-page.tsx index a9b0f5a71..16b357b99 100644 --- a/src/components/services/services-navigator-page.tsx +++ b/src/components/services/services-navigator-page.tsx @@ -19,7 +19,7 @@ import { X, type LucideIcon, } from "lucide-react"; -import { useMemo, useState } from "react"; +import { useMemo, useState, useDeferredValue } from "react"; import { cn } from "@/components/ui-primitives"; import { ModeHomeStatusNotice } from "@/components/mode-home-template"; @@ -533,6 +533,7 @@ export function ServicesNavigatorPage() { const initialQuery = urlQuery || defaultQuery; const [localQuery, setLocalQuery] = useState(() => ({ urlQuery, value: initialQuery })); const query = localQuery.urlQuery === urlQuery ? localQuery.value : initialQuery; + const deferredQuery = useDeferredValue(query); const registry = useRegistryRecords("service"); const registryLoading = registry.status === "loading"; // Demo mode is served by the registry API as status "ready" with fixture @@ -544,9 +545,9 @@ export function ServicesNavigatorPage() { [registry.records, registry.status], ); const matches = useMemo(() => { - const ranked = rankServiceRecords(searchableRecords, query); - return ranked.length ? ranked.map((match) => match.service) : query.trim() ? [] : searchableRecords; - }, [query, searchableRecords]); + const ranked = rankServiceRecords(searchableRecords, deferredQuery); + return ranked.length ? ranked.map((match) => match.service) : deferredQuery.trim() ? [] : searchableRecords; + }, [deferredQuery, searchableRecords]); const scopedMatches = useMemo(() => { const scopes = command?.commandScopes ?? []; if (!scopes.length) return matches; diff --git a/src/components/therapy-compass/bindings.tsx b/src/components/therapy-compass/bindings.tsx index f62dbd26d..f7a736de4 100644 --- a/src/components/therapy-compass/bindings.tsx +++ b/src/components/therapy-compass/bindings.tsx @@ -1,6 +1,6 @@ "use client"; -import { createContext, useContext, useMemo, useState, type ReactNode } from "react"; +import { createContext, useContext, useMemo, useState, useDeferredValue, type ReactNode } from "react"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { useTherapyData } from "./data/use-therapy-data"; @@ -269,7 +269,8 @@ export function TcProvider({ children }: { children: ReactNode }) { const effectivePathwaySlug = selectedPathwaySlug ?? pathways[0]?.slug ?? null; const selectedPathway = effectivePathwaySlug ? (pathways.find((p) => p.slug === effectivePathwaySlug) ?? null) : null; - const searchResults = useMemo(() => searchTherapies(therapies, search), [therapies, search]); + const deferredSearch = useDeferredValue(search); + const searchResults = useMemo(() => searchTherapies(therapies, deferredSearch), [therapies, deferredSearch]); const compareTherapies = useMemo( () => compareSlugs.map((sl) => bySlug.get(sl)).filter((t): t is Therapy => Boolean(t)), [compareSlugs, bySlug], diff --git a/tests/use-differential-search.dom.test.tsx b/tests/use-differential-search.dom.test.tsx new file mode 100644 index 000000000..5252dce05 --- /dev/null +++ b/tests/use-differential-search.dom.test.tsx @@ -0,0 +1,134 @@ +import { act, renderHook } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + clearDifferentialSearchCacheForTests, + useDifferentialSearch, +} from "@/components/clinical-dashboard/use-differential-catalog"; + +const authSession = vi.hoisted(() => ({ + authorizationHeader: { Authorization: "Bearer differential-search-test" }, + markSessionExpired: vi.fn(), + status: "authenticated" as const, +})); + +vi.mock("@/lib/supabase/client", () => ({ + useAuthSession: () => authSession, +})); + +let fetchMock: ReturnType>; + +beforeEach(() => { + vi.useFakeTimers(); + clearDifferentialSearchCacheForTests(); + authSession.markSessionExpired.mockReset(); + fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + clearDifferentialSearchCacheForTests(); +}); + +function jsonResponse(body: unknown, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +async function advanceDebounce() { + await act(async () => { + await vi.advanceTimersByTimeAsync(250); + }); +} + +async function flushMicrotasks() { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); +} + +describe("useDifferentialSearch debounce/abort/cache", () => { + it("debounces fetches, aborts superseded keystrokes, and serves LRU cache hits", async () => { + const diagnosisMatch = { + record: { slug: "major-depressive-disorder", title: "Major depressive disorder" }, + score: 12, + reasons: ["title"], + }; + const presentationMatch = { + workflow: { slug: "low-mood", title: "Low mood" }, + score: 9, + reasons: ["title"], + }; + const requestSignals: AbortSignal[] = []; + fetchMock.mockImplementation((_input, init) => { + requestSignals.push(init?.signal as AbortSignal); + const url = String(_input); + if (url.includes("kind=diagnosis")) { + return Promise.resolve(jsonResponse({ matches: [diagnosisMatch], demoMode: true })); + } + return Promise.resolve(jsonResponse({ matches: [presentationMatch], demoMode: true })); + }); + + const { result, rerender } = renderHook(({ query }) => useDifferentialSearch(query), { + initialProps: { query: "dep" }, + }); + expect(result.current.status).toBe("loading"); + expect(fetchMock).not.toHaveBeenCalled(); + + await advanceDebounce(); + await flushMicrotasks(); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(result.current).toMatchObject({ + status: "ready", + demoMode: true, + matches: { + diagnoses: [diagnosisMatch], + presentations: [presentationMatch], + }, + }); + + rerender({ query: "depre" }); + expect(result.current.status).toBe("loading"); + await flushMicrotasks(); + expect(requestSignals[0]?.aborted).toBe(true); + expect(requestSignals[1]?.aborted).toBe(true); + + await advanceDebounce(); + await flushMicrotasks(); + expect(fetchMock).toHaveBeenCalledTimes(4); + expect(result.current.status).toBe("ready"); + + // Revisiting the first query hits the auth-keyed LRU and skips the network. + const callsBeforeCacheHit = fetchMock.mock.calls.length; + rerender({ query: "dep" }); + await flushMicrotasks(); + await advanceDebounce(); + await flushMicrotasks(); + expect(fetchMock.mock.calls.length).toBe(callsBeforeCacheHit); + expect(result.current).toMatchObject({ + status: "ready", + matches: { + diagnoses: [diagnosisMatch], + presentations: [presentationMatch], + }, + }); + }); + + it("does not fetch for empty queries", async () => { + const { result } = renderHook(() => useDifferentialSearch(" ")); + await advanceDebounce(); + expect(fetchMock).not.toHaveBeenCalled(); + expect(result.current).toEqual({ + status: "ready", + matches: { diagnoses: [], presentations: [] }, + demoMode: false, + }); + }); +}); diff --git a/tests/use-universal-search-stream.dom.test.tsx b/tests/use-universal-search-stream.dom.test.tsx index 83f990563..e10742a1c 100644 --- a/tests/use-universal-search-stream.dom.test.tsx +++ b/tests/use-universal-search-stream.dom.test.tsx @@ -1,7 +1,7 @@ import { act, renderHook } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { useUniversalSearch } from "@/components/clinical-dashboard/use-universal-search"; +import { useUniversalSearch, clearUniversalSearchCacheForTests } from "@/components/clinical-dashboard/use-universal-search"; import type { UniversalSearchGroup, UniversalSearchResponse } from "@/lib/universal-search"; import type { UniversalSearchStreamEvent } from "@/lib/universal-search-stream"; @@ -77,6 +77,7 @@ let fetchMock: ReturnType>; beforeEach(() => { vi.useFakeTimers(); + clearUniversalSearchCacheForTests(); fetchMock = vi.fn(); vi.stubGlobal("fetch", fetchMock); }); @@ -85,6 +86,7 @@ afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals(); vi.restoreAllMocks(); + clearUniversalSearchCacheForTests(); }); describe("useUniversalSearch NDJSON integration", () => { From 46597a9bfbc2c4126b4adcb837cc23ccc752b134 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 24 Jul 2026 05:31:13 +0000 Subject: [PATCH 02/15] perf(ui): extract deferred registry search to stay under budget Move services/forms deferred ranking out of ClinicalDashboard into use-deferred-registry-search so the maintainability no-growth budget passes, and update the client performance boundary contract. Co-authored-by: BigSimmo --- src/components/ClinicalDashboard.tsx | 29 +++--------- .../document-search-results.tsx | 10 ++++- .../use-deferred-registry-search.ts | 45 +++++++++++++++++++ .../formulation/formulation-home-page.tsx | 5 +-- tests/client-performance-boundaries.test.ts | 9 +++- 5 files changed, 66 insertions(+), 32 deletions(-) create mode 100644 src/components/clinical-dashboard/use-deferred-registry-search.ts diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index d369b7b25..abe371b68 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -26,7 +26,6 @@ import { type CSSProperties, type KeyboardEvent as ReactKeyboardEvent, useCallback, - useDeferredValue, useEffect, useMemo, useReducer, @@ -197,9 +196,7 @@ import { import { persistPrivateSearchScope, restorePrivateSearchScope } from "@/lib/private-search-scope"; import { parseApiErrorResponse } from "@/lib/api-client-error"; import { answerLifecycleReducer, initialAnswerLifecycle } from "@/lib/answer-lifecycle"; -import { rankFormRecords } from "@/lib/form-ranker"; -import { rankServiceRecords } from "@/lib/service-ranker"; -import { useRegistryRecords } from "@/lib/use-registry-records"; +import { useDeferredRegistrySearch } from "@/components/clinical-dashboard/use-deferred-registry-search"; import { buildAnswerFollowUpQuery, buildAnswerFollowUpSuggestions } from "@/lib/answer-follow-up"; import { clearPersistedAnswerThread, @@ -432,25 +429,9 @@ export function ClinicalDashboard({ const [restoredPrivateScopeRef, setRestoredPrivateScopeRef] = useState(null); // Record matches come from the owner-scoped registry API (mock fixtures in - // demo mode); ranking stays client-side so live-typing behaviour is - // unchanged and the registry is fetched once per active mode. - const registryRecords = useRegistryRecords(searchMode === "forms" ? "form" : "service", { - enabled: searchMode === "services" || searchMode === "forms", - }); - const deferredQuery = useDeferredValue(query); - const serviceSearchMatches = useMemo( - () => (searchMode === "services" ? rankServiceRecords(registryRecords.records, deferredQuery) : []), - [deferredQuery, searchMode, registryRecords.records], - ); - const formSearchMatches = useMemo( - () => (searchMode === "forms" ? rankFormRecords(registryRecords.records, deferredQuery) : []), - [deferredQuery, searchMode, registryRecords.records], - ); - const recordSearchMatches = useMemo( - () => (searchMode === "forms" ? formSearchMatches : searchMode === "services" ? serviceSearchMatches : []), - [searchMode, formSearchMatches, serviceSearchMatches], - ); - const recordSearchMode = searchMode === "forms" ? "forms" : "services"; + // demo mode); ranking stays client-side (deferred) so live-typing stays + // responsive and the registry is fetched once per active mode. + const { recordSearchMatches, recordSearchMode, recordStatus } = useDeferredRegistrySearch(searchMode, query); // The thread mirror ref must never outlive the answer it describes: every // reset path nulls `answer`, so clearing here covers them all (mode // switches, new chat, differentials/services clears) without each caller @@ -3752,7 +3733,7 @@ export function ClinicalDashboard({ matches={documentMatches} recordMatches={recordSearchMatches} recordMode={recordSearchMode} - recordStatus={registryRecords.status} + recordStatus={recordStatus} showRecordMatches={searchMode === "services" || searchMode === "forms"} query={query} loading={loading} diff --git a/src/components/clinical-dashboard/document-search-results.tsx b/src/components/clinical-dashboard/document-search-results.tsx index 46c0fe9d6..178f0440d 100644 --- a/src/components/clinical-dashboard/document-search-results.tsx +++ b/src/components/clinical-dashboard/document-search-results.tsx @@ -880,7 +880,10 @@ function DocumentSearchResultsPanelImpl({ // Progressive reveal so large libraries do not mount every card on first paint. // Reset the window whenever the sorted result set identity changes (query/filter/sort). const resultsSignature = `${trimmedQuery}\0${sortValue}\0${effectiveResultType}\0${activeFacetKeys.join(",")}\0${sortedMatches.length}`; - const [visibleCountState, setVisibleCountState] = useState({ signature: resultsSignature, count: DOCUMENT_RESULTS_INITIAL_WINDOW }); + const [visibleCountState, setVisibleCountState] = useState({ + signature: resultsSignature, + count: DOCUMENT_RESULTS_INITIAL_WINDOW, + }); if (visibleCountState.signature !== resultsSignature) { setVisibleCountState({ signature: resultsSignature, count: DOCUMENT_RESULTS_INITIAL_WINDOW }); } @@ -1151,7 +1154,10 @@ function DocumentSearchResultsPanelImpl({ {hasMoreMatches ? (