diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index c2bde1d63..8717bb536 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -715,6 +715,12 @@ This file is append-only. Never rewrite or delete an existing review record; app | 2026-07-21 | claude/clinical-kb-pwa-review-asi3wb (Option A wave verdict — no code change; #1040 merged as cde6c5c) | canary run 29827012719 (#61, main cde6c5c) vs banked #60 (29800029819) | OPTION A WAVE ADOPTED — FIRST FULLY-GREEN 44-CASE CANARY IN PROGRAM HISTORY (Blocking failures: None). (1) Option A payoff EXCEEDED: citation_failure_rate 0.0227→0; the neuroleptic-side-effect-escalation case flipped from wrong-doc→failed-generation→1-citation-fallback to **strong route, successful gpt-5.6-sol generation, passed in 15.4s with no fallback marker** — the rescued S3 retrieval fixed generation itself, not just the citation count; expected_source_hit 0.6364→0.6591. (2) Golden held exactly as the blast-radius analysis promised: 36/36 PASS, content_recall 1.0, mrr@10 0.8921 BYTE-IDENTICAL to the pre-wave baseline (zero ordering movement — no golden case fires the predicate), irrelevant@10 0.1083→0.0917 (slightly better). (3) Parity payoff PARTIAL: monitoring targeting 1/5→2/5 (olanzapine-lai flipped — previously called a retrieval-depth residual; quetiapine-dose also flipped on the dose side); lithium-range (232ch) + metabolic (73ch, byte-identical answer to #60) did NOT flip despite offline-proven fixes — their live chunk sets evidently contain no admissible schedule sentence even under the widened gate → reclassified as retrieval-depth/live-content residuals joining adhd; below the ≥3/5 target but strictly improved, no regression anywhere. Dose 2/5 vs 2/4: same passing count, applicable set grew (new quality-metformin-renal-dosing miss = eval-set churn, not regression). (4) No-worse EXCEEDED: relevance 0.5333→0.6 (the two-step watch-item slide FULLY REVERSED to the #58 level), targeting_rate 0.6667→0.6957, fail_closed 0.9 held, readability/artifact_leaks 1.0, route ceilings 0, grounded 1.0, unsupported_correct 1.0, numeric 0, p95 22.8s, red_result 3/3. Adoption per the measured-gain rule: primary goal achieved, three case flips, relevance recovered, zero regressions. Residual queue: monitoring retrieval-depth trio (lithium-range/metabolic/adhd), E-3d H2 discards, weekly ANSWER_CASE_LIMIT 8→44 raise now unblocked (gate would be green), comparison-class coverage. Wave spend +~$2-4 → Phase E + Option A total ~$12-20 of ≤$20. | Evidence: run #61 job log read (Threshold Status: None; Answer Metrics; neuroleptic diagnostics row; targeting metric_rates + 6-miss list; golden 36 PASS lines + summary). Revert drill NOT triggered. | | 2026-07-21 | claude/clinical-kb-pwa-review-asi3wb (PR: I9 weekly coverage raise) | see PR head | ADDENDUM 5 post-green item I9 (plan-authorized "after reds fixed"): weekly scheduled canary ANSWER_CASE_LIMIT default 8→44 — the Sunday 18:00 UTC cron now guards the FULL answer-quality case set instead of the first 8 (both #57 blocking reds historically lived OUTSIDE the first 8, leaving the weekly gate blind to them). Unblocked by run #61 proving the citation gate green on the full 44. Cost: est +$1-2/week (user-authorized in the plan). Contract test pin updated in lockstep (eval-canary-workflow.test.ts). Dispatch shapes unchanged (input override still wins); operational-risk diff, plain-revert rollback. | check:github-actions PASS; check:ci-scope PASS; check:gate-manifest PASS (20/20); eval-canary-workflow contract 4/4; prettier clean; no provider calls | | 2026-07-21 | claude/database-governance-audit-10b6ed (PR #1051: source-governance audit — safe subset) | cee396730 | Governance-metadata observability + UI display + provenance flow test; no ranking/retrieval/generation surface touched. | IMPLEMENTED + handed off (not a review of prior work). Resolved audit #1 (logger.warn on unrecognized enum values; return value unchanged), #2 (review_due_source added to frontendVisibleWarningCodes → answer-level badge; warning-severity, no refusal impact), #9 (source_metadata retained on safety-finding citations + governance pill in SafetyFindingsListContent), #13 (new tests/provenance-flow.test.ts: DB-normalize→governance→client payload sources+safety citations→render policy). Deferred #4/5/6/8/10 (RAG-protected ranking/selection/LLM-context/cache — need live eval-canary+approval), #11/#5 flag debt (D5/D4), #3 (is_public schema/RLS), #7 (conflict-detection scope), #12 (canary automation). Rebased onto origin/main (was 18 behind; conflict-free — none of the 18 commits touched the 8 files). PR-policy CI green (confirmed no ragRankingPatterns match). | verify:pr-local exit 0 (351 files/3129 tests, production build, client-bundle secret scan, offline RAG fixtures 36/36); typecheck + lint + prettier green. verify:ui NOT run locally: pre-existing globals.css Tailwind/Turbopack dev-compile error (git-clean, unrelated; prod build passed) — CI Production UI job covers it. check:production-readiness deferred (offline env/config validator; PR changes no env/secret/config inputs; secretless worktree). No provider calls. | +| 2026-07-24 | cursor/search-interactive-perf-af54 (PR #1138) | 46597a9b | Explicit performance + frontend-ui review of search/interactive surfaces; low-risk client deferral/cache/abort/progressive-reveal pass | Prior document/universal search latency work retained (NDJSON stream, LRU, lazy PDF, content-first detail). New work: differential debounce+abort+LRU; useDeferredValue on catalogue ranking; document results Show more window; RelatedDocumentsPanel memo; universal LRU 100+TTL; deferred registry search extracted from ClinicalDashboard. No RAG/retrieval/ranking edits. No high-confidence P0–P2 defect found in the shipped scope; residual risk = deferred paint lag on large catalogues and progressive reveal missing deep cards until Show more. | Focused Vitest 10/10 (differential + universal + performance boundaries); verify:cheap exit 0 (3262 tests); typecheck clean; verify:ui exit 0 (Chromium). No provider calls. | +| 2026-07-24 | cursor/search-interactive-perf-af54 (PR #1138 follow-up) | 7e2ccee0 | Bugfix pass on search interactive performance diff | Fixed P1 auth-stale differential matches; P2 progressive-reveal hiding selected card; P2 deferred empty/full-catalogue flash on services/forms/formulation/therapy-compass; Prettier CI failure on universal-search test. No remaining high-confidence P0–P2 in scoped diff. Residual: differential debounce skeleton flicker; RelatedDocumentsPanel memo limited by unstable callbacks. | Focused Vitest 11/11; typecheck; format:check; maintainability budgets. No provider calls. | +| 2026-07-24 | cursor/search-interactive-perf-af54 (PR #1138 babysit) | e27df0fa | Bugbot + merge babysit | Merged origin/main. Fixed Bugbot medium findings: deferred clear left stale service/registry matches. No unresolved review threads. | Focused Vitest (deferred registry + differential + boundaries); typecheck; maintainability budgets. Watching hosted CI. | +| 2026-07-24 | cursor/search-interactive-perf-af54 (PR #1138 merge-ready) | 36d86fd0 | Babysit closeout | CodeRabbit: stable RelatedDocuments callbacks, identity-based progressive reveal, clear differential LRU on 401, Sheet unmount focus-restore via layout flag, formulation/therapy clear. Bugbot: live therapy filters with deferred query text. CI PR required green; 0 unresolved threads. | Focused Vitest; hosted CI PR required PASS. | +| 2026-07-24 | cursor/search-interactive-perf-af54 (PR #1138 bugbot) | 8d712183 | Bugbot babysit | Fixed medium: formulation builder/home cleared live query still ranked against lagging deferredQuery. Merged main (#1137 search-chrome). No unresolved review threads. | Focused Vitest deferred registry; typecheck pending in CI. | +| 2026-07-24 | cursor/search-interactive-perf-af54 (PR #1138 babysit close) | 2f8509cc | Babysit to merge-ready | Merged main; fixed Bugbot deferred-clear findings; fixed Sheet focus-restore teardown flake that failed Unit coverage; no unresolved review threads. Hosted PR required green. | Hosted CI: Static PR, Unit coverage, Build, Production UI, Advisory UI, PR required PASS. No provider calls. | | 2026-07-24 | codex/reindex-agent-enrichment-guard (PR #1143) | f82cf9cfa | Run PR sweep: CI fix + threads + drift | Before: PR required green, 1 unresolved reindex/agent-claim serialization thread, branch behind main. After: remote branch already had atomic RPC fix 2bb0470d plus merge-main d2d57d7c; validated pushed head, resolved thread via GraphQL; reply mutation 403 noted in commit f82cf9cf. | node scripts/run-vitest.mjs run --reporter=dot tests/reindex-enrichment-lease.test.ts tests/private-access-routes.test.ts tests/supabase-schema.test.ts tests/drift-detection.test.ts PASS (218/218); npm run check:migration-role PASS; npm run typecheck PASS; npm run check:production-readiness PASS with non-blocking warnings; no live eval gates run. | ## 2026-07-24 — work search chrome behaviour review @@ -724,3 +730,4 @@ This file is append-only. Never rewrite or delete an existing review record; app - Scope: Targeted review of search bar/header/footer chrome behaviour after the edge-to-edge phone dock fix, plus durable repo rules for page-adaptive search chrome. - Outcome: No new P0/P1 search chrome defect found in the static review. Fixed one regression hazard: a stale ClinicalDashboard comment still instructed a 0.75rem hidden dock pad despite the implementation/tests requiring 0rem. Added durable search chrome behaviour rules in AGENTS.md and docs/search-chrome-behaviour.md, with a static guard tying the remembered rules to the hidden-reserve contract. - Checks: dependency shortcut section count; git diff --check; targeted rg for stale 0.75rem hidden-pad source wording (only negative test assertions remain); targeted Vitest command attempted but blocked by missing node_modules/vitest under Node 20.20.2 in this container. No provider-backed checks run. +| 2026-07-24 | cursor/search-interactive-perf-af54 (PR #1138) | ff4b293d95f922e70ebf5ee9b0c156c41a8bff3b | Run PR sweep: CI fix + threads + drift | Before: CONFLICTING, CI green, 0 threads. RAG impact: no retrieval behaviour change — PR is client deferred-search/UI only (no src/lib/rag/**). After: merged origin/main; conflict resolved in src/components/ui/sheet.tsx by keeping main restoreTimersRef/unmountingRef focus-restore fix; pushed ff4b293d9. Threads: none. | merge origin/main only; no provider-backed checks run | diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index f6be6e265..5006553b6 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -196,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, @@ -431,24 +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 serviceSearchMatches = useMemo( - () => (searchMode === "services" ? rankServiceRecords(registryRecords.records, query) : []), - [query, searchMode, registryRecords.records], - ); - const formSearchMatches = useMemo( - () => (searchMode === "forms" ? rankFormRecords(registryRecords.records, query) : []), - [query, 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 @@ -3750,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} @@ -3849,8 +3832,8 @@ export function ClinicalDashboard({ {activeModeResultKind === "answer" && answer && ( )} {(documentsDrawerOpen || uploadDrawerOpen) && ( 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..002f8f5ec 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,33 @@ 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), + // but expand far enough that an explicit selection stays visible in the list. + const resultsSignature = [ + trimmedQuery, + sortValue, + effectiveResultType, + activeFacetKeys.join(","), + sortedMatches.map((document) => document.document_id).join(","), + ].join("\0"); + const selectedIndex = selectedDocumentId + ? sortedMatches.findIndex((document) => document.document_id === selectedDocumentId) + : -1; + const minimumVisibleForSelection = + selectedIndex >= 0 ? Math.max(DOCUMENT_RESULTS_INITIAL_WINDOW, selectedIndex + 1) : DOCUMENT_RESULTS_INITIAL_WINDOW; + const [visibleCountState, setVisibleCountState] = useState({ + signature: resultsSignature, + count: minimumVisibleForSelection, + }); + if (visibleCountState.signature !== resultsSignature) { + setVisibleCountState({ signature: resultsSignature, count: minimumVisibleForSelection }); + } else if (selectedIndex >= visibleCountState.count) { + setVisibleCountState({ signature: resultsSignature, count: selectedIndex + 1 }); + } + 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 +1043,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 +1165,24 @@ function DocumentSearchResultsPanelImpl({ ); })} + {hasMoreMatches ? ( + + ) : null} {selectedDocument ? ( { + if (searchMode !== "services") return []; + // Cleared composer: drop matches immediately (do not wait on deferred lag). + if (!liveQuery) return []; + // First keystrokes may leave deferred empty — avoid empty-query "all records" ranking. + if (!deferredQuery.trim()) return []; + return rankServiceRecords(registryRecords.records, deferredQuery); + }, [deferredQuery, liveQuery, searchMode, registryRecords.records]); + + const formSearchMatches = useMemo(() => { + if (searchMode !== "forms") return []; + if (!liveQuery) return []; + if (!deferredQuery.trim()) return []; + return rankFormRecords(registryRecords.records, deferredQuery); + }, [deferredQuery, liveQuery, searchMode, registryRecords.records]); + + const recordSearchMatches = useMemo( + () => (searchMode === "forms" ? formSearchMatches : searchMode === "services" ? serviceSearchMatches : []), + [searchMode, formSearchMatches, serviceSearchMatches], + ); + return { + recordSearchMatches, + recordSearchMode: searchMode === "forms" ? "forms" : "services", + recordStatus: registryRecords.status, + }; +} diff --git a/src/components/clinical-dashboard/use-differential-catalog.ts b/src/components/clinical-dashboard/use-differential-catalog.ts index f0d757881..e1366b85f 100644 --- a/src/components/clinical-dashboard/use-differential-catalog.ts +++ b/src/components/clinical-dashboard/use-differential-catalog.ts @@ -44,75 +44,166 @@ export type DifferentialPresentationState = { governance: DifferentialRecordGovernance | null; }; +const debounceMs = 250; +const resultCacheMax = 50; +const resultCacheTtlMs = 5 * 60 * 1000; + +type DifferentialSearchCacheEntry = { + matches: DifferentialSearchMatches; + demoMode: boolean; + expiresAt: number; +}; + +// Module-scoped LRU so backspace/retype resolves instantly. Auth signature is part of +// the key so one identity's cached results are never served to another. +const differentialSearchCache = new Map(); + +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, - }); - // Reset to loading during render when the query changes (repo pattern — - // avoids react-hooks/set-state-in-effect). + 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 or auth identity changes + // (repo pattern — avoids react-hooks/set-state-in-effect). Prefer a warm cache hit. + // Auth must clear prior identity's matches immediately (parity with useUniversalSearch). const [lastRequestKey, setLastRequestKey] = useState(requestKey); - if (lastRequestKey !== requestKey) { + const [lastAuthSignature, setLastAuthSignature] = useState(authSignature); + if (lastRequestKey !== requestKey || lastAuthSignature !== authSignature) { setLastRequestKey(requestKey); - setState({ - status: requestKey ? "loading" : "ready", - matches: emptyDifferentialMatches, - demoMode: false, - }); + setLastAuthSignature(authSignature); + 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(); + // Session is invalid for this client identity — drop every cached hit so + // a later retype of any prior query cannot resurrect authorized matches. + differentialSearchCache.clear(); + 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..bc432395d 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,10 +620,15 @@ function FormsSearchResultsPageContent({ query }: FormsSearchResultsPageProps) { const registryReady = registry.status === "ready"; const [refineOpen, setRefineOpen] = useState(false); const refinePanelId = useId(); - const matches = useMemo( - () => (registryReady ? rankFormRecords(registry.records, query) : []), - [registryReady, registry.records, query], - ); + const deferredQuery = useDeferredValue(query); + const matches = useMemo(() => { + if (!registryReady) return []; + // Cleared query: no form matches (page usually remounts, but keep lag-safe). + if (!query.trim()) return []; + // Deferred empty while live has text: wait — do not rank as empty-query "all forms". + if (!deferredQuery.trim()) return []; + return rankFormRecords(registry.records, deferredQuery); + }, [registryReady, registry.records, deferredQuery, query]); const scopedMatches = useMemo(() => { const scopes = command?.commandScopes ?? []; if (!scopes.length) return matches; @@ -649,7 +654,7 @@ function FormsSearchResultsPageContent({ query }: FormsSearchResultsPageProps) { onSortChange={setSortValue} /> - {query.trim() && displayedMatches.length === 0 ? ( + {query.trim() && deferredQuery === query && displayedMatches.length === 0 ? ( ("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>({}); @@ -220,10 +221,15 @@ export function FormulationBuilderPage({ .filter((mechanism): mechanism is FormulationMechanism => Boolean(mechanism)), [selectedIds], ); - const visibleMechanisms = useMemo( - () => searchFormulationMechanisms(query, { domain }).map((result) => result.mechanism), - [domain, query], - ); + const visibleMechanisms = useMemo(() => { + // Cleared live query should restore the full browse catalogue immediately. + if (!query.trim()) { + return searchFormulationMechanisms("", { domain }).map((result) => result.mechanism); + } + // Empty deferred while live query has text would score every mechanism. + if (!deferredQuery.trim()) return []; + return searchFormulationMechanisms(deferredQuery, { domain }).map((result) => result.mechanism); + }, [domain, deferredQuery, query]); const activeSections = formulationSectionsForTemplate(templateId); const generatedDraft = formulationDraftFor({ mechanisms: selectedMechanisms, diff --git a/src/components/formulation/formulation-home-page.tsx b/src/components/formulation/formulation-home-page.tsx index 400b4cf3c..f8ec73edc 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,16 @@ 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 rankingReady = deferredQuery === query; + const results = useMemo(() => { + // Cleared live query should restore the full browse catalogue immediately. + if (!query.trim()) return searchFormulationMechanisms("", { domain }); + // Empty deferred while live query has text would score every mechanism — + // treat that lag as "no results yet" instead of dumping the full catalogue. + if (!deferredQuery.trim()) return []; + return searchFormulationMechanisms(deferredQuery, { domain }); + }, [domain, deferredQuery, query]); return ( @@ -234,9 +243,9 @@ function FormulationResults({ query }: { query: string }) { - {results.length === 0 ? ( + {results.length === 0 && rankingReady ? ( - ) : ( + ) : results.length === 0 ? null : (
{results.map(({ mechanism }, index) => (
({ 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 @@ -547,9 +548,15 @@ 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]); + // Cleared live query should restore the full catalogue immediately, even if + // deferredQuery still holds the previous term for a frame. + if (!query.trim()) return searchableRecords; + const ranked = rankServiceRecords(searchableRecords, deferredQuery); + if (ranked.length) return ranked.map((match) => match.service); + // Deferred empty while the live query has text means ranking is lagging — + // never dump the full catalogue as if the box were cleared. + return []; + }, [deferredQuery, query, searchableRecords]); const scopedMatches = useMemo(() => { const scopes = command?.commandScopes ?? []; if (!scopes.length) return matches; @@ -635,7 +642,7 @@ export function ServicesNavigatorPage() { body="The services registry could not be loaded. Try again shortly." /> ) - ) : query.trim() && displayedMatches.length === 0 ? ( + ) : query.trim() && deferredQuery === query && displayedMatches.length === 0 ? ( p.slug === effectivePathwaySlug) ?? null) : null; - const searchResults = useMemo(() => searchTherapies(therapies, search), [therapies, search]); + const deferredSearch = useDeferredValue(search); + const searchResults = useMemo(() => { + const liveQuery = search.query.trim(); + const deferredQuery = deferredSearch.query.trim(); + // Cleared live query should browse with live filters immediately (avoid stale + // deferred tags/flags from the previous term). + if (!liveQuery) { + return searchTherapies(therapies, search); + } + // First keystrokes: deferred text may still be empty — never dump the full library. + if (!deferredQuery) return []; + // Defer only the text cost; apply live filter chips/flags immediately so toggles + // match aria-pressed state without waiting for useDeferredValue. + return searchTherapies(therapies, { ...search, query: deferredSearch.query }); + }, [therapies, deferredSearch.query, search]); const compareTherapies = useMemo( () => compareSlugs.map((sl) => bySlug.get(sl)).filter((t): t is Therapy => Boolean(t)), [compareSlugs, bySlug], diff --git a/tests/client-performance-boundaries.test.ts b/tests/client-performance-boundaries.test.ts index ed3d2b525..f10e792d7 100644 --- a/tests/client-performance-boundaries.test.ts +++ b/tests/client-performance-boundaries.test.ts @@ -25,8 +25,13 @@ describe("fixture-free client performance boundaries", () => { it("keeps initial dashboard rankers on fixture-free entry points", () => { const dashboard = source("src/components/ClinicalDashboard.tsx"); - expect(dashboard).toContain('from "@/lib/form-ranker"'); - expect(dashboard).toContain('from "@/lib/service-ranker"'); + const deferredRegistrySearch = source("src/components/clinical-dashboard/use-deferred-registry-search.ts"); + expect(dashboard).toContain('from "@/components/clinical-dashboard/use-deferred-registry-search"'); + expect(dashboard).not.toContain('from "@/lib/form-ranker"'); + expect(dashboard).not.toContain('from "@/lib/service-ranker"'); + expect(deferredRegistrySearch).toContain('from "@/lib/form-ranker"'); + expect(deferredRegistrySearch).toContain('from "@/lib/service-ranker"'); + expect(deferredRegistrySearch).toContain("useDeferredValue"); expect(source("src/lib/cross-mode-links.ts")).not.toMatch(/@\/lib\/(forms|services)"/); }); diff --git a/tests/use-deferred-registry-search.dom.test.tsx b/tests/use-deferred-registry-search.dom.test.tsx new file mode 100644 index 000000000..0723669ab --- /dev/null +++ b/tests/use-deferred-registry-search.dom.test.tsx @@ -0,0 +1,62 @@ +import { act, renderHook } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { useDeferredRegistrySearch } from "@/components/clinical-dashboard/use-deferred-registry-search"; + +vi.mock("@/lib/use-registry-records", () => ({ + useRegistryRecords: () => ({ + status: "ready", + records: [ + { + id: "svc-1", + slug: "crisis-line", + title: "Crisis Line", + summary: "24/7 crisis support", + status: "available", + locality: "local", + searchText: "crisis line support", + }, + { + id: "svc-2", + slug: "housing", + title: "Housing Support", + summary: "Housing pathway", + status: "available", + locality: "local", + searchText: "housing support", + }, + ], + }), +})); + +vi.mock("@/lib/service-ranker", async () => { + const actual = await vi.importActual("@/lib/service-ranker"); + return actual; +}); + +vi.mock("@/lib/form-ranker", async () => { + const actual = await vi.importActual("@/lib/form-ranker"); + return actual; +}); + +describe("useDeferredRegistrySearch clear behavior", () => { + it("drops service matches immediately when the live query is cleared", async () => { + const { result, rerender } = renderHook(({ query }) => useDeferredRegistrySearch("services", query), { + initialProps: { query: "crisis" }, + }); + + await act(async () => { + await Promise.resolve(); + }); + + // Allow deferred value to settle on the typed query. + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + expect(result.current.recordSearchMatches.length).toBeGreaterThan(0); + + rerender({ query: "" }); + expect(result.current.recordSearchMatches).toEqual([]); + }); +}); diff --git a/tests/use-differential-search.dom.test.tsx b/tests/use-differential-search.dom.test.tsx new file mode 100644 index 000000000..a5dfddabe --- /dev/null +++ b/tests/use-differential-search.dom.test.tsx @@ -0,0 +1,207 @@ +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(); + authSession.authorizationHeader = { Authorization: "Bearer differential-search-test" }; + authSession.status = "authenticated"; + 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, + }); + }); + + it("clears prior matches immediately when the auth identity changes", async () => { + const diagnosisMatch = { + record: { slug: "major-depressive-disorder", title: "Major depressive disorder" }, + score: 12, + reasons: ["title"], + }; + fetchMock.mockImplementation((_input) => { + const url = String(_input); + if (url.includes("kind=diagnosis")) { + return Promise.resolve(jsonResponse({ matches: [diagnosisMatch], demoMode: false })); + } + return Promise.resolve(jsonResponse({ matches: [], demoMode: false })); + }); + + const { result, rerender } = renderHook(() => useDifferentialSearch("depression")); + await advanceDebounce(); + await flushMicrotasks(); + expect(result.current.status).toBe("ready"); + expect(result.current.matches.diagnoses).toEqual([diagnosisMatch]); + + authSession.authorizationHeader = { Authorization: "Bearer other-user" }; + rerender(); + expect(result.current.status).toBe("loading"); + expect(result.current.matches).toEqual({ diagnoses: [], presentations: [] }); + + await advanceDebounce(); + await flushMicrotasks(); + expect(result.current.status).toBe("ready"); + }); + + it("clears the search LRU on 401 so prior authorized hits cannot resurface", async () => { + const diagnosisMatch = { + record: { slug: "major-depressive-disorder", title: "Major depressive disorder" }, + score: 12, + reasons: ["title"], + }; + fetchMock.mockImplementation((_input) => { + const url = String(_input); + if (url.includes("kind=diagnosis")) { + return Promise.resolve(jsonResponse({ matches: [diagnosisMatch], demoMode: false })); + } + return Promise.resolve(jsonResponse({ matches: [], demoMode: false })); + }); + + const { result, rerender } = renderHook(({ q }) => useDifferentialSearch(q), { + initialProps: { q: "depression" }, + }); + await advanceDebounce(); + await flushMicrotasks(); + expect(result.current.status).toBe("ready"); + expect(result.current.matches.diagnoses).toEqual([diagnosisMatch]); + + fetchMock.mockImplementation(() => Promise.resolve(jsonResponse({ error: "unauthorized" }, 401))); + rerender({ q: "anxiety" }); + await advanceDebounce(); + await flushMicrotasks(); + expect(result.current.status).toBe("unauthorized"); + expect(authSession.markSessionExpired).toHaveBeenCalled(); + + // Same identity + prior query must not resurrect the pre-401 cache entry. + fetchMock.mockClear(); + fetchMock.mockImplementation(() => Promise.resolve(jsonResponse({ error: "unauthorized" }, 401))); + rerender({ q: "depression" }); + expect(result.current.status).toBe("loading"); + expect(result.current.matches.diagnoses).toEqual([]); + await advanceDebounce(); + await flushMicrotasks(); + expect(result.current.status).toBe("unauthorized"); + expect(fetchMock).toHaveBeenCalled(); + }); +}); diff --git a/tests/use-universal-search-stream.dom.test.tsx b/tests/use-universal-search-stream.dom.test.tsx index 83f990563..eb934fae6 100644 --- a/tests/use-universal-search-stream.dom.test.tsx +++ b/tests/use-universal-search-stream.dom.test.tsx @@ -1,7 +1,10 @@ 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 +80,7 @@ let fetchMock: ReturnType>; beforeEach(() => { vi.useFakeTimers(); + clearUniversalSearchCacheForTests(); fetchMock = vi.fn(); vi.stubGlobal("fetch", fetchMock); }); @@ -85,6 +89,7 @@ afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals(); vi.restoreAllMocks(); + clearUniversalSearchCacheForTests(); }); describe("useUniversalSearch NDJSON integration", () => {