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. */}
+
+ );
+ })}
+ {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
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}
-
-
-
-
-
- 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();
};