diff --git a/docs/design-system/COMPONENTS.md b/docs/design-system/COMPONENTS.md index ce4575aef3..439df26874 100644 --- a/docs/design-system/COMPONENTS.md +++ b/docs/design-system/COMPONENTS.md @@ -1080,7 +1080,7 @@ This generated snapshot is a local source-derived inventory. It does not assert | `IconButton` | controls | yes | yes | inherited-global-root | yes | no | 3 | | `InlineNotice` | feedback | yes | yes | inherited-global-root | yes | no | 33 | | `LinkAction` | controls | yes | yes | no | yes | no | 0 | -| `LoadingPanel` | feedback | yes | yes | inherited-global-root | yes | no | 11 | +| `LoadingPanel` | feedback | yes | yes | inherited-global-root | yes | no | 10 | | `MissingValue` | feedback | yes | yes | inherited-global-root | yes | no | 11 | | `OverlayRoot` | layout | yes | yes | inherited-global-root | yes | no | 1 | | `PageHeader` | layout | yes | yes | inherited-global-root | yes | no | 16 | @@ -1095,7 +1095,7 @@ This generated snapshot is a local source-derived inventory. It does not assert | `SegmentedControl` | controls | yes | yes | inherited-global-root | yes | no | 33 | | `Select` | controls | yes | yes | inherited-global-root | yes | no | 16 | | `Sheet` | layout | yes | yes | inherited-global-root | yes | no | 136 | -| `Skeleton` | feedback | yes | yes | inherited-global-root | yes | no | 8 | +| `Skeleton` | feedback | yes | yes | inherited-global-root | yes | no | 9 | | `SourceDesignationBadge` | source | yes | yes | inherited-global-root | yes | no | 4 | | `SourceProvenance` | source | yes | yes | inherited-global-root | yes | no | 1 | | `SourceStatusBadge` | source | yes | yes | inherited-global-root | yes | no | 4 | diff --git a/docs/design-system/adoption-manifest.json b/docs/design-system/adoption-manifest.json index 15c67fa805..59c7277ee3 100644 --- a/docs/design-system/adoption-manifest.json +++ b/docs/design-system/adoption-manifest.json @@ -1563,7 +1563,6 @@ "src/app/(search-app)/dictionary/topics/page.tsx", "src/components/clinical-dashboard/clinical-dashboard-lazy.tsx", "src/components/clinical-dashboard/dashboard-nav.tsx", - "src/components/clinical-dashboard/document-search-results.tsx", "src/components/clinical-dashboard/global-search-shell.tsx", "src/components/clinical-dashboard/medication-record-page.tsx", "src/components/document-viewer/document-visuals-panel.tsx", @@ -1576,7 +1575,6 @@ "src/app/(search-app)/dictionary/topics/page.tsx", "src/components/clinical-dashboard/clinical-dashboard-lazy.tsx", "src/components/clinical-dashboard/dashboard-nav.tsx", - "src/components/clinical-dashboard/document-search-results.tsx", "src/components/clinical-dashboard/global-search-shell.tsx", "src/components/clinical-dashboard/medication-record-page.tsx", "src/components/document-viewer/document-visuals-panel.tsx", @@ -2613,6 +2611,7 @@ "src/app/(search-app)/open-shifts/loading.tsx", "src/app/(search-app)/roster/loading.tsx", "src/components/clinical-dashboard/document-search-results.tsx", + "src/components/clinical-dashboard/result-rows-placeholder.tsx", "src/components/clinical-dashboard/signed-image.tsx", "src/components/mode-home-page-skeleton.tsx" ], @@ -2623,6 +2622,7 @@ "src/app/(search-app)/open-shifts/loading.tsx", "src/app/(search-app)/roster/loading.tsx", "src/components/clinical-dashboard/document-search-results.tsx", + "src/components/clinical-dashboard/result-rows-placeholder.tsx", "src/components/clinical-dashboard/signed-image.tsx", "src/components/mode-home-page-skeleton.tsx" ], diff --git a/src/components/clinical-dashboard/answer-status.tsx b/src/components/clinical-dashboard/answer-status.tsx index 9e0db358d3..d210a753fd 100644 --- a/src/components/clinical-dashboard/answer-status.tsx +++ b/src/components/clinical-dashboard/answer-status.tsx @@ -16,6 +16,7 @@ import { } from "@/components/clinical-dashboard/answer-evidence-preview"; import type { VerifiedEvidencePreviewUnit } from "@/lib/answer-stream-contract"; import { useAppPreferences } from "@/components/clinical-dashboard/use-app-preferences"; +import { useModeResultsWarmup } from "@/components/clinical-dashboard/use-mode-results-warmup"; import { ModeHomeTemplate } from "@/components/mode-home-template"; import { LazyMyDayHomeCard } from "@/components/my-day/my-day-home-card-lazy"; import { ShowAllChip } from "@/components/show-all-chip"; @@ -67,6 +68,9 @@ export function SharedHomeEmptyState({ : []; const presentation: SharedHomePresentation = sharedHomePresentation[modeId]; const catalogueChip = sharedHomeCatalogueChips[modeId]; + // Load what this mode's results page needs while the home is open, so the first + // search does not wait for it. Local chunks and catalogue only; never a query. + useModeResultsWarmup(modeId); return ( + // Rows shaped like the document results on their way, not a spinner over an empty page. + ) : matches.length === 0 ? ( // A services or forms search that matched records but no documents. // This branch used to render `null`, which stranded the reader: moving diff --git a/src/components/clinical-dashboard/medication-prescribing-workspace.tsx b/src/components/clinical-dashboard/medication-prescribing-workspace.tsx index 332240dcbc..3ff9c626df 100644 --- a/src/components/clinical-dashboard/medication-prescribing-workspace.tsx +++ b/src/components/clinical-dashboard/medication-prescribing-workspace.tsx @@ -33,6 +33,7 @@ import { resultFilterGroup, } from "@/components/clinical-dashboard/result-filter-control"; import { UniversalSearchAlsoMatches } from "@/components/clinical-dashboard/universal-search-also-matches"; +import { ResultRowsPlaceholder } from "@/components/clinical-dashboard/result-rows-placeholder"; import { medicationVerdictRingClass, PatientVerdictSignal, @@ -880,11 +881,11 @@ function MedicationResults({ {/* The error branch moved into the band's fault panel, which carries the - same message and announces it once. Loading copy stays here. */} + same message and announces it once. While the first response is on its way the + list shows rows shaped like the results it is waiting for, not a line of text + over an empty page. */} {initialCatalogLoading ? ( -
-

Loading medication catalogue…

-
+ ) : null} {!initialCatalogLoading && !catalog.error && resultCount === 0 ? ( @@ -1117,7 +1118,11 @@ function MedicationResults({ })} - + {/* Mounted (so its lookup runs in parallel) but kept out of view until the medication + list itself has arrived: it fills in underneath the results, never ahead of them. */} + + {Array.from({ length: rows }).map((_, index) => { + const delay = `${index * 60}ms`; + return ( +
+ +
+ + + +
+
+ ); + })} + {label} + + ); +} diff --git a/src/components/clinical-dashboard/universal-search-also-matches.tsx b/src/components/clinical-dashboard/universal-search-also-matches.tsx index d5349d51ed..6cd95f8a36 100644 --- a/src/components/clinical-dashboard/universal-search-also-matches.tsx +++ b/src/components/clinical-dashboard/universal-search-also-matches.tsx @@ -25,35 +25,6 @@ function matchCountLabel(count: number) { return count === 1 ? "1 related mode" : `${count} related modes`; } -/** - * Loading placeholder that occupies the real card geometry. - * - * Replaces a single muted sentence, which collapsed the tray to one text line - * and then snapped it open to a four-card grid the moment results landed. The - * skeletons keep the tray the height it is about to be, so nothing below the - * panel jumps. Purely decorative — announcement is owned by the visually - * hidden `role="status"` node in the panel body (SPEC §9.2: a live region is - * never visible content). - */ -function AlsoMatchesSkeletonCard({ className }: { className?: string }) { - return ( -
-
- - -
- - -
- ); -} - export function UniversalSearchAlsoMatches({ modeId, query, @@ -178,243 +149,252 @@ export function UniversalSearchAlsoMatches({ // What the announcer says must be what the panel is showing. Rendering one // fixed "searching / nothing found" string unconditionally would announce // "No additional matches" over a grid of four populated mode cards. - const panelStatus = searchPending - ? "Searching other modes" - : matchCount > 0 + // Read only once the lookup has landed (see liveStatus below), so it has no pending arm. + const panelStatus = + matchCount > 0 ? `${withCatalogueDegradedNotice(`${matchCountLabel(matchCount)} also match this search`, catalogueDegraded)}.` : emptyMessage; - // Below 360px the 12px type floor leaves no room for "related", so the closed header reads - // "3 modes"; the full count is still announced by the status node above. - const headerMeta = searchPending ? ( - "Searching…" - ) : matchCount > 0 ? ( - <> - {matchCount} related - {matchCount === 1 ? "mode" : "modes"} - - ) : ( - "No other matches" - ); + // No pending arm. The strip is not drawn at all while this query's lookup is running (see + // the gate below), so a "Searching…" here could never show. Below 360px the 12px type floor + // leaves no room for "related", so the closed header reads "3 modes"; the full count is + // still announced by the status node above. + const headerMeta = + matchCount > 0 ? ( + <> + {matchCount} related + {matchCount === 1 ? "mode" : "modes"} + + ) : ( + "No other matches" + ); - if (!submissionActive) return null; - if (!viewportReady || trimmedQuery.length < 2) return null; - if (modeId === "answer" && currentGroups.length === 0) return null; // At every width now, not just from sm up: the phone lookup is eager, so a // header that would open onto nothing is dropped instead of offered. - if (!searchPending && currentGroups.length === 0) return null; + // + // And not while the lookup is still running either. This strip used to mount as soon as + // the search was submitted and sit there saying "Searching…" — over an otherwise empty + // page before the main results arrived, and still saying it after they had. It is + // secondary to the results the search asked for, so it now fills in quietly underneath + // them once it has something to show, and never holds or competes with the main list. + // Streamed groups arrive one mode at a time, so a partial count is never shown either. + const stripShown = + submissionActive && viewportReady && trimmedQuery.length >= 2 && currentGroups.length > 0 && !searchPending; + + // The live region is always mounted, empty until the strip is drawn, and only then filled + // with the count; a cached lookup can be complete on the very render a search is submitted. + // Screen readers announce a change to a live region that is already on the page, not the + // starting text of one that appears with its message already in it, so mounting it together + // with the strip would silence the count. + // It stays outside the collapsible panel for the same reason (see the note below). + const liveStatus = ( + + {stripShown ? panelStatus : ""} + + ); return ( -
- - {/* The live region sits OUTSIDE the collapsible panel. SPEC §9.2 makes this + + {headerMeta} + + + + + {/* The live region sits OUTSIDE the collapsible panel. SPEC §9.2 makes this node the announcer and keeps the visible count `aria-hidden`; inside the panel it would be `display: none` whenever the tray is closed, so from the moment this became a real disclosure at every width the count would have reached no screen reader at all — the closed control would be a blind door for exactly the users who cannot see the number beside it. */} - - {panelStatus} - -
- {searchPending ? ( - <> - - - - - - ) : null} - {!searchPending && currentGroups.length === 0 ? ( -

{emptyMessage}

- ) : null} - {currentGroups.map((group) => { - const targetModeId = group.modeId; - const targetMode = appModeDefinition(targetModeId); - const accent = APP_MODE_ACCENT[targetModeId]; - return ( -
- - - - View all in - {withCatalogueDegradedNotice(targetMode.label, group.degraded)} - - {/* Short code beside the full mode name it abbreviates — decorative + // EVERY display utility lives in the open branch, and that is + // load-bearing. A `sm:grid` sitting in the base list beside `hidden` + // does NOT collapse this panel from 640px up: both are display + // utilities of equal specificity and Tailwind emits the `sm:` rule + // later, inside a media query, so `display: grid` wins. The panel + // would sit open on every desktop while its own trigger reported + // `aria-expanded="false"`. `cross-mode-links.tsx` carries the same + // warning after hitting it. + "grid-cols-1 gap-2 sm:grid-cols-2 xl:grid-cols-4", + expanded ? "mt-1.5 grid" : "hidden", + )} + > + {currentGroups.map((group) => { + const targetModeId = group.modeId; + const targetMode = appModeDefinition(targetModeId); + const accent = APP_MODE_ACCENT[targetModeId]; + return ( +
+ + + + View all in + {withCatalogueDegradedNotice(targetMode.label, group.degraded)} + + {/* Short code beside the full mode name it abbreviates — decorative in the accessible name, which already says the name in full. */} - {/* Hidden on phones: the row already names the mode in full one + {/* Hidden on phones: the row already names the mode in full one span to the left, so "Meds" beside "MEDICATION" is the same word twice, and it puts a second block of colour in a header whose icon tile already carries the accent. Kept from sm up, where the row has room and the short code helps scanning a 2-up or 4-up. Same call as the sibling tray in cross-mode-links.tsx (PR #2661). */} - - {targetMode.search.statusLabel} - - - - {/* A degraded mode with nothing in it still gets its card, and the card has to say + + {targetMode.search.statusLabel} + + + + {/* A degraded mode with nothing in it still gets its card, and the card has to say what it found rather than showing an empty box: the built-in list held no match, and the published list was never read. */} - {group.items.length === 0 ? ( -

- No matches in the retained copy. -

- ) : ( -
    - {group.items.map((item) => ( -
  • - - {item.title} - - -
  • - ))} -
- )} -
- ); - })} -
-
+ {group.items.length === 0 ? ( +

+ No matches in the retained copy. +

+ ) : ( +
    + {group.items.map((item) => ( +
  • + + {item.title} + + +
  • + ))} +
+ )} + + ); + })} + + + ) : null} + ); } diff --git a/src/components/clinical-dashboard/use-medication-catalog.ts b/src/components/clinical-dashboard/use-medication-catalog.ts index 4c4f5e85b1..e755e213f3 100644 --- a/src/components/clinical-dashboard/use-medication-catalog.ts +++ b/src/components/clinical-dashboard/use-medication-catalog.ts @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useLayoutEffect, useState } from "react"; +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; import { authSessionFingerprint, createAuthRequestLifecycle } from "@/lib/auth-request-lifecycle"; import type { MedicationRecord, MedicationSearchResult } from "@/lib/medications"; @@ -233,6 +233,12 @@ export function useMedicationCatalog( const [prevAuthIdentity, setPrevAuthIdentity] = useState(authIdentity); const [prevAuthorizationHeader, setPrevAuthorizationHeader] = useState(authorizationHeader); const [requestLifecycle] = useState(() => createAuthRequestLifecycle()); + // The debounce exists to coalesce keystrokes, so it only applies once this hook has + // actually sent a request. The first request of a freshly mounted results view (a + // submitted search) goes immediately instead of waiting out a typing pause that never + // happened. Set when the timer fires, not when it is scheduled, so a dev StrictMode + // mount/unmount/mount (which clears the first timer) still sends the first one at once. + const requestSentRef = useRef(false); const [state, setState] = useState>({ data: null, loading: enabled, @@ -268,40 +274,48 @@ export function useMedicationCatalog( if (fields) params.set("fields", fields); const suffix = params.toString(); const url = suffix ? `/api/medications?${suffix}` : "/api/medications"; - - const timer = window.setTimeout(() => { - fetchJson(url, authorizationHeader, controller.signal, parseMedicationCatalogResponse) - .then((data) => { - if (!controller.signal.aborted && isCurrentRequest()) setState({ data, loading: false, error: null }); - }) - .catch((error) => { - if ( - controller.signal.aborted || - !isCurrentRequest() || - (error instanceof DOMException && error.name === "AbortError") - ) - return; - const isOffline = - (typeof navigator !== "undefined" && !navigator.onLine) || - (error instanceof TypeError && error.message.includes("fetch")); - setState({ - data: null, - loading: false, - error: isOffline - ? "You are offline. Connect to search the live medication registry." - : error instanceof Error - ? error.message - : "Could not load medications.", + // While sign-in is still resolving, keep the typing pause and don't count the request as + // the first: a quick sign-in cancels this anonymous timer, and the authenticated request + // that follows still goes at once rather than waiting a debounce. + const authResolving = authStatus === "loading"; + + const timer = window.setTimeout( + () => { + if (!authResolving) requestSentRef.current = true; + fetchJson(url, authorizationHeader, controller.signal, parseMedicationCatalogResponse) + .then((data) => { + if (!controller.signal.aborted && isCurrentRequest()) setState({ data, loading: false, error: null }); + }) + .catch((error) => { + if ( + controller.signal.aborted || + !isCurrentRequest() || + (error instanceof DOMException && error.name === "AbortError") + ) + return; + const isOffline = + (typeof navigator !== "undefined" && !navigator.onLine) || + (error instanceof TypeError && error.message.includes("fetch")); + setState({ + data: null, + loading: false, + error: isOffline + ? "You are offline. Connect to search the live medication registry." + : error instanceof Error + ? error.message + : "Could not load medications.", + }); }); - }); - }, debounceMs); + }, + requestSentRef.current || authResolving ? debounceMs : 0, + ); return () => { window.clearTimeout(timer); controller.abort(); registration.release(); }; - }, [trimmed, enabled, fields, debounceMs, authIdentity, authorizationHeader, requestLifecycle]); + }, [trimmed, enabled, fields, debounceMs, authIdentity, authorizationHeader, authStatus, requestLifecycle]); return state; } diff --git a/src/components/clinical-dashboard/use-mode-results-warmup.ts b/src/components/clinical-dashboard/use-mode-results-warmup.ts new file mode 100644 index 0000000000..cf41b7eccc --- /dev/null +++ b/src/components/clinical-dashboard/use-mode-results-warmup.ts @@ -0,0 +1,65 @@ +"use client"; + +import { useEffect } from "react"; + +import type { AppModeId } from "@/lib/app-modes"; + +/** + * Starts loading what a mode's results page needs while its home is still on screen, + * so the first search does not pay for it after Enter. + * + * Only the results surface's JavaScript chunk is warmed. No data request is made: the + * medication catalogue route shares the search rate limit, so a background fetch from + * every home visit could use up the allowance a real search needs. Nothing here sends a + * query, calls a paid or model-backed service, or stores anything. + * What is warmed never changes which results a search returns or their order; it only + * moves when the shared pieces are fetched. + * + * Each mode is warmed at most once per page load, after the browser goes idle, and + * every failure is ignored: a warm-up that fails leaves the search exactly as it was. + */ + +type ModeWarmup = () => void; + +const modeWarmups: Partial> = { + prescribing: () => { + void import("@/components/clinical-dashboard/medication-prescribing-workspace").catch(() => undefined); + }, + documents: () => { + void import("@/components/clinical-dashboard/document-search-results").catch(() => undefined); + }, +}; + +const warmedModes = new Set(); + +/** Runs a mode's warm-up once per page load. Exported for tests. */ +export function warmModeResults(modeId: AppModeId) { + const warmup = modeWarmups[modeId]; + if (!warmup || warmedModes.has(modeId)) return false; + warmedModes.add(modeId); + warmup(); + return true; +} + +/** Test seam: forget which modes were warmed. */ +export function resetModeResultsWarmupForTests() { + warmedModes.clear(); +} + +type IdleWindow = Window & { + requestIdleCallback?: (run: () => void, options?: { timeout: number }) => number; + cancelIdleCallback?: (handle: number) => void; +}; + +export function useModeResultsWarmup(modeId: AppModeId) { + useEffect(() => { + if (!modeWarmups[modeId] || warmedModes.has(modeId)) return; + const idleWindow = window as IdleWindow; + if (typeof idleWindow.requestIdleCallback === "function") { + const handle = idleWindow.requestIdleCallback(() => warmModeResults(modeId), { timeout: 2000 }); + return () => idleWindow.cancelIdleCallback?.(handle); + } + const timer = window.setTimeout(() => warmModeResults(modeId), 600); + return () => window.clearTimeout(timer); + }, [modeId]); +} diff --git a/tests/audit-navigation-auth-regressions.test.ts b/tests/audit-navigation-auth-regressions.test.ts index fe0bc382b9..008fd2d4e3 100644 --- a/tests/audit-navigation-auth-regressions.test.ts +++ b/tests/audit-navigation-auth-regressions.test.ts @@ -302,22 +302,34 @@ describe("audit navigation and auth regressions", () => { expect(universalAlsoMatchesSource).toContain("const searchActive = submissionActive;"); expect(universalAlsoMatchesSource).not.toContain('modeId !== "prescribing"'); expect(universalAlsoMatchesSource).not.toContain('(isWide || modeId === "answer" || expanded)'); - // The header now says pending / a count / nothing found. The "Tap to open" - // arm it replaced survives only in the comment above the searchActive gate, - // which is why this pins the expression rather than searching for the string. - // The count arm is JSX so "related" can drop below 360px at the 12px floor. + // The header says a count or nothing found. The "Tap to open" arm it replaced + // survives only in the comment above the searchActive gate, which is why this + // pins the expression rather than searching for the string. The pending arm + // ("Searching…") went on 2026-10-12: the strip is no longer drawn until this + // query's matches are in. The count arm is JSX so "related" can drop below 360px. expect(universalAlsoMatchesSource).toMatch( - /const headerMeta = searchPending \? \(\s*"Searching…"\s*\) : matchCount > 0 \? \([\s\S]*?\) : \(\s*"No other matches"\s*\);/, + /const headerMeta =\s*matchCount > 0 \? \([\s\S]*?\) : \(\s*"No other matches"\s*\);/, ); + expect(universalAlsoMatchesSource).not.toContain('searchPending ? "Searching…"'); expect(universalAlsoMatchesSource).toContain("enabled: trimmedQuery.length >= 2 && searchActive"); - expect(universalAlsoMatchesSource).toContain('if (modeId === "answer" && currentGroups.length === 0) return null;'); - expect(universalAlsoMatchesSource).toContain("if (!searchPending && currentGroups.length === 0) return null;"); + // Answer mode needs no gate of its own now: with no groups the strip is simply not drawn. + expect(universalAlsoMatchesSource).not.toContain( + 'if (modeId === "answer" && currentGroups.length === 0) return null;', + ); + // Not drawn while pending, and not drawn when nothing matched: one gate for both. + expect(universalAlsoMatchesSource).toMatch( + /const stripShown =\s*submissionActive && viewportReady && trimmedQuery\.length >= 2 && currentGroups\.length > 0 && !searchPending;/, + ); + // The live region is mounted empty through the lookup and filled once, so the count is + // announced as a change rather than lost as a region's starting text. + expect(universalAlsoMatchesSource).toContain('{stripShown ? panelStatus : ""}'); + expect(universalAlsoMatchesSource).not.toContain("if (!searchPending && currentGroups.length === 0) return null;"); expect(universalAlsoMatchesSource).toContain("const [viewportReady, setViewportReady] = useState(false);"); expect(universalAlsoMatchesSource).toContain("setViewportReady(true);"); - // The panel status is a three-way now — pending / a count / nothing found — - // because one fixed string announced "No additional matches" over a grid of - // populated mode cards. The pending arm is the one this contract is about. - expect(universalAlsoMatchesSource).toMatch(/const panelStatus = searchPending\s*\n?\s*\? "Searching other modes"/); + // The panel status is a count or nothing found, because one fixed string announced + // "No additional matches" over a grid of populated mode cards. Its pending arm went + // on 2026-10-12: the region stays empty while the lookup runs. + expect(universalAlsoMatchesSource).not.toContain('"Searching other modes"'); // Since #3PW9TY the empty message has two arms, and the contract is about which one // speaks. "No additional matches" is a claim about the catalogue's contents; a catalogue // that could not be read has no contents to report, and saying there are none is the diff --git a/tests/instant-results.dom.test.tsx b/tests/instant-results.dom.test.tsx new file mode 100644 index 0000000000..8802a1ac1c --- /dev/null +++ b/tests/instant-results.dom.test.tsx @@ -0,0 +1,137 @@ +/** @vitest-environment jsdom */ + +import { act, render, renderHook, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { ResultRowsPlaceholder } from "@/components/clinical-dashboard/result-rows-placeholder"; +import { useMedicationCatalog } from "@/components/clinical-dashboard/use-medication-catalog"; +import { + resetModeResultsWarmupForTests, + useModeResultsWarmup, + warmModeResults, +} from "@/components/clinical-dashboard/use-mode-results-warmup"; + +/** + * Results that feel instant (site recommendations 2026-10-12, item 6). These change WHEN + * results appear, never which results or their order, so every case here is about timing + * and loading presentation only. + */ + +const authSession = vi.hoisted(() => ({ + authorizationHeader: undefined as Record | undefined, + markSessionExpired: vi.fn(), + session: null, + status: "signed_out" as "signed_out" | "loading" | "authenticated", +})); + +vi.mock("@/lib/supabase/client", () => ({ + useAuthSession: () => authSession, +})); + +let fetchMock: ReturnType>; + +function jsonResponse(body: unknown) { + return new Response(JSON.stringify(body), { status: 200, headers: { "Content-Type": "application/json" } }); +} + +beforeEach(() => { + fetchMock = vi.fn(() => Promise.resolve(jsonResponse({ records: [], total: 0 }))); + vi.stubGlobal("fetch", fetchMock); + resetModeResultsWarmupForTests(); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe("medication catalogue request timing", () => { + it("sends the first request of a freshly opened results view without the typing debounce", async () => { + vi.useFakeTimers(); + renderHook(() => useMedicationCatalog("lithium")); + // Zero-delay timer: nothing waits out the 250 ms keystroke pause on a submitted search. + await act(async () => vi.advanceTimersByTimeAsync(0)); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0]?.[0]).toBe("/api/medications?q=lithium"); + }); + + it("still debounces later query changes, so typing does not send a request per keystroke", async () => { + vi.useFakeTimers(); + const { rerender } = renderHook(({ query }) => useMedicationCatalog(query), { + initialProps: { query: "lithium" }, + }); + await act(async () => vi.advanceTimersByTimeAsync(0)); + expect(fetchMock).toHaveBeenCalledTimes(1); + + rerender({ query: "lithium l" }); + rerender({ query: "lithium le" }); + await act(async () => vi.advanceTimersByTimeAsync(200)); + expect(fetchMock).toHaveBeenCalledTimes(1); + await act(async () => vi.advanceTimersByTimeAsync(100)); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock.mock.calls[1]?.[0]).toBe("/api/medications?q=lithium+le"); + }); +}); + +describe("medication catalogue request while sign-in resolves", () => { + afterEach(() => { + authSession.status = "signed_out"; + }); + + it("waits out the pause while sign-in is loading, then sends the resolved request at once", async () => { + vi.useFakeTimers(); + authSession.status = "loading"; + const { rerender } = renderHook(() => useMedicationCatalog("lithium")); + await act(async () => vi.advanceTimersByTimeAsync(100)); + // No anonymous zero-delay request while the session is still resolving. + expect(fetchMock).not.toHaveBeenCalled(); + + authSession.status = "signed_out"; + rerender(); + await act(async () => vi.advanceTimersByTimeAsync(0)); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); +}); + +describe("mode results warm-up", () => { + it("warms medication once and makes no data request, so it never spends the search rate limit", () => { + expect(warmModeResults("prescribing")).toBe(true); + expect(warmModeResults("prescribing")).toBe(false); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("makes no data request for documents, whose search is retrieval-backed", () => { + expect(warmModeResults("documents")).toBe(true); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("does nothing for a mode with no warm-up", () => { + expect(warmModeResults("dsm")).toBe(false); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("starts after the page goes idle and is cancelled if the home closes first", async () => { + vi.useFakeTimers(); + const first = renderHook(() => useModeResultsWarmup("prescribing")); + first.unmount(); + await act(async () => vi.advanceTimersByTimeAsync(5_000)); + renderHook(() => useModeResultsWarmup("prescribing")); + await act(async () => vi.advanceTimersByTimeAsync(5_000)); + // The cancelled home never warmed; the second one did, so a third call has nothing to do. + expect(warmModeResults("prescribing")).toBe(false); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); + +describe("result rows placeholder", () => { + it("is one named status region whose shimmer rows are hidden from assistive technology", () => { + render(); + const region = screen.getByRole("status", { name: "Loading medication results" }); + expect(region).toBe(screen.getByTestId("placeholder")); + expect(region).not.toHaveAttribute("aria-live"); + const rows = region.querySelectorAll(":scope > [aria-hidden]"); + expect(rows).toHaveLength(3); + expect(screen.getByText("Loading medication results")).toHaveClass("sr-only"); + }); +}); diff --git a/tests/medication-prescribing-workspace.dom.test.tsx b/tests/medication-prescribing-workspace.dom.test.tsx index b3d55fe045..97b45414ef 100644 --- a/tests/medication-prescribing-workspace.dom.test.tsx +++ b/tests/medication-prescribing-workspace.dom.test.tsx @@ -13,6 +13,8 @@ vi.mock("next/navigation", () => ({ // two records: the "nothing found" state. const catalogEmpty = vi.hoisted(() => ({ current: false })); const catalogLoading = vi.hoisted(() => ({ current: false })); +// When set, the first response for this query has not landed yet: no data at all. +const catalogNoData = vi.hoisted(() => ({ current: false })); const catalogueRecords = [ { slug: "lithium", @@ -110,18 +112,20 @@ vi.mock("@/components/clinical-dashboard/universal-search-also-matches", () => ( vi.mock("@/components/clinical-dashboard/use-medication-catalog", () => ({ useMedicationCatalog: () => ({ - data: { - records: catalogEmpty.current ? catalogueRecords : [], - matches: (catalogEmpty.current ? [] : [clozapine, lithium, sertraline]).map((result) => ({ - medication: undefined, - result, - score: 1, - reasons: [], - })), - interpretation: catalogInterpretation.current, - total: 3, - governance: {}, - }, + data: catalogNoData.current + ? null + : { + records: catalogEmpty.current ? catalogueRecords : [], + matches: (catalogEmpty.current ? [] : [clozapine, lithium, sertraline]).map((result) => ({ + medication: undefined, + result, + score: 1, + reasons: [], + })), + interpretation: catalogInterpretation.current, + total: 3, + governance: {}, + }, loading: catalogLoading.current, error: null, }), @@ -159,6 +163,7 @@ afterEach(() => { catalogInterpretation.current = undefined; catalogEmpty.current = false; catalogLoading.current = false; + catalogNoData.current = false; window.history.replaceState(null, "", "/"); cleanup(); vi.restoreAllMocks(); @@ -253,6 +258,24 @@ describe("MedicationPrescribingWorkspace — query interpretation", () => { expect(note.closest('[role="status"]')).not.toBeNull(); }); + it("shows rows shaped like results, announced outside any busy region, while the first response is on its way", () => { + catalogLoading.current = true; + catalogNoData.current = true; + const { container } = renderWorkspace({ query: "lithium", showHome: false }); + expect(screen.queryByText(/Loading medication catalogue/)).not.toBeInTheDocument(); + expect(screen.getByRole("status", { name: "Loading medication results" })).toBe( + screen.getByTestId("medication-results-placeholder"), + ); + // No busy parent: it would hold back the placeholder's status until the rows had gone. + expect(container.querySelector(".medication-results-workspace")).not.toHaveAttribute("aria-busy"); + }); + + it("drops the placeholder once results are in", () => { + const { container } = renderWorkspace({ query: "lithium", showHome: false }); + expect(screen.queryByTestId("medication-results-placeholder")).not.toBeInTheDocument(); + expect(container.querySelector(".medication-results-workspace")).not.toHaveAttribute("aria-busy"); + }); + it("hides a kept correction while the catalogue is still answering the new words", () => { catalogLoading.current = true; catalogInterpretation.current = { diff --git a/tests/ui-style-contract.spec.ts b/tests/ui-style-contract.spec.ts index 97b80f8a9d..d967820f34 100644 --- a/tests/ui-style-contract.spec.ts +++ b/tests/ui-style-contract.spec.ts @@ -250,8 +250,15 @@ test.describe("unlayered style rules render their effect", () => { // all — the audit would enumerate an empty set and pass vacuously. The result // rows are where this repo's tap carriers actually live. const runAudit = async (): Promise => { + // The "Also matches" strip below the results only mounts once its cross-mode lookup has + // answered, so it can arrive after the main rows have settled. Wait for that lookup too, + // or a slow first answer would leave its header out of one run and in the next. + const crossModeLookup = page + .waitForResponse((response) => response.url().includes("/api/search/universal"), { timeout: 20_000 }) + .catch(() => null); await page.goto("/forms/search?q=transport&run=1", { waitUntil: "domcontentloaded" }); await page.getByTestId("form-search-mobile-results").waitFor({ state: "visible", timeout: 20_000 }); + await crossModeLookup; return waitForStableEnumeration(); };