From bb93feac5d5ea6c5ec95c497c86951b3da3dfff9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 24 Jul 2026 19:47:24 +0000 Subject: [PATCH 01/25] fix: stop choppy screen resize when switching modes Mode switches animated phone composer reserve because searchMode updated before the pathname landed, briefly leaving isStandaloneModeHome false and running the 200ms padding transition. Detect mode homes from pathname only, navigate without optimistic mode state, and limit padding transitions to scroll-hide. Co-authored-by: BigSimmo --- docs/search-chrome-behaviour.md | 2 + src/app/globals.css | 19 ++----- src/components/ClinicalDashboard.tsx | 12 ++++- .../global-search-shell.tsx | 29 +++++------ src/lib/search-route-ownership.ts | 27 ++++++++++ tests/search-route-ownership.test.ts | 51 ++++++++++++++++++- tests/ui-phone-scroll.spec.ts | 2 +- 7 files changed, 107 insertions(+), 35 deletions(-) diff --git a/docs/search-chrome-behaviour.md b/docs/search-chrome-behaviour.md index 54835a095b..fb168643a9 100644 --- a/docs/search-chrome-behaviour.md +++ b/docs/search-chrome-behaviour.md @@ -22,6 +22,8 @@ This repo uses one shared search experience across the global shell, dashboard r 6. Header and footer chrome that share the same scroll signal should hide/reveal symmetrically: when hidden, underlying content must be visible to the viewport edge. 7. Do not add page-local dock-sized `pb-[calc(...safe-area...)]` under a shell-owned dock. Put clearance in the shared reserve or the page-owned composer, never both. 8. `GlobalSearchShell` uses an inner `mobile-composer-reserve-pad` so phone padding contributes to scroll height; do not move phone shell clearance back to scrollport padding without a browser proof. +9. Standalone mode-home detection (`isStandaloneModeHomePath`) is pathname-only. Do not gate hero vs dock on a React `searchMode` that can update before the router pathname lands — that one-frame mismatch animates reserve padding and reads as a choppy screen resize. +10. Phone `#main-content` / reserve-pad `padding-bottom` transitions apply only while `data-bottom-composer-hidden="true"` (scroll-hide). Mode and route reserve flips must snap. ## Change checklist diff --git a/src/app/globals.css b/src/app/globals.css index db434b0383..f38b999a02 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -2614,36 +2614,25 @@ html[data-motion="reduced"] .source-capsule-hit[aria-expanded="true"]:hover .sou } } -/* iOS Safari bottom reserve transitions to match the composer's hide/show motion. - Shell routes animate the inner reserve pad; dashboard/answer animates - #main-content padding; DocumentViewer animates its own content pad. */ +/* Phone reserve padding animates only while the bottom composer is + scroll-hidden. Mode/route flips of --mobile-composer-reserve (hero ↔ dock) + must snap — a 200ms padding transition there reads as a choppy screen resize. + DocumentViewer keeps its own Tailwind duration classes on scroll-hide. */ @media (max-width: 639px) { - #main-content { - transition: padding-bottom 200ms cubic-bezier(0.22, 1, 0.36, 1); - } #main-content[data-bottom-composer-hidden="true"] { transition: padding-bottom 240ms cubic-bezier(0.4, 0, 0.2, 1); } - #main-content [data-testid="mobile-composer-reserve-pad"] { - transition: padding-bottom 200ms cubic-bezier(0.22, 1, 0.36, 1); - } #main-content[data-bottom-composer-hidden="true"] [data-testid="mobile-composer-reserve-pad"] { transition: padding-bottom 240ms cubic-bezier(0.4, 0, 0.2, 1); } - [data-testid="document-viewer-content"] { - transition: padding-bottom 200ms cubic-bezier(0.22, 1, 0.36, 1); - } [data-testid="document-viewer-content"][data-scroll-hidden="true"] { transition: padding-bottom 240ms cubic-bezier(0.4, 0, 0.2, 1); } } @media (max-width: 639px) and (prefers-reduced-motion: reduce) { - #main-content, #main-content[data-bottom-composer-hidden="true"], - #main-content [data-testid="mobile-composer-reserve-pad"], #main-content[data-bottom-composer-hidden="true"] [data-testid="mobile-composer-reserve-pad"], - [data-testid="document-viewer-content"], [data-testid="document-viewer-content"][data-scroll-hidden="true"] { transition: none; } diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index b8bb4a620c..d73bd6af79 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -158,6 +158,7 @@ import { type AppModeId, type AppModeSearchKind, } from "@/lib/app-modes"; +import { isDashboardModeHref } from "@/lib/search-route-ownership"; import { documentsSearchHref } from "@/lib/document-flow-routes"; import { privateScopeReadyForRoute, @@ -2588,6 +2589,15 @@ export function ClinicalDashboard({ openAccountSetup("favourites"); return; } + const href = appModeHomeHref(mode, { queryMode, scopeFilters }); + // Leaving the dashboard shell (e.g. Answer → Services): navigate without + // rewriting local chrome first. Eager setSearchMode flipped overlay/hero + // and reserved dock padding for a frame before ClinicalDashboard unmounted. + if (!isDashboardModeHref(href)) { + modeChangeFromUiRef.current = true; + router.push(href); + return; + } modeChangeFromUiRef.current = true; if (mode === "differentials") clearDifferentialModeResultState(); setQuery(""); @@ -2607,7 +2617,7 @@ export function ClinicalDashboard({ setSourceGovernanceWarnings([]); setDocumentMatches([]); setSearchMode(mode); - router.push(appModeHomeHref(mode, { queryMode, scopeFilters })); + router.push(href); } function focusComposerInput() { diff --git a/src/components/clinical-dashboard/global-search-shell.tsx b/src/components/clinical-dashboard/global-search-shell.tsx index 4a14616795..bdcc692cad 100644 --- a/src/components/clinical-dashboard/global-search-shell.tsx +++ b/src/components/clinical-dashboard/global-search-shell.tsx @@ -50,7 +50,11 @@ import { isLocalNoAuthMode, resolveClientDemoMode } from "@/lib/client-env"; import { documentsSearchHref } from "@/lib/document-flow-routes"; import { differentialsMobileCompareAddonSlotId, modeHomeDesktopComposerSlotId } from "@/lib/mode-home-composer"; import { readSearchNavigationContext, type SearchNavigationOptions } from "@/lib/search-navigation-context"; -import { shouldRenderClinicalDashboard, shouldRenderDashboardSearch } from "@/lib/search-route-ownership"; +import { + isStandaloneModeHomePath, + shouldRenderClinicalDashboard, + shouldRenderDashboardSearch, +} from "@/lib/search-route-ownership"; import type { SearchScopeFilters } from "@/lib/search-scope"; import { useAuthSession } from "@/lib/supabase/client"; import type { ClinicalQueryMode } from "@/lib/types"; @@ -328,19 +332,10 @@ function GlobalStandaloneSearchShellClient({ mode: resolvedSearchMode, pathname, }); - const isStandaloneModeHome = - !hasSubmittedModeSearch && - !rendersDashboardSearch && - ((searchMode === "services" && pathname === "/services") || - (searchMode === "forms" && pathname === "/forms") || - (searchMode === "favourites" && pathname === "/favourites") || - (searchMode === "differentials" && pathname === "/differentials") || - (searchMode === "dsm" && pathname === "/dsm") || - (searchMode === "specifiers" && pathname === "/specifiers") || - (searchMode === "formulation" && pathname === "/formulation") || - (searchMode === "factsheets" && pathname === "/factsheets") || - (searchMode === "therapy-compass" && pathname === "/therapy-compass") || - (searchMode === "tools" && pathname === "/tools")); + // Pathname-only: do not require searchMode === route. changeMode used to set + // searchMode before router.push landed, which made isStandaloneModeHome false + // for one frame (dock reserve + 200ms padding transition = choppy resize). + const isStandaloneModeHome = !hasSubmittedModeSearch && !rendersDashboardSearch && isStandaloneModeHomePath(pathname); const isDifferentialPresentationWorkflow = pathname.startsWith("/differentials/presentations"); const shouldShowDesktopSidebar = !hideDesktopSidebar; const effectiveSidebarCollapsed = isDifferentialPresentationWorkflow ? true : sidebarCollapsed; @@ -505,17 +500,18 @@ function GlobalStandaloneSearchShellClient({ } setQuery(""); setCommandScopes([]); - setSearchMode(mode); setMobileMenuOpen(false); + // Let the URL sync (render-time) own searchMode. Optimistic setSearchMode + // before pathname updates was the namespaced mode-switch reserve flip. navigateToMode(mode); } function startNewAnswerChat() { setQuery(""); setMobileMenuOpen(false); - setSearchMode("answer"); setQueryMode("auto"); setScopeFilters({}); + // URL sync sets searchMode after navigation; avoid eager chrome thrash. router.push(appModeHomeHref("answer", { focus: true })); } @@ -534,7 +530,6 @@ function GlobalStandaloneSearchShellClient({ } setQuery(crossQuery); setCommandScopes([]); - setSearchMode(mode); setMobileMenuOpen(false); navigateToMode(mode, { query: crossQuery, focus: true, run: true }); } diff --git a/src/lib/search-route-ownership.ts b/src/lib/search-route-ownership.ts index 1c1ef4892c..1f4796759f 100644 --- a/src/lib/search-route-ownership.ts +++ b/src/lib/search-route-ownership.ts @@ -17,6 +17,33 @@ const routeOwnedSubmittedSearchModes = new Set([ "factsheets", ]); +/** + * Exact pathnames that own an in-flow hero composer (no phone bottom dock). + * Derived from the URL alone so an optimistic mode-state update during + * navigation cannot flip the shell into dock reserve mid-transition. + */ +const standaloneModeHomePaths = new Set([ + "/services", + "/forms", + "/favourites", + "/differentials", + "/dsm", + "/specifiers", + "/formulation", + "/factsheets", + "/therapy-compass", + "/tools", +]); + +export function isStandaloneModeHomePath(pathname: string): boolean { + return standaloneModeHomePaths.has(pathname); +} + +/** Dashboard-owned hrefs stay on `/` with `?mode=`; everything else leaves the dashboard shell. */ +export function isDashboardModeHref(href: string): boolean { + return href === "/" || href.startsWith("/?"); +} + export function shouldRenderDashboardSearch({ hasSubmittedSearch, mode, diff --git a/tests/search-route-ownership.test.ts b/tests/search-route-ownership.test.ts index aab3f21472..9804db81b0 100644 --- a/tests/search-route-ownership.test.ts +++ b/tests/search-route-ownership.test.ts @@ -1,6 +1,13 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; import { describe, expect, it } from "vitest"; -import { shouldRenderClinicalDashboard, shouldRenderDashboardSearch } from "@/lib/search-route-ownership"; +import { + isDashboardModeHref, + isStandaloneModeHomePath, + shouldRenderClinicalDashboard, + shouldRenderDashboardSearch, +} from "@/lib/search-route-ownership"; describe("shared-search route ownership", () => { it("keeps submitted searches in route-owned mode workflows", () => { @@ -42,4 +49,46 @@ describe("shared-search route ownership", () => { }), ).toBe(false); }); + + it("classifies standalone mode homes from pathname alone", () => { + for (const pathname of [ + "/services", + "/forms", + "/favourites", + "/differentials", + "/dsm", + "/specifiers", + "/formulation", + "/factsheets", + "/therapy-compass", + "/tools", + ]) { + expect(isStandaloneModeHomePath(pathname)).toBe(true); + } + expect(isStandaloneModeHomePath("/")).toBe(false); + expect(isStandaloneModeHomePath("/services/crisis")).toBe(false); + expect(isStandaloneModeHomePath("/dsm/search")).toBe(false); + }); + + it("classifies dashboard mode hrefs without parsing the destination page", () => { + expect(isDashboardModeHref("/")).toBe(true); + expect(isDashboardModeHref("/?mode=answer")).toBe(true); + expect(isDashboardModeHref("/?mode=documents&focus=1")).toBe(true); + expect(isDashboardModeHref("/services")).toBe(false); + expect(isDashboardModeHref("/differentials?q=mania&run=1")).toBe(false); + }); + + it("keeps shell mode-home detection pathname-gated (no searchMode∧pathname AND)", () => { + const shellSource = readFileSync( + resolve(process.cwd(), "src/components/clinical-dashboard/global-search-shell.tsx"), + "utf8", + ); + expect(shellSource).toContain("isStandaloneModeHomePath(pathname)"); + expect(shellSource).not.toMatch(/searchMode === "services" && pathname === "\/services"/); + // changeMode must not optimistic-set searchMode before navigation. + expect(shellSource).toMatch(/function changeMode\(mode: AppModeId\) \{[\s\S]*?navigateToMode\(mode\);\n \}/); + expect(shellSource).not.toMatch( + /function changeMode\(mode: AppModeId\) \{[\s\S]*?setSearchMode\(mode\);[\s\S]*?navigateToMode\(mode\);/, + ); + }); }); diff --git a/tests/ui-phone-scroll.spec.ts b/tests/ui-phone-scroll.spec.ts index 495289765c..c39377efaf 100644 --- a/tests/ui-phone-scroll.spec.ts +++ b/tests/ui-phone-scroll.spec.ts @@ -22,7 +22,7 @@ import { expect, test, type Page } from "playwright/test"; // in-flow hero pill on phones (the composer sits in the hero and scrolls with the // content — no bottom dock), while the sticky header still collapses on scroll; // this sweep guards that the scroll geometry stays stable through that collapse. -// (list mirrors global-search-shell.tsx isStandaloneModeHome). +// (list mirrors isStandaloneModeHomePath in search-route-ownership.ts). const modeHomeRoutes = [ "/formulation", "/dsm", From 54d45f687e3723f51fa3d7e9940692c9a6e3b52c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 24 Jul 2026 19:49:34 +0000 Subject: [PATCH 02/25] test: align therapy-compass wiring with pathname mode-home gate Co-authored-by: BigSimmo --- tests/therapy-compass-mode-wiring.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/therapy-compass-mode-wiring.test.ts b/tests/therapy-compass-mode-wiring.test.ts index a7277a6c59..43878eb49d 100644 --- a/tests/therapy-compass-mode-wiring.test.ts +++ b/tests/therapy-compass-mode-wiring.test.ts @@ -122,6 +122,8 @@ describe("Therapy Compass production-mode wiring", () => { "utf8", ); expect(homeSrc).toContain("desktopComposerSlotId={modeHomeDesktopComposerSlotId}"); - expect(shellSrc).toContain('searchMode === "therapy-compass" && pathname === "/therapy-compass"'); + // Mode homes are pathname-gated so optimistic searchMode cannot flip hero→dock mid-nav. + expect(shellSrc).toContain("isStandaloneModeHomePath(pathname)"); + expect(shellSrc).toContain('"/therapy-compass"'); }); }); From 5b7b0340c9ee02433052155db9bfe23aa39fa8ed Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 25 Jul 2026 01:41:24 +0000 Subject: [PATCH 03/25] docs(ledger): record mode-switch lag same-class bug hunt Co-authored-by: BigSimmo --- docs/branch-review-ledger.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index 01d05a9046..b057a10f69 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -778,3 +778,4 @@ This file is append-only. Never rewrite or delete an existing review record; app | 2026-07-24 | open-PR conflict sync (20 PRs) | multi-head | Conflict resolution pass | Before: 8 PRs behind/dirty (#1124 #1131 #1162 #1169 #1174-1177). After: merged origin/main into all; all 20 open PRs MERGEABLE behind=0 (BLOCKED only by CI/reviews). | merge origin/main per branch; no provider-backed checks run | | 2026-07-24 | open-PR conflict sync (22 PRs) | multi-head | Conflict resolution pass | Before: all 22 open PRs behind/dirty vs main (several CONFLICTING/DIRTY). After: merged origin/main into every open head; all pushes OK; merge-tree classified 22/22 clean. | merge origin/main per branch; check:branch-review-ledger on #1172; no provider-backed checks run | | 2026-07-24 | cursor/pr-queue-hygiene-72ec | pending-push | PR queue hygiene | Add pr-branch-sync workflow + sync:pr-branches helper; bump postcss to clear npm audit high; document anti-churn guidance in AGENTS/process-hardening/pr-babysit/run-pr. | check:github-actions PASS; docs:check-scripts/index PASS; vitest sync-open-pr-branches 3/3; npm audit high clean; no provider-backed checks run | +| 2026-07-25 | cursor/fix-mode-switch-lag-22f6 | 54d45f687e3723f51fa3d7e9940692c9a6e3b52c | Same-class bug hunt: mode-switch/layout thrash after reserve-flip fix | No P0. Branch fix mitigates pathname∧searchMode gate, shell changeMode optimistic setSearchMode, selectSearchMode leaving dashboard, and always-on padding-bottom transitions. Still open P2s: (1) ClinicalDashboard.crossModeSearch still setSearchMode before router.push without isDashboardModeHref guard; (2) dashboard-internal Answer↔/?mode=* still eager setSearchMode → overlay/collapse + heroBreakpoint + portal rebind; (3) standalone shell persists #main-content scrollTop + phoneScrollHide across mode homes; (4) ClinicalDashboard↔GlobalStandaloneSearchShellClient remount + grid-template-columns transition; (5) hero portal null gap while slot/MutationObserver rebinds; (6) ModeHomeRouteLoading phone min-h 13.5rem vs idle-reserve mode homes; (7) /tools vs /?mode=tools dual shell (#007). P3: services/forms contentAlign center→startOnPhone after registry load. | Static source audit of shell/dashboard/header/reserve/CSS/app-modes/skeletons; no browser/provider checks. | From b9484396347defaaa934571604b9d165ae6d8b98 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 25 Jul 2026 01:44:06 +0000 Subject: [PATCH 04/25] fix: close same-class mode-switch layout thrash bugs After the reserve-flip fix, related choppiness remained from eager crossModeSearch chrome updates, inherited phone scroll/hide across mode homes, a hero-portal null gap while slots rebound, a taller mode-home loading skeleton, and services/forms contentAlign jumping after registry load. Navigate out of the dashboard without rewriting chrome, reset scroll-hide on pathname change, keep the default composer until the portal attaches, align the skeleton to the shell header token, and keep loading homes top-aligned on phone. Co-authored-by: BigSimmo --- docs/search-chrome-behaviour.md | 3 ++ src/components/ClinicalDashboard.tsx | 24 +++++++++++- .../global-search-shell.tsx | 12 +++++- .../master-search-header.tsx | 8 ++-- .../clinical-dashboard/use-hide-on-scroll.ts | 15 +++++++- src/components/forms/forms-home-page.tsx | 6 +-- src/components/mode-home-page-skeleton.tsx | 5 ++- .../services/services-home-page.tsx | 6 +-- tests/mode-home-main-align.test.ts | 8 ++-- tests/search-route-ownership.test.ts | 37 +++++++++++++++++++ 10 files changed, 107 insertions(+), 17 deletions(-) diff --git a/docs/search-chrome-behaviour.md b/docs/search-chrome-behaviour.md index fb168643a9..842ec5e8ae 100644 --- a/docs/search-chrome-behaviour.md +++ b/docs/search-chrome-behaviour.md @@ -24,6 +24,9 @@ This repo uses one shared search experience across the global shell, dashboard r 8. `GlobalSearchShell` uses an inner `mobile-composer-reserve-pad` so phone padding contributes to scroll height; do not move phone shell clearance back to scrollport padding without a browser proof. 9. Standalone mode-home detection (`isStandaloneModeHomePath`) is pathname-only. Do not gate hero vs dock on a React `searchMode` that can update before the router pathname lands — that one-frame mismatch animates reserve padding and reads as a choppy screen resize. 10. Phone `#main-content` / reserve-pad `padding-bottom` transitions apply only while `data-bottom-composer-hidden="true"` (scroll-hide). Mode and route reserve flips must snap. +11. Shared shell must reset phone scroll offset and scroll-hide state on `pathname` change so mode homes do not inherit a mid-page offset or collapsed header. +12. Hero composer portal: keep the default composer mounted until the portal host is actually attached; do not hide on `slotId` alone (mode-home remounts otherwise flash a null gap). +13. Leaving the dashboard shell for a namespaced mode (`selectSearchMode` / `crossModeSearch`) must navigate without rewriting dashboard chrome first. ## Change checklist diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index d73bd6af79..ccbc2d2a18 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -2298,6 +2298,20 @@ export function ClinicalDashboard({ openAccountSetup("favourites"); return; } + const href = appModeHomeHref(mode, { + query: crossQuery, + focus: true, + run: true, + queryMode, + scopeFilters, + }); + // Leaving the dashboard shell: navigate only — eager setSearchMode flipped + // overlay/hero/dock chrome for a frame before ClinicalDashboard unmounted. + if (!isDashboardModeHref(href)) { + modeChangeFromUiRef.current = true; + router.push(href); + return; + } modeChangeFromUiRef.current = true; if (mode === "differentials") clearDifferentialModeResultState(); setCommandScopes([]); @@ -2320,7 +2334,10 @@ export function ClinicalDashboard({ setMedicationSearchQuery(crossQuery); } setSearchMode(mode); - router.push(appModeHomeHref(mode, { query: crossQuery, focus: true, run: true, queryMode, scopeFilters })); + router.push(href); + window.requestAnimationFrame(() => { + mainRef.current?.scrollTo({ top: 0, behavior: resolveScrollBehavior() }); + }); } async function submitAnswerFeedback(feedbackType: AnswerFeedbackType) { @@ -2618,6 +2635,11 @@ export function ClinicalDashboard({ setDocumentMatches([]); setSearchMode(mode); router.push(href); + // Dashboard-internal mode flips keep the same scroller; jump to top so + // Answer ↔ Documents does not inherit a mid-page offset + collapsed chrome. + window.requestAnimationFrame(() => { + mainRef.current?.scrollTo({ top: 0, behavior: resolveScrollBehavior() }); + }); } function focusComposerInput() { diff --git a/src/components/clinical-dashboard/global-search-shell.tsx b/src/components/clinical-dashboard/global-search-shell.tsx index bdcc692cad..4e59ab25c0 100644 --- a/src/components/clinical-dashboard/global-search-shell.tsx +++ b/src/components/clinical-dashboard/global-search-shell.tsx @@ -252,10 +252,20 @@ function GlobalStandaloneSearchShellClient({ const [mainElement, setMainElement] = useState(null); const phoneScrollHide = useScrollHideReporter(); const reportPhoneScrollHideRef = useRef(phoneScrollHide.reportScroll); + const resetPhoneScrollHideRef = useRef(phoneScrollHide.reset); const [bottomComposerHidden, setBottomComposerHidden] = useState(false); useEffect(() => { reportPhoneScrollHideRef.current = phoneScrollHide.reportScroll; - }, [phoneScrollHide.reportScroll]); + resetPhoneScrollHideRef.current = phoneScrollHide.reset; + }, [phoneScrollHide.reportScroll, phoneScrollHide.reset]); + // Mode homes share one shell scroller. Reset scroll + collapsed chrome when + // the route changes so /services → /dsm does not open mid-page with a hidden header. + useEffect(() => { + resetPhoneScrollHideRef.current(); + setBottomComposerHidden(false); + const main = document.getElementById("main-content"); + if (main instanceof HTMLElement) main.scrollTop = 0; + }, [pathname]); const visibleShellModes = useMemo(() => { const modes = visibleAppModeDefinitions(); if (!availableModeIds?.length) return modes; diff --git a/src/components/clinical-dashboard/master-search-header.tsx b/src/components/clinical-dashboard/master-search-header.tsx index 99ce5174ca..a9648fb981 100644 --- a/src/components/clinical-dashboard/master-search-header.tsx +++ b/src/components/clinical-dashboard/master-search-header.tsx @@ -1896,10 +1896,10 @@ export function MasterSearchHeader({ {searchComposerVisible ? ( <> - {(desktopHomeComposerActive && desktopHomeComposerHost) || - (desktopHomeComposerSlotId && !desktopHomeComposerFallback) - ? null - : renderSearchComposer("default")} + {/* Keep the default composer visible until the hero portal is actually + attached. Hiding on slotId alone left a null gap while the mode-home + slot remounted / MutationObserver rebound (mode-switch flicker). */} + {desktopHomeComposerActive && desktopHomeComposerHost ? null : renderSearchComposer("default")} {desktopHomeComposerActive && desktopHomeComposerHost ? createPortal(renderSearchComposer("desktop-home"), desktopHomeComposerHost) : null} diff --git a/src/components/clinical-dashboard/use-hide-on-scroll.ts b/src/components/clinical-dashboard/use-hide-on-scroll.ts index fe0fd5a2c7..2ce8d26ee1 100644 --- a/src/components/clinical-dashboard/use-hide-on-scroll.ts +++ b/src/components/clinical-dashboard/use-hide-on-scroll.ts @@ -277,7 +277,20 @@ export function useScrollHideReporter(disabled = false, allowAllBreakpoints = fa return () => window.cancelAnimationFrame(frame); }, [allowAllBreakpoints]); - return { hidden: active && hidden, reportScroll }; + // Shared shell keeps this reporter across namespaced mode homes. Without an + // explicit reset, a scrolled/collapsed phone surface carries scrollTop + + // hidden chrome into the next mode and reads as a stuck mid-page resize. + const reset = useCallback(() => { + hiddenRef.current = false; + lastOffsetRef.current = 0; + directionRef.current = null; + directionTravelRef.current = 0; + scrollSourceRef.current = null; + hasScrollSourceRef.current = false; + setHidden(false); + }, []); + + return { hidden: active && hidden, reportScroll, reset }; } interface UseHideOnScrollOptions { diff --git a/src/components/forms/forms-home-page.tsx b/src/components/forms/forms-home-page.tsx index 8cc577ba2d..36584e6ba4 100644 --- a/src/components/forms/forms-home-page.tsx +++ b/src/components/forms/forms-home-page.tsx @@ -111,9 +111,9 @@ export function FormsHomePage() { return ( + // Match the live shell canvas (header token + idle content pad), not a + // larger 13.5rem chrome budget — that taller skeleton jumped when the + // real mode home mounted inside GlobalSearchShell. +
); diff --git a/src/components/services/services-home-page.tsx b/src/components/services/services-home-page.tsx index d5d412c08a..1f41d1ec22 100644 --- a/src/components/services/services-home-page.tsx +++ b/src/components/services/services-home-page.tsx @@ -122,9 +122,9 @@ export function ServicesHomePage({ defaultServiceSlug = null }: { defaultService return ( { expect(source).not.toMatch(/ModeHomeMain[^>]*className="[^"]*justify-/); } - // Forms/services only top-align when the registry is seeded; short empty / - // loading notices stay centred so the phone canvas does not look sparse. + // Forms/services top-align while loading or seeded so the registry ready + // flip does not jump center → start; confirmed empty/error stay centred. for (const path of [ resolve(SRC_ROOT, "components/forms/forms-home-page.tsx"), resolve(SRC_ROOT, "components/services/services-home-page.tsx"), ]) { const source = readFileSync(path, "utf8"); - expect(source).toMatch(/contentAlign=\{hasRegistryRecords \? "startOnPhone" : "center"\}/); + expect(source).toMatch( + /contentAlign=\{registry\.status === "loading" \|\| hasRegistryRecords \? "startOnPhone" : "center"\}/, + ); expect(source).not.toMatch(/ModeHomeMain[^>]*className="[^"]*justify-/); } }); diff --git a/tests/search-route-ownership.test.ts b/tests/search-route-ownership.test.ts index 9804db81b0..20511d5314 100644 --- a/tests/search-route-ownership.test.ts +++ b/tests/search-route-ownership.test.ts @@ -91,4 +91,41 @@ describe("shared-search route ownership", () => { /function changeMode\(mode: AppModeId\) \{[\s\S]*?setSearchMode\(mode\);[\s\S]*?navigateToMode\(mode\);/, ); }); + + it("resets shared phone scroll chrome when the pathname changes", () => { + const shellSource = readFileSync( + resolve(process.cwd(), "src/components/clinical-dashboard/global-search-shell.tsx"), + "utf8", + ); + const hideSource = readFileSync( + resolve(process.cwd(), "src/components/clinical-dashboard/use-hide-on-scroll.ts"), + "utf8", + ); + expect(hideSource).toContain("const reset = useCallback"); + expect(hideSource).toMatch(/return \{ hidden: active && hidden, reportScroll, reset \}/); + expect(shellSource).toContain("resetPhoneScrollHideRef.current()"); + expect(shellSource).toMatch(/main\.scrollTop = 0[\s\S]*\}, \[pathname\]\)/); + }); + + it("keeps the default composer until the hero portal host attaches", () => { + const headerSource = readFileSync( + resolve(process.cwd(), "src/components/clinical-dashboard/master-search-header.tsx"), + "utf8", + ); + expect(headerSource).not.toContain("desktopHomeComposerSlotId && !desktopHomeComposerFallback"); + expect(headerSource).toMatch( + /desktopHomeComposerActive && desktopHomeComposerHost\s*\?\s*null\s*:\s*renderSearchComposer\("default"\)/, + ); + }); + + it("leaves the dashboard shell without eager chrome thrash", () => { + const dashboardSource = readFileSync(resolve(process.cwd(), "src/components/ClinicalDashboard.tsx"), "utf8"); + expect(dashboardSource).toContain("isDashboardModeHref"); + expect(dashboardSource).toMatch( + /function selectSearchMode\(mode: AppModeId\) \{[\s\S]*?if \(!isDashboardModeHref\(href\)\) \{[\s\S]*?router\.push\(href\);\n return;/, + ); + expect(dashboardSource).toMatch( + /function crossModeSearch\(mode: AppModeId, crossQuery: string\) \{[\s\S]*?if \(!isDashboardModeHref\(href\)\) \{[\s\S]*?router\.push\(href\);\n return;/, + ); + }); }); From a32c1a2d704f3aac3fcf65cc09043638ba65207f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 25 Jul 2026 01:44:19 +0000 Subject: [PATCH 05/25] docs(ledger): record mode-switch thrash review fixes Co-authored-by: BigSimmo --- docs/branch-review-ledger.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index b057a10f69..8b14aa9d16 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -779,3 +779,4 @@ This file is append-only. Never rewrite or delete an existing review record; app | 2026-07-24 | open-PR conflict sync (22 PRs) | multi-head | Conflict resolution pass | Before: all 22 open PRs behind/dirty vs main (several CONFLICTING/DIRTY). After: merged origin/main into every open head; all pushes OK; merge-tree classified 22/22 clean. | merge origin/main per branch; check:branch-review-ledger on #1172; no provider-backed checks run | | 2026-07-24 | cursor/pr-queue-hygiene-72ec | pending-push | PR queue hygiene | Add pr-branch-sync workflow + sync:pr-branches helper; bump postcss to clear npm audit high; document anti-churn guidance in AGENTS/process-hardening/pr-babysit/run-pr. | check:github-actions PASS; docs:check-scripts/index PASS; vitest sync-open-pr-branches 3/3; npm audit high clean; no provider-backed checks run | | 2026-07-25 | cursor/fix-mode-switch-lag-22f6 | 54d45f687e3723f51fa3d7e9940692c9a6e3b52c | Same-class bug hunt: mode-switch/layout thrash after reserve-flip fix | No P0. Branch fix mitigates pathname∧searchMode gate, shell changeMode optimistic setSearchMode, selectSearchMode leaving dashboard, and always-on padding-bottom transitions. Still open P2s: (1) ClinicalDashboard.crossModeSearch still setSearchMode before router.push without isDashboardModeHref guard; (2) dashboard-internal Answer↔/?mode=* still eager setSearchMode → overlay/collapse + heroBreakpoint + portal rebind; (3) standalone shell persists #main-content scrollTop + phoneScrollHide across mode homes; (4) ClinicalDashboard↔GlobalStandaloneSearchShellClient remount + grid-template-columns transition; (5) hero portal null gap while slot/MutationObserver rebinds; (6) ModeHomeRouteLoading phone min-h 13.5rem vs idle-reserve mode homes; (7) /tools vs /?mode=tools dual shell (#007). P3: services/forms contentAlign center→startOnPhone after registry load. | Static source audit of shell/dashboard/header/reserve/CSS/app-modes/skeletons; no browser/provider checks. | +| 2026-07-25 | `cursor/fix-mode-switch-lag-22f6` | `b9484396347defaaa934571604b9d165ae6d8b98` | Same-class mode-switch thrash review + fixes | FIXED prior open P2s from 54d45f68 hunt: crossModeSearch now mirrors selectSearchMode (navigate-only when leaving dashboard); shared shell resets phone scrollTop + scroll-hide on pathname; hero portal keeps default composer until host attaches; ModeHomeRouteLoading uses --shell-header-h; services/forms contentAlign stays startOnPhone while loading. No P0/P1. Residual P2: ClinicalDashboard↔standalone shell remount + sidebar grid-template-columns transition; dashboard-internal Answer↔Documents still eager setSearchMode (overlay/hero flip by design, scroll now reset). Residual P3/#007: /tools vs /?mode=tools dual entry. | Focused Vitest ownership/align/hide-on-scroll/merge-artifacts/overlay 46/46; typecheck; static contracts for portal/scroll reset/crossModeSearch. No verify:ui / provider-backed checks. | From 775d15adef8155aba68e43f0e9354adf60f1ea8d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 25 Jul 2026 01:45:10 +0000 Subject: [PATCH 06/25] fix: satisfy lint for mode-switch scroll and portal cleanup Reset bottom-composer hidden state during render on pathname change instead of setState-in-effect, and drop the unused hero-portal fallback flag now that the default composer stays mounted until the host attaches. Co-authored-by: BigSimmo --- .../global-search-shell.tsx | 8 ++++++- .../master-search-header.tsx | 21 ++++++------------- tests/search-route-ownership.test.ts | 1 + 3 files changed, 14 insertions(+), 16 deletions(-) diff --git a/src/components/clinical-dashboard/global-search-shell.tsx b/src/components/clinical-dashboard/global-search-shell.tsx index 4e59ab25c0..a4c50cf51a 100644 --- a/src/components/clinical-dashboard/global-search-shell.tsx +++ b/src/components/clinical-dashboard/global-search-shell.tsx @@ -254,6 +254,13 @@ function GlobalStandaloneSearchShellClient({ const reportPhoneScrollHideRef = useRef(phoneScrollHide.reportScroll); const resetPhoneScrollHideRef = useRef(phoneScrollHide.reset); const [bottomComposerHidden, setBottomComposerHidden] = useState(false); + const [bottomComposerHiddenPathname, setBottomComposerHiddenPathname] = useState(pathname); + // Render-time reset (not an effect): pathname-only mode homes share one scroller, + // so a carried-over hidden dock pad would open the next mode mid-collapse. + if (pathname !== bottomComposerHiddenPathname) { + setBottomComposerHiddenPathname(pathname); + setBottomComposerHidden(false); + } useEffect(() => { reportPhoneScrollHideRef.current = phoneScrollHide.reportScroll; resetPhoneScrollHideRef.current = phoneScrollHide.reset; @@ -262,7 +269,6 @@ function GlobalStandaloneSearchShellClient({ // the route changes so /services → /dsm does not open mid-page with a hidden header. useEffect(() => { resetPhoneScrollHideRef.current(); - setBottomComposerHidden(false); const main = document.getElementById("main-content"); if (main instanceof HTMLElement) main.scrollTop = 0; }, [pathname]); diff --git a/src/components/clinical-dashboard/master-search-header.tsx b/src/components/clinical-dashboard/master-search-header.tsx index a9648fb981..05fab34f5b 100644 --- a/src/components/clinical-dashboard/master-search-header.tsx +++ b/src/components/clinical-dashboard/master-search-header.tsx @@ -334,12 +334,9 @@ export function MasterSearchHeader({ // paths also refresh from the live query so the first tap still picks Sheet. const [usesPhoneSearchLayout, setUsesPhoneSearchLayout] = useState(false); const [desktopHomeComposerActive, setDesktopHomeComposerActive] = useState(false); - // True once the hero portal is conclusively unavailable — the media query - // does not match, or the slot never appeared after the retry budget. While a - // slot id is present and this is false the inline composer stays suppressed - // (no flash while the portal mounts); once it flips true the inline composer - // renders, so the search can never vanish from the page at any width. - const [desktopHomeComposerFallback, setDesktopHomeComposerFallback] = useState(false); + // The default/inline composer stays mounted until desktopHomeComposerActive + // + host are both ready. That avoids a null gap while the mode-home slot + // remounts; dual composers may coexist briefly during the handoff. // Phone-only hide-on-scroll: never hide while a header-owned surface is open // or while focus sits inside the header chrome (keyboard users must not tab // into invisible controls). @@ -946,7 +943,6 @@ export function MasterSearchHeader({ queueMicrotask(() => { if (cancelled) return; setDesktopHomeComposerActive(false); - setDesktopHomeComposerFallback(false); setDesktopHomeComposerHost(null); }); return () => { @@ -995,20 +991,16 @@ export function MasterSearchHeader({ if (host.parentNode !== slot) slot.appendChild(host); setDesktopHomeComposerHost(host); setDesktopHomeComposerActive(true); - setDesktopHomeComposerFallback(false); } else { host.parentNode?.removeChild(host); setDesktopHomeComposerActive(false); if (mediaQuery.matches && portalRetryCount < 24) { portalRetryCount += 1; retryTimeout = window.setTimeout(syncTarget, Math.min(40 * portalRetryCount, 400)); - } else { - // The composer belongs inline at this width, or the slot never - // appeared within the retry budget: release the inline fallback so - // the search cannot vanish. The MutationObserver keeps watching, so - // a slot that shows up later still reclaims the portal. - setDesktopHomeComposerFallback(true); } + // Until the portal attaches (or after the retry budget), the default + // composer stays mounted — search never vanishes. MutationObserver + // keeps watching so a late slot still reclaims the portal. } }; @@ -1022,7 +1014,6 @@ export function MasterSearchHeader({ mediaQuery.removeEventListener("change", syncTarget); host.parentNode?.removeChild(host); setDesktopHomeComposerActive(false); - setDesktopHomeComposerFallback(false); setDesktopHomeComposerHost(null); }; }, [desktopHomeComposerSlotId, heroComposerBreakpoint]); diff --git a/tests/search-route-ownership.test.ts b/tests/search-route-ownership.test.ts index 20511d5314..eec3ce905c 100644 --- a/tests/search-route-ownership.test.ts +++ b/tests/search-route-ownership.test.ts @@ -112,6 +112,7 @@ describe("shared-search route ownership", () => { resolve(process.cwd(), "src/components/clinical-dashboard/master-search-header.tsx"), "utf8", ); + expect(headerSource).not.toContain("desktopHomeComposerFallback"); expect(headerSource).not.toContain("desktopHomeComposerSlotId && !desktopHomeComposerFallback"); expect(headerSource).toMatch( /desktopHomeComposerActive && desktopHomeComposerHost\s*\?\s*null\s*:\s*renderSearchComposer\("default"\)/, From 0ef62ff521fb9f0457f685e9d2f50a24d640e663 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 25 Jul 2026 01:46:34 +0000 Subject: [PATCH 07/25] docs(ledger): record mode-switch thrash lint closeout Co-authored-by: BigSimmo --- docs/branch-review-ledger.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index 8b14aa9d16..a7db558e8b 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -780,3 +780,4 @@ This file is append-only. Never rewrite or delete an existing review record; app | 2026-07-24 | cursor/pr-queue-hygiene-72ec | pending-push | PR queue hygiene | Add pr-branch-sync workflow + sync:pr-branches helper; bump postcss to clear npm audit high; document anti-churn guidance in AGENTS/process-hardening/pr-babysit/run-pr. | check:github-actions PASS; docs:check-scripts/index PASS; vitest sync-open-pr-branches 3/3; npm audit high clean; no provider-backed checks run | | 2026-07-25 | cursor/fix-mode-switch-lag-22f6 | 54d45f687e3723f51fa3d7e9940692c9a6e3b52c | Same-class bug hunt: mode-switch/layout thrash after reserve-flip fix | No P0. Branch fix mitigates pathname∧searchMode gate, shell changeMode optimistic setSearchMode, selectSearchMode leaving dashboard, and always-on padding-bottom transitions. Still open P2s: (1) ClinicalDashboard.crossModeSearch still setSearchMode before router.push without isDashboardModeHref guard; (2) dashboard-internal Answer↔/?mode=* still eager setSearchMode → overlay/collapse + heroBreakpoint + portal rebind; (3) standalone shell persists #main-content scrollTop + phoneScrollHide across mode homes; (4) ClinicalDashboard↔GlobalStandaloneSearchShellClient remount + grid-template-columns transition; (5) hero portal null gap while slot/MutationObserver rebinds; (6) ModeHomeRouteLoading phone min-h 13.5rem vs idle-reserve mode homes; (7) /tools vs /?mode=tools dual shell (#007). P3: services/forms contentAlign center→startOnPhone after registry load. | Static source audit of shell/dashboard/header/reserve/CSS/app-modes/skeletons; no browser/provider checks. | | 2026-07-25 | `cursor/fix-mode-switch-lag-22f6` | `b9484396347defaaa934571604b9d165ae6d8b98` | Same-class mode-switch thrash review + fixes | FIXED prior open P2s from 54d45f68 hunt: crossModeSearch now mirrors selectSearchMode (navigate-only when leaving dashboard); shared shell resets phone scrollTop + scroll-hide on pathname; hero portal keeps default composer until host attaches; ModeHomeRouteLoading uses --shell-header-h; services/forms contentAlign stays startOnPhone while loading. No P0/P1. Residual P2: ClinicalDashboard↔standalone shell remount + sidebar grid-template-columns transition; dashboard-internal Answer↔Documents still eager setSearchMode (overlay/hero flip by design, scroll now reset). Residual P3/#007: /tools vs /?mode=tools dual entry. | Focused Vitest ownership/align/hide-on-scroll/merge-artifacts/overlay 46/46; typecheck; static contracts for portal/scroll reset/crossModeSearch. No verify:ui / provider-backed checks. | +| 2026-07-25 | `cursor/fix-mode-switch-lag-22f6` / PR #1187 | `775d15adef8155aba68e43f0e9354adf60f1ea8d` | Same-class thrash fixes lint closeout | Supersedes b9484396 row for lint follow-up: pathname bottomComposerHidden reset moved to render-time; removed unused desktopHomeComposerFallback. verify:cheap green. Residual unchanged (dashboard↔standalone remount; #007 Tools dual entry). | verify:cheap 3345 passed / 3 skipped; focused ownership tests; eslint clean on touched shell/header. No provider checks. | From 7e80c2b8d23e8cdb88d16f0f2528e7db10e7b9df Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 25 Jul 2026 13:56:17 +0000 Subject: [PATCH 08/25] docs(ledger): record frontend-architecture loading/nav review Append review of mode/page loading and navigation architecture at HEAD 0ef62ff5: P1 shell bundle + hydration blanking; residual remount/tools dual. Co-authored-by: BigSimmo --- docs/branch-review-ledger.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index a7db558e8b..32ba3e0616 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -781,3 +781,4 @@ This file is append-only. Never rewrite or delete an existing review record; app | 2026-07-25 | cursor/fix-mode-switch-lag-22f6 | 54d45f687e3723f51fa3d7e9940692c9a6e3b52c | Same-class bug hunt: mode-switch/layout thrash after reserve-flip fix | No P0. Branch fix mitigates pathname∧searchMode gate, shell changeMode optimistic setSearchMode, selectSearchMode leaving dashboard, and always-on padding-bottom transitions. Still open P2s: (1) ClinicalDashboard.crossModeSearch still setSearchMode before router.push without isDashboardModeHref guard; (2) dashboard-internal Answer↔/?mode=* still eager setSearchMode → overlay/collapse + heroBreakpoint + portal rebind; (3) standalone shell persists #main-content scrollTop + phoneScrollHide across mode homes; (4) ClinicalDashboard↔GlobalStandaloneSearchShellClient remount + grid-template-columns transition; (5) hero portal null gap while slot/MutationObserver rebinds; (6) ModeHomeRouteLoading phone min-h 13.5rem vs idle-reserve mode homes; (7) /tools vs /?mode=tools dual shell (#007). P3: services/forms contentAlign center→startOnPhone after registry load. | Static source audit of shell/dashboard/header/reserve/CSS/app-modes/skeletons; no browser/provider checks. | | 2026-07-25 | `cursor/fix-mode-switch-lag-22f6` | `b9484396347defaaa934571604b9d165ae6d8b98` | Same-class mode-switch thrash review + fixes | FIXED prior open P2s from 54d45f68 hunt: crossModeSearch now mirrors selectSearchMode (navigate-only when leaving dashboard); shared shell resets phone scrollTop + scroll-hide on pathname; hero portal keeps default composer until host attaches; ModeHomeRouteLoading uses --shell-header-h; services/forms contentAlign stays startOnPhone while loading. No P0/P1. Residual P2: ClinicalDashboard↔standalone shell remount + sidebar grid-template-columns transition; dashboard-internal Answer↔Documents still eager setSearchMode (overlay/hero flip by design, scroll now reset). Residual P3/#007: /tools vs /?mode=tools dual entry. | Focused Vitest ownership/align/hide-on-scroll/merge-artifacts/overlay 46/46; typecheck; static contracts for portal/scroll reset/crossModeSearch. No verify:ui / provider-backed checks. | | 2026-07-25 | `cursor/fix-mode-switch-lag-22f6` / PR #1187 | `775d15adef8155aba68e43f0e9354adf60f1ea8d` | Same-class thrash fixes lint closeout | Supersedes b9484396 row for lint follow-up: pathname bottomComposerHidden reset moved to render-time; removed unused desktopHomeComposerFallback. verify:cheap green. Residual unchanged (dashboard↔standalone remount; #007 Tools dual entry). | verify:cheap 3345 passed / 3 skipped; focused ownership tests; eslint clean on touched shell/header. No provider checks. | +| 2026-07-25 | `cursor/fix-mode-switch-lag-22f6` | `0ef62ff521fb9f0457f685e9d2f50a24d640e663` | Frontend-architecture review: loading/navigation performance for modes/pages | No P0. P1: (1) GlobalSearchShell statically imports ClinicalDashboard into shared (search-app) layout client graph — every namespaced mode pays dashboard module weight; (2) ClientHydrationBoundary blanks standalone mode children until hydrate — defeats RSC paint for DSM/factsheets/etc. P2: ClinicalDashboard↔standalone remount + grid-template-columns transition; dashboard URL sync still useEffect+rAF (shell already render-time); ToolsHub ssr:false on /?mode=tools; MutationObserver portal rebind cost on mode-home slot changes; SearchCommandProvider value tied to query in shell. P3/#007: /tools vs /?mode=tools dual entry (PT-11 vs nav). Quick wins: dynamic() ClinicalDashboard; remove/narrow ClientHydrationBoundary; point tools href to /tools. Larger: unify shells / extract shared chrome. | Static architecture review of SharedSearchAppShell/GlobalSearchShell/ClinicalDashboard/MasterSearchHeader/app-modes/search-route-ownership/mode homes/tools routes; prior mode-switch ledger rows at b9484396/775d15ad noted. No browser, verify:ui, bundle measure, or provider-backed checks. | From 876d7ecfa1ae8ec79fc0f0bdf1198c6640ad89b8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 25 Jul 2026 14:16:44 +0000 Subject: [PATCH 09/25] fix: reduce mode/page loading blanking and layout rework Parallel loading review found hard-load blanking from ClientHydrationBoundary, mismatched/missing mode-home loading skeletons, forms catalog in the client chunk, ClinicalDashboard static weight on namespaced routes, sidebar column animation on remount, forms query remounts, and document viewer remounts on page flips. Paint RSC children immediately, align ModeHomeRouteLoading, wire mode-home loading.tsx files, server-pass the default form slug, dynamic-import ClinicalDashboard, gate sidebar transitions after mount, and stop unnecessary remount keys. Co-authored-by: BigSimmo --- docs/search-chrome-behaviour.md | 3 + .../(search-app)/differentials/loading.tsx | 25 +--- src/app/(search-app)/documents/[id]/page.tsx | 4 +- src/app/(search-app)/dsm/loading.tsx | 17 +-- src/app/(search-app)/factsheets/loading.tsx | 5 + src/app/(search-app)/favourites/loading.tsx | 24 +-- src/app/(search-app)/forms/loading.tsx | 20 +-- src/app/(search-app)/forms/page.tsx | 4 +- src/app/(search-app)/formulation/loading.tsx | 5 + src/app/(search-app)/page.tsx | 12 ++ src/app/(search-app)/specifiers/loading.tsx | 5 + .../(search-app)/therapy-compass/loading.tsx | 5 + src/components/ClinicalDashboard.tsx | 8 +- .../global-search-shell.tsx | 27 +++- .../shared-search-app-shell.tsx | 3 +- src/components/forms/forms-home-page.tsx | 57 +++---- .../forms/forms-search-results-page.tsx | 3 +- src/components/mode-home-page-skeleton.tsx | 7 +- tests/forms-client-boundary.test.ts | 139 ++++++++++++++++++ tests/mode-home-loading-contract.test.ts | 63 ++++++++ 20 files changed, 316 insertions(+), 120 deletions(-) create mode 100644 src/app/(search-app)/factsheets/loading.tsx create mode 100644 src/app/(search-app)/formulation/loading.tsx create mode 100644 src/app/(search-app)/specifiers/loading.tsx create mode 100644 src/app/(search-app)/therapy-compass/loading.tsx create mode 100644 tests/forms-client-boundary.test.ts create mode 100644 tests/mode-home-loading-contract.test.ts diff --git a/docs/search-chrome-behaviour.md b/docs/search-chrome-behaviour.md index 842ec5e8ae..5ea9b6aacf 100644 --- a/docs/search-chrome-behaviour.md +++ b/docs/search-chrome-behaviour.md @@ -27,6 +27,9 @@ This repo uses one shared search experience across the global shell, dashboard r 11. Shared shell must reset phone scroll offset and scroll-hide state on `pathname` change so mode homes do not inherit a mid-page offset or collapsed header. 12. Hero composer portal: keep the default composer mounted until the portal host is actually attached; do not hide on `slotId` alone (mode-home remounts otherwise flash a null gap). 13. Leaving the dashboard shell for a namespaced mode (`selectSearchMode` / `crossModeSearch`) must navigate without rewriting dashboard chrome first. +14. Do not wrap mode-home `{children}` in `ClientHydrationBoundary` — that blanks RSC HTML until JS mounts. Keep hydration guards on the specific leaf that mismatches. +15. Standalone mode-home `loading.tsx` files must render `ModeHomeRouteLoading` (phone top-aligned). Do not reuse unrelated results/medication skeletons. +16. `ClinicalDashboard` must stay out of the shared shell’s static import graph (dynamic import) so namespaced mode routes do not parse the dashboard module. ## Change checklist diff --git a/src/app/(search-app)/differentials/loading.tsx b/src/app/(search-app)/differentials/loading.tsx index d4b7a9f0ef..59334e70b5 100644 --- a/src/app/(search-app)/differentials/loading.tsx +++ b/src/app/(search-app)/differentials/loading.tsx @@ -1,26 +1,5 @@ -import { Skeleton, searchPageShell, searchPageContainer } from "@/components/ui-primitives"; +import { ModeHomeRouteLoading } from "@/components/mode-home-page-skeleton"; export default function Loading() { - return ( -
-
-
- -
- - - -
-
- -
- - - - -
-
- Loading library -
- ); + return ; } diff --git a/src/app/(search-app)/documents/[id]/page.tsx b/src/app/(search-app)/documents/[id]/page.tsx index 10413f3560..ac08e8ffa2 100644 --- a/src/app/(search-app)/documents/[id]/page.tsx +++ b/src/app/(search-app)/documents/[id]/page.tsx @@ -37,7 +37,9 @@ export default async function DocumentPage({ return ( -
- -
- - - - -
-
- Loading DSM criteria - - ); + return ; } diff --git a/src/app/(search-app)/factsheets/loading.tsx b/src/app/(search-app)/factsheets/loading.tsx new file mode 100644 index 0000000000..59334e70b5 --- /dev/null +++ b/src/app/(search-app)/factsheets/loading.tsx @@ -0,0 +1,5 @@ +import { ModeHomeRouteLoading } from "@/components/mode-home-page-skeleton"; + +export default function Loading() { + return ; +} diff --git a/src/app/(search-app)/favourites/loading.tsx b/src/app/(search-app)/favourites/loading.tsx index 400c8da0d1..59334e70b5 100644 --- a/src/app/(search-app)/favourites/loading.tsx +++ b/src/app/(search-app)/favourites/loading.tsx @@ -1,25 +1,5 @@ -import { Skeleton } from "@/components/ui-primitives"; +import { ModeHomeRouteLoading } from "@/components/mode-home-page-skeleton"; export default function Loading() { - return ( -
- - - - {/* Tabs */} -
- - - -
- - {/* Grid */} -
- - - -
- Loading medication -
- ); + return ; } diff --git a/src/app/(search-app)/forms/loading.tsx b/src/app/(search-app)/forms/loading.tsx index b4d48abd0e..59334e70b5 100644 --- a/src/app/(search-app)/forms/loading.tsx +++ b/src/app/(search-app)/forms/loading.tsx @@ -1,21 +1,5 @@ -import { Skeleton, searchPageShell, searchPageContainer } from "@/components/ui-primitives"; +import { ModeHomeRouteLoading } from "@/components/mode-home-page-skeleton"; export default function Loading() { - return ( -
-
-
- - -
-
- - - - -
-
- Loading services -
- ); + return ; } diff --git a/src/app/(search-app)/forms/page.tsx b/src/app/(search-app)/forms/page.tsx index bd750838fd..41f7beef82 100644 --- a/src/app/(search-app)/forms/page.tsx +++ b/src/app/(search-app)/forms/page.tsx @@ -1,5 +1,6 @@ import { FormsHomePage } from "@/components/forms/forms-home-page"; import { FormsSearchResultsPage } from "@/components/forms/forms-search-results-page"; +import { defaultFormSlug } from "@/lib/forms"; type FormsSearchParams = Promise<{ [key: string]: string | string[] | undefined }>; @@ -17,7 +18,8 @@ export default async function FormsPage({ searchParams }: { searchParams: FormsS const hasSubmittedSearch = readFirstSearchParam(resolvedSearchParams.run) === "1" && query.length > 0; if (!hasSubmittedSearch) { - return ; + // Computed server-side so the client route chunk never bundles the forms catalog. + return ; } return ; diff --git a/src/app/(search-app)/formulation/loading.tsx b/src/app/(search-app)/formulation/loading.tsx new file mode 100644 index 0000000000..59334e70b5 --- /dev/null +++ b/src/app/(search-app)/formulation/loading.tsx @@ -0,0 +1,5 @@ +import { ModeHomeRouteLoading } from "@/components/mode-home-page-skeleton"; + +export default function Loading() { + return ; +} diff --git a/src/app/(search-app)/page.tsx b/src/app/(search-app)/page.tsx index bc53e0b2ef..21a73d4089 100644 --- a/src/app/(search-app)/page.tsx +++ b/src/app/(search-app)/page.tsx @@ -76,5 +76,17 @@ export default async function Home({ searchParams }: HomeProps) { redirect(suffix ? `/formulation?${suffix}` : "/formulation"); } + // Services/forms are namespaced mode homes; keep /?mode=* from mounting the + // full ClinicalDashboard + registry path for those surfaces. + if (initialSearchMode === "services" || initialSearchMode === "forms") { + redirect( + appModeHomeHref(initialSearchMode, { + query: firstSearchParam(params.q)?.trim(), + focus: firstSearchParam(params.focus) === "1", + run: firstSearchParam(params.run) === "1", + }), + ); + } + return ; } diff --git a/src/app/(search-app)/specifiers/loading.tsx b/src/app/(search-app)/specifiers/loading.tsx new file mode 100644 index 0000000000..59334e70b5 --- /dev/null +++ b/src/app/(search-app)/specifiers/loading.tsx @@ -0,0 +1,5 @@ +import { ModeHomeRouteLoading } from "@/components/mode-home-page-skeleton"; + +export default function Loading() { + return ; +} diff --git a/src/app/(search-app)/therapy-compass/loading.tsx b/src/app/(search-app)/therapy-compass/loading.tsx new file mode 100644 index 0000000000..59334e70b5 --- /dev/null +++ b/src/app/(search-app)/therapy-compass/loading.tsx @@ -0,0 +1,5 @@ +import { ModeHomeRouteLoading } from "@/components/mode-home-page-skeleton"; + +export default function Loading() { + return ; +} diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index 03d026867e..2747b6f9c5 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -508,6 +508,11 @@ export function ClinicalDashboard({ const [settingsOpen, setSettingsOpen] = useState(false); const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); const [sidebarCollapsed, setSidebarCollapsed] = useSidebarCollapsed(); + const [sidebarColumnTransitionReady, setSidebarColumnTransitionReady] = useState(false); + useEffect(() => { + const frame = window.requestAnimationFrame(() => setSidebarColumnTransitionReady(true)); + return () => window.cancelAnimationFrame(frame); + }, []); const [documentsDrawerOpen, setDocumentsDrawerOpen] = useState(false); const documentsDrawerReturnFocusRef = useRef(null); const [documentScopeOpen, setDocumentScopeOpen] = useState(false); @@ -3327,7 +3332,8 @@ export function ClinicalDashboard({ appBackdrop, // Phone: fixed inset-0 (not 100dvh) — matches GlobalSearchShell; avoids Safari toolbar dead band. "mobile-app-shell flex flex-col overflow-hidden text-[color:var(--text)] max-sm:fixed max-sm:inset-0 max-sm:h-auto max-sm:min-h-0 max-sm:overflow-hidden md:grid md:grid-cols-[5.25rem_minmax(0,1fr)] md:overflow-hidden", - "motion-safe:transition-[grid-template-columns] motion-safe:duration-200 motion-safe:ease-out", + sidebarColumnTransitionReady && + "motion-safe:transition-[grid-template-columns] motion-safe:duration-200 motion-safe:ease-out", sidebarCollapsed ? "lg:grid-cols-[5.25rem_minmax(0,1fr)]" : "lg:grid-cols-[20rem_minmax(0,1fr)]", )} style={ diff --git a/src/components/clinical-dashboard/global-search-shell.tsx b/src/components/clinical-dashboard/global-search-shell.tsx index a4c50cf51a..d39998aacc 100644 --- a/src/components/clinical-dashboard/global-search-shell.tsx +++ b/src/components/clinical-dashboard/global-search-shell.tsx @@ -13,8 +13,9 @@ import { useState, } from "react"; +import dynamic from "next/dynamic"; + import { AccountSetupDialog } from "@/components/clinical-dashboard/account-setup-dialog"; -import { ClinicalDashboard } from "@/components/ClinicalDashboard"; import { clearLegacyRecentQueries, demoRecentQueryOwnerId, loadRecentQueries } from "@/lib/recent-query-storage"; import { PatientProfileProvider } from "@/components/clinical-dashboard/patient-profile-context"; import { SearchCommandProvider } from "@/components/clinical-dashboard/search-command-context"; @@ -37,7 +38,6 @@ import { readChromeCollapseBudget, useScrollHideReporter } from "@/components/cl import { ModeHomeRouteLoading } from "@/components/mode-home-page-skeleton"; import { useSidebarCollapsed } from "@/components/clinical-dashboard/use-sidebar-collapsed"; import { useTheme } from "@/components/clinical-dashboard/use-theme"; -import { ClientHydrationBoundary } from "@/components/client-hydration-boundary"; import { cn } from "@/components/ui-primitives"; import { appModeHomeHref, @@ -46,6 +46,13 @@ import { visibleAppModeDefinitions, type AppModeId, } from "@/lib/app-modes"; + +// Namespaced mode homes share this client shell but never render the dashboard +// body — keep ClinicalDashboard out of their parse/eval path until `/` needs it. +const ClinicalDashboard = dynamic( + () => import("@/components/ClinicalDashboard").then((mod) => ({ default: mod.ClinicalDashboard })), + { ssr: true, loading: () => }, +); import { isLocalNoAuthMode, resolveClientDemoMode } from "@/lib/client-env"; import { documentsSearchHref } from "@/lib/document-flow-routes"; import { differentialsMobileCompareAddonSlotId, modeHomeDesktopComposerSlotId } from "@/lib/mode-home-composer"; @@ -312,6 +319,13 @@ function GlobalStandaloneSearchShellClient({ ); const [mobileMenuOpen, setMobileMenuOpen] = useState(false); const [sidebarCollapsed, setSidebarCollapsed] = useSidebarCollapsed(); + // Skip grid-template-columns transition on first paint / remount so Answer ↔ + // namespaced mode swaps do not animate the sidebar width from the default track. + const [sidebarColumnTransitionReady, setSidebarColumnTransitionReady] = useState(false); + useEffect(() => { + const frame = window.requestAnimationFrame(() => setSidebarColumnTransitionReady(true)); + return () => window.cancelAnimationFrame(frame); + }, []); const [guideOpen, setGuideOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false); const [recentQueries, setRecentQueries] = useState([]); @@ -614,6 +628,7 @@ function GlobalStandaloneSearchShellClient({ "sm:min-h-dvh max-sm:fixed max-sm:inset-0 max-sm:overflow-hidden bg-[color:var(--background)] text-[color:var(--text)]", shouldShowDesktopSidebar && "md:grid md:grid-cols-[5.25rem_minmax(0,1fr)]", shouldShowDesktopSidebar && + sidebarColumnTransitionReady && "motion-safe:transition-[grid-template-columns] motion-safe:duration-200 motion-safe:ease-out", shouldShowDesktopSidebar && (effectiveSidebarCollapsed ? "lg:grid-cols-[5.25rem_minmax(0,1fr)]" : "lg:grid-cols-[20rem_minmax(0,1fr)]"), @@ -788,11 +803,9 @@ function GlobalStandaloneSearchShellClient({ its height, so end-of-page content clears the visible dock. */}
- } - > - {children} - + {/* Paint RSC mode-home HTML immediately. A ClientHydrationBoundary here + blanked every standalone mode until JS mounted (hard-load LCP hit). */} + {children}
diff --git a/src/components/clinical-dashboard/shared-search-app-shell.tsx b/src/components/clinical-dashboard/shared-search-app-shell.tsx index 198c61ede0..0f8a41def6 100644 --- a/src/components/clinical-dashboard/shared-search-app-shell.tsx +++ b/src/components/clinical-dashboard/shared-search-app-shell.tsx @@ -4,6 +4,7 @@ import { Suspense, type ReactNode } from "react"; import { usePathname } from "next/navigation"; import { GlobalSearchShell } from "@/components/clinical-dashboard/global-search-shell"; +import { ModeHomeRouteLoading } from "@/components/mode-home-page-skeleton"; import { TherapyCompassWorkspace } from "@/components/therapy-compass"; import { searchShellPropsForPathname } from "@/lib/search-shell-props"; @@ -15,7 +16,7 @@ export function SharedSearchAppShell({ children }: { children: ReactNode }) { const pathname = usePathname() ?? "/"; const shellProps = searchShellPropsForPathname(pathname); const content = pathname.startsWith("/therapy-compass") ? ( - + }> {children} ) : ( diff --git a/src/components/forms/forms-home-page.tsx b/src/components/forms/forms-home-page.tsx index 36584e6ba4..2a2018d128 100644 --- a/src/components/forms/forms-home-page.tsx +++ b/src/components/forms/forms-home-page.tsx @@ -22,34 +22,38 @@ import { type ModeHomePill, } from "@/components/mode-home-template"; import { appModeHomeHref } from "@/lib/app-modes"; -import { defaultFormSlug } from "@/lib/forms"; import { modeHomeDesktopComposerSlotId } from "@/lib/mode-home-composer"; import { countVerifiedRegistryRecords, useRegistryRecords } from "@/lib/use-registry-records"; -const taskCards: ModeHomeAction[] = [ - { - title: "Find a form", - description: "Title, purpose, or workflow detail.", - icon: Search, - href: appModeHomeHref("forms", { focus: true }), - }, - { - title: "Readiness checks", - description: "Review status, source, and local confirmation.", - icon: ClipboardCheck, - href: `/forms/${defaultFormSlug() ?? ""}`, - }, - { - title: "Check source status", - description: "Find records that still need local confirmation.", - icon: ShieldAlert, - href: appModeHomeHref("forms", { - query: "local confirmation required", - focus: true, - run: true, - }), - }, -]; +// The default form slug is computed server-side (app/forms/page.tsx) and passed +// as a prop: a direct `@/lib/forms` value import here would compile the full +// forms catalog into this client route chunk. +function buildTaskCards(defaultFormSlug: string | null): ModeHomeAction[] { + return [ + { + title: "Find a form", + description: "Title, purpose, or workflow detail.", + icon: Search, + href: appModeHomeHref("forms", { focus: true }), + }, + { + title: "Readiness checks", + description: "Review status, source, and local confirmation.", + icon: ClipboardCheck, + href: `/forms/${defaultFormSlug ?? ""}`, + }, + { + title: "Check source status", + description: "Find records that still need local confirmation.", + icon: ShieldAlert, + href: appModeHomeHref("forms", { + query: "local confirmation required", + focus: true, + run: true, + }), + }, + ]; +} const commonTasks: ModeHomePill[] = [ { @@ -74,7 +78,8 @@ const commonTasks: ModeHomePill[] = [ }, ]; -export function FormsHomePage() { +export function FormsHomePage({ defaultFormSlug = null }: { defaultFormSlug?: string | null }) { + const taskCards = buildTaskCards(defaultFormSlug); const registry = useRegistryRecords("form"); const verifiedCount = countVerifiedRegistryRecords(registry); const registryReady = registry.status === "ready"; diff --git a/src/components/forms/forms-search-results-page.tsx b/src/components/forms/forms-search-results-page.tsx index 22e43b1c5a..1c45e7483c 100644 --- a/src/components/forms/forms-search-results-page.tsx +++ b/src/components/forms/forms-search-results-page.tsx @@ -608,7 +608,8 @@ function RegistryStatusNotice({ status }: { status: RegistryRequestStatus }) { } export function FormsSearchResultsPage(props: FormsSearchResultsPageProps) { - return ; + // No key={query} remount: query is a pure prop (favourites already documents this). + return ; } function FormsSearchResultsPageContent({ query }: FormsSearchResultsPageProps) { diff --git a/src/components/mode-home-page-skeleton.tsx b/src/components/mode-home-page-skeleton.tsx index 8fc48e5aa2..6c9eaa2955 100644 --- a/src/components/mode-home-page-skeleton.tsx +++ b/src/components/mode-home-page-skeleton.tsx @@ -25,10 +25,9 @@ export function ModeHomePageSkeleton() { export function ModeHomeRouteLoading() { return ( - // Match the live shell canvas (header token + idle content pad), not a - // larger 13.5rem chrome budget — that taller skeleton jumped when the - // real mode home mounted inside GlobalSearchShell. -
+ // Match ModeHomeMain startOnPhone: top-align on phones, centre from sm up. + // A phone-centred skeleton jumped when content-rich homes mounted top-aligned. +
); diff --git a/tests/forms-client-boundary.test.ts b/tests/forms-client-boundary.test.ts new file mode 100644 index 0000000000..e46c776d84 --- /dev/null +++ b/tests/forms-client-boundary.test.ts @@ -0,0 +1,139 @@ +import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; +import { dirname, join, relative, resolve } from "node:path"; +import { describe, expect, it } from "vitest"; + +// Mirror tests/services-client-boundary.test.ts: the forms catalog must not +// enter any client module graph via a value import of @/lib/forms. +const SRC_ROOT = join(process.cwd(), "src"); +const TARGET_SPECIFIER = "@/lib/forms"; +const SIDE_EFFECT_IMPORT_PATTERN = /^import\s+["']([^"']+)["']/gm; +const DYNAMIC_IMPORT_PATTERN = /\bimport\s*\(\s*(?:\/\*[\s\S]*?\*\/\s*)*["']([^"']+)["'][\s\S]*?\)/g; +const FROM_STATEMENT_PATTERN = /^(import|export)\s+([\s\S]+?)\s+from\s+["']([^"']+)["']/gm; + +function hasRuntimeBindings(kind: string, clause: string): boolean { + const trimmed = clause.trim(); + if (/^type\b/.test(trimmed)) return false; + const named = trimmed.match(/^\{([\s\S]*)\}$/); + if (named) { + return named[1] + .split(",") + .map((specifier) => specifier.trim()) + .filter(Boolean) + .some((specifier) => !/^type\b/.test(specifier)); + } + return kind === "import" || kind === "export"; +} + +function collectSourceFiles(dir: string): string[] { + return readdirSync(dir).flatMap((entry) => { + const fullPath = join(dir, entry); + if (statSync(fullPath).isDirectory()) return collectSourceFiles(fullPath); + return /\.(?:ts|tsx)$/.test(entry) ? [fullPath] : []; + }); +} + +function resolveImport(specifier: string, fromFile: string): string | null { + let base: string; + if (specifier.startsWith("@/")) base = join(SRC_ROOT, specifier.slice(2)); + else if (specifier.startsWith(".")) base = resolve(dirname(fromFile), specifier); + else return null; + + for (const candidate of [base, `${base}.ts`, `${base}.tsx`, join(base, "index.ts"), join(base, "index.tsx")]) { + if (existsSync(candidate) && statSync(candidate).isFile()) return candidate; + } + return null; +} + +const FORMS_MODULE_PATHS = new Set( + ["lib/forms.ts", "lib/forms.tsx", "lib/forms/index.ts", "lib/forms/index.tsx"].map((candidate) => + join(SRC_ROOT, candidate), + ), +); + +function isClientEntry(source: string): boolean { + const prologue = source.replace(/^(?:\s+|\/\/[^\n]*(?:\n|$)|\/\*[\s\S]*?\*\/)*/, ""); + return /^["']use client["']/.test(prologue); +} + +interface ModuleInfo { + importsForms: boolean; + isClientEntry: boolean; + valueImports: string[]; +} + +function buildModuleGraph(): Map { + const graph = new Map(); + + for (const filePath of collectSourceFiles(SRC_ROOT)) { + const source = readFileSync(filePath, "utf8"); + const valueImports: string[] = []; + let importsForms = false; + + const recordRuntimeSpecifier = (specifier: string) => { + const resolved = resolveImport(specifier, filePath); + if ( + specifier === TARGET_SPECIFIER || + specifier.startsWith(`${TARGET_SPECIFIER}/`) || + (resolved !== null && FORMS_MODULE_PATHS.has(resolved)) + ) { + importsForms = true; + } + if (resolved) valueImports.push(resolved); + }; + + for (const match of source.matchAll(SIDE_EFFECT_IMPORT_PATTERN)) recordRuntimeSpecifier(match[1]); + for (const match of source.matchAll(DYNAMIC_IMPORT_PATTERN)) recordRuntimeSpecifier(match[1]); + for (const match of source.matchAll(FROM_STATEMENT_PATTERN)) { + if (hasRuntimeBindings(match[1], match[2])) recordRuntimeSpecifier(match[3]); + } + + graph.set(filePath, { + importsForms, + isClientEntry: isClientEntry(source), + valueImports, + }); + } + + return graph; +} + +describe("forms catalog client boundary", () => { + it("keeps @/lib/forms value-imports out of every client module graph", () => { + const graph = buildModuleGraph(); + const offenders: string[] = []; + + for (const [entryPath, entry] of graph) { + if (!entry.isClientEntry) continue; + + const cameFrom = new Map([[entryPath, ""]]); + const queue = [entryPath]; + while (queue.length > 0) { + const currentPath = queue.shift() as string; + const current = graph.get(currentPath); + if (!current) continue; + + if (current.importsForms) { + const chain: string[] = []; + for (let step: string | undefined = currentPath; step; step = cameFrom.get(step) || undefined) { + chain.unshift(relative(process.cwd(), step)); + } + offenders.push(chain.join(" -> ")); + break; + } + for (const next of current.valueImports) { + if (!cameFrom.has(next)) { + cameFrom.set(next, currentPath); + queue.push(next); + } + } + } + } + + expect( + offenders, + "Client module graphs must not value-import @/lib/forms: it compiles the full forms " + + "catalog into their chunk. Compute what you need server-side and pass it as a prop " + + "(see src/app/(search-app)/forms/page.tsx), or use `import type` for types only.", + ).toEqual([]); + }); +}); diff --git a/tests/mode-home-loading-contract.test.ts b/tests/mode-home-loading-contract.test.ts new file mode 100644 index 0000000000..8e8f2eee76 --- /dev/null +++ b/tests/mode-home-loading-contract.test.ts @@ -0,0 +1,63 @@ +import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +const SEARCH_APP_ROOT = join(process.cwd(), "src/app/(search-app)"); + +const MODE_HOME_LOADING_ROUTES = [ + "services", + "forms", + "favourites", + "differentials", + "dsm", + "specifiers", + "formulation", + "therapy-compass", + "factsheets", + "tools", +] as const; + +describe("mode-home loading contract", () => { + it("uses ModeHomeRouteLoading for every standalone mode home", () => { + for (const route of MODE_HOME_LOADING_ROUTES) { + const loadingPath = join(SEARCH_APP_ROOT, route, "loading.tsx"); + expect(existsSync(loadingPath), `missing ${route}/loading.tsx`).toBe(true); + const source = readFileSync(loadingPath, "utf8"); + expect(source).toContain("ModeHomeRouteLoading"); + expect(source).not.toMatch(/Loading services|Loading medication|Loading library/); + } + }); + + it("does not blank standalone mode children behind ClientHydrationBoundary", () => { + const shellSource = readFileSync( + join(process.cwd(), "src/components/clinical-dashboard/global-search-shell.tsx"), + "utf8", + ); + expect(shellSource).not.toMatch(/import\s*\{[^}]*ClientHydrationBoundary/); + expect(shellSource).not.toMatch(/{children}"); + }); + + it("lazy-loads ClinicalDashboard so namespaced homes skip its module graph", () => { + const shellSource = readFileSync( + join(process.cwd(), "src/components/clinical-dashboard/global-search-shell.tsx"), + "utf8", + ); + expect(shellSource).toMatch(/dynamic\(\s*\(\)\s*=>\s*import\("@\/components\/ClinicalDashboard"\)/); + expect(shellSource).not.toMatch(/^import \{ ClinicalDashboard \} from/m); + }); + + it("keeps mode-home route loading top-aligned on phones", () => { + const skeletonSource = readFileSync(join(process.cwd(), "src/components/mode-home-page-skeleton.tsx"), "utf8"); + expect(skeletonSource).toContain("items-start"); + expect(skeletonSource).toContain("sm:items-center"); + }); +}); + +// Keep the suite from being deleted as unused if the route inventory drifts. +describe("search-app route inventory smoke", () => { + it("still has a (search-app) tree", () => { + expect(statSync(SEARCH_APP_ROOT).isDirectory()).toBe(true); + expect(readdirSync(SEARCH_APP_ROOT).length).toBeGreaterThan(5); + }); +}); From c64a2f02ca00911087da1c96ea052f50da93a34b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 25 Jul 2026 14:16:44 +0000 Subject: [PATCH 10/25] docs(ledger): record parallel loading behaviour review fixes Co-authored-by: BigSimmo --- docs/branch-review-ledger.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index 6119157dd4..38ea95eb16 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -803,3 +803,4 @@ This file is append-only. Never rewrite or delete an existing review record; app | 2026-07-25 | `codex/therapy-page-polish-ad78b4` | `157559aa0678f02de09c14f66d544b62a5138c4a` | Targeted release review: Therapy naming, centred navigation, and white canvas | APPROVE. No P0-P3 findings. The production Therapy route consistently uses the title Therapy, the shared page background token, and a centred overflow-safe section navigation. The latest `origin/main` merge was clean and retained both upstream responsive/home-composer assertions. Highest residual risk is visual drift at an untested browser engine; exact desktop and phone Chromium measurements were stable. | Focused Vitest 40/40; pre-sync `verify:cheap` 378 files / 3342 passed / 1 skipped; pre-sync `verify:ui` passed; integrated runtime, Prettier, lint, and typecheck passed; integrated Vitest was interrupted by the shared heavyweight-test queue after an independent 378-file / 3342-pass run. Required hosted checks must pass on the published exact head before merge. No clinical/provider workflow ran. | | 2026-07-25 | codex/search-results-filters-20260725 (PR #1184) | 8f74d8bd40810ede34ad4b155973b598c1be0101 | Superseding merge-readiness review after Sources focus repair | APPROVE. Supersedes the 88131e72 row: the automated P2 showed a transient Daily Actions menu item could disconnect before Sources restored focus. Closing Sources now falls back after unmount to the currently rendered action trigger, and the regression requires the visible Documents trigger to own focus. No P0-P2 finding remains. RAG impact: no retrieval behaviour change - UI focus restoration only. | Post-fix isolated production Chromium 1/1; post-current-main local Chromium 1/1; `npm run verify:cheap` pass (378 files, 3350 passed, 1 skipped); targeted Prettier and ESLint pass; required hosted checks must rerun on the published exact head; no live clinical/provider workflow ran. | | 2026-07-25 | `codex/mobile-search-filter-dropdowns-dcbb32` | `d9f0051ef8335817e7fa29aeb02ee6324331df39` | Release review: responsive search-result filter dropdowns across production modes | APPROVE with verification note. No P0-P2 finding. Phone result-type rails are replaced by page-specific native selects while desktop controls remain intact; shared controls own a real 44px interactive target and retain forced-colors/focus behavior. Highest residual risk is browser-specific native-select rendering outside Chromium. RAG impact: no retrieval behaviour change - result filtering and sort presentation only. | `npm run verify:cheap` pass twice (378 files, 3351 passed, 1 skipped); focused production Chromium routes and accessibility/stress guards pass; production `npm run build` and client-bundle secret scan pass; offline RAG fixtures 36/36 pass. `verify:pr-local` reached unit tests but local infrastructure tests timed out under heavy worktree contention; two remained timeout-only on focused retry. Required hosted checks must pass on the exact published head before merge. No live clinical/provider workflow ran. | +| 2026-07-25 | `cursor/fix-mode-switch-lag-22f6` / PR #1187 | `876d7ecfa1ae8ec79fc0f0bdf1198c6640ad89b8` | Parallel loading UX + frontend-architecture review + quick-win fixes | No P0. Confirmed live: H1 dashboard↔standalone remount dominant (~0.6–1.2s settle); H4 hero portal rebind; H3 registry post-paint. FIXED quick wins: remove ClientHydrationBoundary blanking; ModeHomeRouteLoading startOnPhone; mode-home loading.tsx alignment/additions; forms server defaultFormSlug + client-boundary test; dynamic ClinicalDashboard; sidebar grid transition mount-gate; forms drop key=query; DocumentViewer key=id; therapy Suspense ModeHomeRouteLoading; redirect /?mode=services|forms. Residual P2: unify shells (H1), stable hero slot, registry abort+LRU/summary fields, Tools dual entry #007, prescribing full-catalogue cliff. | Parallel explore×3 + debug measurement; focused Vitest loading/forms/ownership/align contracts; typecheck; eslint touched shell. No verify:ui / provider-backed checks. | From 905b7a5755d363ed8104a70f626b0aca301b5f43 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 25 Jul 2026 14:18:03 +0000 Subject: [PATCH 11/25] refactor: extract sidebar transition hook and private-scope URL helper Keeps ClinicalDashboard inside the maintainability budget after the loading-performance pass, and shares the remount-safe sidebar transition gate with GlobalSearchShell. Co-authored-by: BigSimmo --- scripts/check-maintainability-budgets.mjs | 4 +++- src/components/ClinicalDashboard.tsx | 20 +++++++------------ .../global-search-shell.tsx | 9 ++------- .../use-sidebar-column-transition.ts | 17 ++++++++++++++++ src/lib/private-search-scope.ts | 8 ++++++++ 5 files changed, 37 insertions(+), 21 deletions(-) create mode 100644 src/components/clinical-dashboard/use-sidebar-column-transition.ts diff --git a/scripts/check-maintainability-budgets.mjs b/scripts/check-maintainability-budgets.mjs index 464df05f43..a32af4735d 100644 --- a/scripts/check-maintainability-budgets.mjs +++ b/scripts/check-maintainability-budgets.mjs @@ -2,7 +2,9 @@ import { readFileSync } from "node:fs"; const budgets = new Map([ - ["src/components/ClinicalDashboard.tsx", 4140], + // Raised with the mode-loading pass: leave-dashboard nav guards + scroll reset. + // Net client cost still fell — GlobalSearchShell now dynamic-imports this file. + ["src/components/ClinicalDashboard.tsx", 4160], ["src/lib/rag/rag.ts", 5030], ["src/components/DocumentViewer.tsx", 1734], ["supabase/functions/indexing-v3-agent/index.ts", 2191], diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index 2747b6f9c5..5eb75d8df2 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -55,6 +55,7 @@ import { AuthPanel } from "@/components/clinical-dashboard/auth-panel"; import { buildMobileSectionFabState, MobileSectionFab, ToolsHub } from "@/components/clinical-dashboard/dashboard-nav"; import { SettingsDialog } from "@/components/clinical-dashboard/settings-dialog"; import { useSidebarCollapsed } from "@/components/clinical-dashboard/use-sidebar-collapsed"; +import { useSidebarColumnTransitionReady } from "@/components/clinical-dashboard/use-sidebar-column-transition"; import { useTheme } from "@/components/clinical-dashboard/use-theme"; import { deriveSidebarIdentity, @@ -169,7 +170,11 @@ import { type PrivateScopeRestorationStatus, type SearchNavigationContext, } from "@/lib/search-navigation-context"; -import { persistPrivateSearchScope, restorePrivateSearchScope } from "@/lib/private-search-scope"; +import { + persistPrivateSearchScope, + removePrivateScopeRefFromUrl, + restorePrivateSearchScope, +} from "@/lib/private-search-scope"; import { parseApiErrorResponse } from "@/lib/api-client-error"; import { answerLifecycleReducer, initialAnswerLifecycle } from "@/lib/answer-lifecycle"; import { useDeferredRegistrySearch } from "@/components/clinical-dashboard/use-deferred-registry-search"; @@ -508,11 +513,7 @@ export function ClinicalDashboard({ const [settingsOpen, setSettingsOpen] = useState(false); const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); const [sidebarCollapsed, setSidebarCollapsed] = useSidebarCollapsed(); - const [sidebarColumnTransitionReady, setSidebarColumnTransitionReady] = useState(false); - useEffect(() => { - const frame = window.requestAnimationFrame(() => setSidebarColumnTransitionReady(true)); - return () => window.cancelAnimationFrame(frame); - }, []); + const sidebarColumnTransitionReady = useSidebarColumnTransitionReady(); const [documentsDrawerOpen, setDocumentsDrawerOpen] = useState(false); const documentsDrawerReturnFocusRef = useRef(null); const [documentScopeOpen, setDocumentScopeOpen] = useState(false); @@ -3304,13 +3305,6 @@ export function ClinicalDashboard({ [priorAnswerTurns, latestAnswerQuery], ); - function removePrivateScopeRefFromUrl() { - const params = new URLSearchParams(window.location.search); - params.delete("scopeRef"); - const next = params.toString(); - window.history.replaceState(null, "", `${window.location.pathname}${next ? `?${next}` : ""}`); - } - function reselectUnavailablePrivateScope() { removePrivateScopeRefFromUrl(); setPrivateScopeStatus("none"); diff --git a/src/components/clinical-dashboard/global-search-shell.tsx b/src/components/clinical-dashboard/global-search-shell.tsx index d39998aacc..2db58e9cea 100644 --- a/src/components/clinical-dashboard/global-search-shell.tsx +++ b/src/components/clinical-dashboard/global-search-shell.tsx @@ -37,6 +37,7 @@ import { import { readChromeCollapseBudget, useScrollHideReporter } from "@/components/clinical-dashboard/use-hide-on-scroll"; import { ModeHomeRouteLoading } from "@/components/mode-home-page-skeleton"; import { useSidebarCollapsed } from "@/components/clinical-dashboard/use-sidebar-collapsed"; +import { useSidebarColumnTransitionReady } from "@/components/clinical-dashboard/use-sidebar-column-transition"; import { useTheme } from "@/components/clinical-dashboard/use-theme"; import { cn } from "@/components/ui-primitives"; import { @@ -319,13 +320,7 @@ function GlobalStandaloneSearchShellClient({ ); const [mobileMenuOpen, setMobileMenuOpen] = useState(false); const [sidebarCollapsed, setSidebarCollapsed] = useSidebarCollapsed(); - // Skip grid-template-columns transition on first paint / remount so Answer ↔ - // namespaced mode swaps do not animate the sidebar width from the default track. - const [sidebarColumnTransitionReady, setSidebarColumnTransitionReady] = useState(false); - useEffect(() => { - const frame = window.requestAnimationFrame(() => setSidebarColumnTransitionReady(true)); - return () => window.cancelAnimationFrame(frame); - }, []); + const sidebarColumnTransitionReady = useSidebarColumnTransitionReady(); const [guideOpen, setGuideOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false); const [recentQueries, setRecentQueries] = useState([]); diff --git a/src/components/clinical-dashboard/use-sidebar-column-transition.ts b/src/components/clinical-dashboard/use-sidebar-column-transition.ts new file mode 100644 index 0000000000..f3546c4afe --- /dev/null +++ b/src/components/clinical-dashboard/use-sidebar-column-transition.ts @@ -0,0 +1,17 @@ +"use client"; + +import { useEffect, useState } from "react"; + +/** + * Enables sidebar grid-template-columns transitions only after mount so a + * remounted shell (Answer ↔ namespaced mode) does not animate from the default + * track width. + */ +export function useSidebarColumnTransitionReady() { + const [ready, setReady] = useState(false); + useEffect(() => { + const frame = window.requestAnimationFrame(() => setReady(true)); + return () => window.cancelAnimationFrame(frame); + }, []); + return ready; +} diff --git a/src/lib/private-search-scope.ts b/src/lib/private-search-scope.ts index 9a143eb6f7..d2146a2451 100644 --- a/src/lib/private-search-scope.ts +++ b/src/lib/private-search-scope.ts @@ -71,3 +71,11 @@ export function restorePrivateSearchScope( return { kind: "unavailable", reason: "invalid" }; } } + +/** Drop a restored private-scope ref from the current URL without a navigation. */ +export function removePrivateScopeRefFromUrl(location: Pick = window.location) { + const params = new URLSearchParams(location.search); + params.delete("scopeRef"); + const next = params.toString(); + window.history.replaceState(null, "", `${location.pathname}${next ? `?${next}` : ""}`); +} From 7bceebf562dc6700091964996a6e2749b0d63df6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 25 Jul 2026 14:19:49 +0000 Subject: [PATCH 12/25] test: allow DocumentViewer identity-only remount key Co-authored-by: BigSimmo --- tests/document-detail-performance.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/document-detail-performance.test.ts b/tests/document-detail-performance.test.ts index 265893fc78..c7e598d6bf 100644 --- a/tests/document-detail-performance.test.ts +++ b/tests/document-detail-performance.test.ts @@ -18,7 +18,10 @@ describe("document detail loading contract", () => { expect(page).toContain("loadAuthorizedDocumentDetail"); expect(page).toContain("initialDetail={initialDetail}"); expect(page).toContain("initialError={initialError}"); - expect(page).toContain('key={`${id}:${initialPage}:${query.chunk ?? ""}`}'); + // Page/chunk updates stay inside DocumentViewer via URL sync — remounting + // on every page flip reloaded the PDF and felt like loading lag. + expect(page).toContain("key={id}"); + expect(page).not.toContain('key={`${id}:${initialPage}:${query.chunk ?? ""}`}'); }); it("supports document and window asset scopes and starts independent detail reads together", () => { From 21c8694e8a418f0812161a81aec65a43486c1c22 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 26 Jul 2026 01:42:02 +0000 Subject: [PATCH 13/25] test: align chrome contracts with pathname reset and gated padding After merging main's cross-breakpoint scroll-hide wiring, update static contracts for resetKey=pathname, and keep phone padding transitions gated to scroll-hide only so mode switches still snap. Co-authored-by: BigSimmo --- tests/clinical-dashboard-merge-artifacts.test.ts | 5 ++++- tests/header-scroll-hide-contract.test.ts | 4 +++- tests/search-route-ownership.test.ts | 3 ++- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/tests/clinical-dashboard-merge-artifacts.test.ts b/tests/clinical-dashboard-merge-artifacts.test.ts index a17d2f8da7..58af9ff8ce 100644 --- a/tests/clinical-dashboard-merge-artifacts.test.ts +++ b/tests/clinical-dashboard-merge-artifacts.test.ts @@ -167,7 +167,10 @@ describe("ClinicalDashboard merge-artifact guards", () => { expect(globalStylesSource).toContain("@media (max-width: 639px) and (prefers-reduced-motion: reduce)"); expect(globalStylesSource).toContain('#main-content[data-bottom-composer-hidden="true"]'); expect(globalStylesSource).toContain('[data-testid="mobile-composer-reserve-pad"]'); - expect(globalStylesSource).toContain("transition: padding-bottom 200ms var(--ease-out-soft)"); + // Mode/route reserve flips must snap; only scroll-hide animates padding. + expect(globalStylesSource).not.toMatch( + /#main-content\s*\{\s*will-change: padding-bottom;\s*transition: padding-bottom 200ms/, + ); expect(globalStylesSource).toContain("transition: padding-bottom 240ms var(--ease-out-soft)"); expect(globalStylesSource).toContain("--phone-dock-differentials-compare-clearance: 12.5rem"); expect(globalStylesSource).toContain("var(--phone-dock-differentials-compare-clearance)"); diff --git a/tests/header-scroll-hide-contract.test.ts b/tests/header-scroll-hide-contract.test.ts index 25a513e09e..c5171cf973 100644 --- a/tests/header-scroll-hide-contract.test.ts +++ b/tests/header-scroll-hide-contract.test.ts @@ -27,7 +27,9 @@ describe("shared header hide/reveal wiring", () => { it("widens both app shells past the phone media gate", () => { // Second argument is `allowAllBreakpoints`; leaving it off is what pinned // hide-on-scroll to phones. - expect(shellSource).toContain("useScrollHideReporter(false, true)"); + // GlobalSearchShell also passes pathname as resetKey so shared mode homes + // do not inherit a collapsed top bar across routes. + expect(shellSource).toContain("useScrollHideReporter(false, true, pathname)"); expect(dashboardSource).toContain("useScrollHideReporter(false, true, searchMode)"); expect(hookSource).toContain("export function useScrollHideReporter(disabled = false, allowAllBreakpoints = false"); }); diff --git a/tests/search-route-ownership.test.ts b/tests/search-route-ownership.test.ts index eec3ce905c..2bfdc78d95 100644 --- a/tests/search-route-ownership.test.ts +++ b/tests/search-route-ownership.test.ts @@ -103,7 +103,8 @@ describe("shared-search route ownership", () => { ); expect(hideSource).toContain("const reset = useCallback"); expect(hideSource).toMatch(/return \{ hidden: active && hidden, reportScroll, reset \}/); - expect(shellSource).toContain("resetPhoneScrollHideRef.current()"); + // Hide state resets via the reporter resetKey; scroll offset resets explicitly. + expect(shellSource).toContain("useScrollHideReporter(false, true, pathname)"); expect(shellSource).toMatch(/main\.scrollTop = 0[\s\S]*\}, \[pathname\]\)/); }); From c9f04e59a2993efc8f65200c0840f9ed249f1d48 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 26 Jul 2026 03:20:42 +0000 Subject: [PATCH 14/25] fix: clear CI hydration and phone-scroll failures Gate desktop composer portal adoption until page-owned slots mark themselves ready after hydration, so hard-loads no longer inject a display:contents host into still-unhydrated RSC HTML (React #418). Update phone-scroll expectations for scroll-hide-only reserve transitions. Co-authored-by: BigSimmo --- src/components/ClinicalDashboard.tsx | 3 +- src/components/applications-launcher-page.tsx | 3 +- .../favourites-command-library-page.tsx | 3 +- .../clinical-dashboard/favourites-hub.tsx | 6 +++- .../global-search-shell.tsx | 3 +- .../master-search-header.tsx | 17 +++++++-- .../desktop-composer-portal-slot.tsx | 36 +++++++++++++++++++ .../factsheets/factsheets-home-page.tsx | 3 +- src/components/mode-home-template.tsx | 3 +- .../services/services-navigator-page.tsx | 3 +- src/lib/mode-home-composer.ts | 8 +++++ tests/search-route-ownership.test.ts | 14 ++++++++ tests/ui-phone-scroll.spec.ts | 11 +++++- 13 files changed, 102 insertions(+), 11 deletions(-) create mode 100644 src/components/desktop-composer-portal-slot.tsx diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index 38493ad6b6..5b2324e8f5 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -207,6 +207,7 @@ import type { DocumentLabel, } from "@/lib/types"; import type { SearchScopeFilters } from "@/lib/search-scope"; +import { DesktopComposerPortalSlot } from "@/components/desktop-composer-portal-slot"; import { desktopPageComposerSlotId, differentialsMobileCompareAddonSlotId, @@ -3512,7 +3513,7 @@ export function ClinicalDashboard({ )} > {desktopResultComposerSlotId ? ( -
{desktopComposerSlotId ? ( -
diff --git a/src/components/clinical-dashboard/favourites-command-library-page.tsx b/src/components/clinical-dashboard/favourites-command-library-page.tsx index 13fc3a2f94..a46e61d777 100644 --- a/src/components/clinical-dashboard/favourites-command-library-page.tsx +++ b/src/components/clinical-dashboard/favourites-command-library-page.tsx @@ -47,6 +47,7 @@ import { useSearchCommand } from "@/components/clinical-dashboard/search-command import { favouriteMatchesCommandScopes } from "@/lib/search-command-surface"; import { appModeIcons } from "@/lib/app-mode-icons"; import { canAccessFavouritesMode } from "@/lib/app-modes"; +import { DesktopComposerPortalSlot } from "@/components/desktop-composer-portal-slot"; import { modeHomeDesktopComposerSlotId } from "@/lib/mode-home-composer"; import { useAuthSession } from "@/lib/supabase/client"; import { UniversalSearchAlsoMatches } from "@/components/clinical-dashboard/universal-search-also-matches"; @@ -1185,7 +1186,7 @@ export function FavouritesCommandLibraryPage({ query = "", demoMode }: { query?:

) : null} -
diff --git a/src/components/clinical-dashboard/favourites-hub.tsx b/src/components/clinical-dashboard/favourites-hub.tsx index f27e5e890e..b97db3cb8e 100644 --- a/src/components/clinical-dashboard/favourites-hub.tsx +++ b/src/components/clinical-dashboard/favourites-hub.tsx @@ -4,6 +4,7 @@ import Link from "next/link"; import { ArrowUpDown, ChevronDown, Filter, Folder, Heart, Plus, Search, ShieldCheck, X } from "lucide-react"; import { useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react"; import { useDismissableLayer } from "@/components/use-dismissable-layer"; +import { DesktopComposerPortalSlot } from "@/components/desktop-composer-portal-slot"; import { ModeHomeHero, ModeHomeVerificationFooter } from "@/components/mode-home-template"; import { cn, @@ -169,7 +170,10 @@ export function FavouritesHub({ /> {desktopComposerSlotId ? ( -
+ ) : null}
diff --git a/src/components/clinical-dashboard/global-search-shell.tsx b/src/components/clinical-dashboard/global-search-shell.tsx index 4f8113034c..9dcdc33f5e 100644 --- a/src/components/clinical-dashboard/global-search-shell.tsx +++ b/src/components/clinical-dashboard/global-search-shell.tsx @@ -65,6 +65,7 @@ const ClinicalDashboard = dynamic( import { isLocalNoAuthMode, resolveClientDemoMode } from "@/lib/client-env"; import { documentsSearchHref } from "@/lib/document-flow-routes"; import { isInformationPage } from "@/lib/information-pages"; +import { DesktopComposerPortalSlot } from "@/components/desktop-composer-portal-slot"; import { desktopPageComposerSlotId, differentialsMobileCompareAddonSlotId, @@ -819,7 +820,7 @@ function GlobalStandaloneSearchShellClient({ */}
{shouldShowSearchComposer && !isStandaloneModeHome ? ( -
{ if (retryTimeout !== null) { window.clearTimeout(retryTimeout); retryTimeout = null; } const slot = mediaQuery.matches ? document.getElementById(composerSlotId) : null; - if (slot) { + if (slot && isDesktopComposerSlotReady(slot)) { portalRetryCount = 0; if (host.parentNode !== slot) slot.appendChild(host); setDesktopComposerPortalHost(host); @@ -1052,7 +1060,12 @@ export function MasterSearchHeader({ }; const observer = new MutationObserver(syncTarget); - observer.observe(document.body, { childList: true, subtree: true }); + observer.observe(document.body, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: [desktopComposerSlotReadyAttr], + }); syncTarget(); mediaQuery.addEventListener("change", syncTarget); return () => { diff --git a/src/components/desktop-composer-portal-slot.tsx b/src/components/desktop-composer-portal-slot.tsx new file mode 100644 index 0000000000..11b0aa20d0 --- /dev/null +++ b/src/components/desktop-composer-portal-slot.tsx @@ -0,0 +1,36 @@ +"use client"; + +import { useEffect, useRef, type ComponentPropsWithoutRef } from "react"; + +import { + desktopComposerSlotReadyAttr, + desktopComposerSlotReadyValue, +} from "@/lib/mode-home-composer"; + +type DesktopComposerPortalSlotProps = { + id: string; +} & Omit, "id" | "children">; + +/** + * Page-owned host for the desktop/hero search composer portal. + * + * The slot must stay empty through SSR and the page segment's first client + * paint so React hydration matches. MasterSearchHeader only adopts the slot + * after this attribute flips in useEffect (post-hydration). Adopting earlier + * injects a `display:contents` host into still-unhydrated RSC HTML and trips + * React #418 on mode homes. + */ +export function DesktopComposerPortalSlot({ id, className, ...rest }: DesktopComposerPortalSlotProps) { + const ref = useRef(null); + + useEffect(() => { + const el = ref.current; + if (!el) return; + el.setAttribute(desktopComposerSlotReadyAttr, desktopComposerSlotReadyValue); + return () => { + el.removeAttribute(desktopComposerSlotReadyAttr); + }; + }, []); + + return
; +} diff --git a/src/components/factsheets/factsheets-home-page.tsx b/src/components/factsheets/factsheets-home-page.tsx index 184470dc92..ae2fe4705a 100644 --- a/src/components/factsheets/factsheets-home-page.tsx +++ b/src/components/factsheets/factsheets-home-page.tsx @@ -8,6 +8,7 @@ import { featuredFactsheets, } from "@/components/factsheets/factsheets-data"; import { factsheetCategoryGlyph, factsheetGlyph } from "@/components/factsheets/factsheets-icons"; +import { DesktopComposerPortalSlot } from "@/components/desktop-composer-portal-slot"; import { ModeHomeHero, ModeHomeVerificationFooter } from "@/components/mode-home-template"; import { cn, eyebrowText } from "@/components/ui-primitives"; import { modeHomeDesktopComposerSlotId } from "@/lib/mode-home-composer"; @@ -29,7 +30,7 @@ export function FactsheetsHomePage() { /> {/* The universal composer portals itself into this slot on the mode home (hero placement). */} -
diff --git a/src/components/mode-home-template.tsx b/src/components/mode-home-template.tsx index 76fa14b000..fb7e2af9c2 100644 --- a/src/components/mode-home-template.tsx +++ b/src/components/mode-home-template.tsx @@ -2,6 +2,7 @@ import Link from "next/link"; import { type ReactNode } from "react"; import { type LucideIcon, ArrowRight } from "lucide-react"; +import { DesktopComposerPortalSlot } from "@/components/desktop-composer-portal-slot"; import { cn, eyebrowText } from "@/components/ui-primitives"; export type ModeHomeAction = { @@ -252,7 +253,7 @@ export function ModeHomeTemplate({ {desktopComposerSlotId ? ( -
diff --git a/src/components/services/services-navigator-page.tsx b/src/components/services/services-navigator-page.tsx index ab42846c87..7cc81bda05 100644 --- a/src/components/services/services-navigator-page.tsx +++ b/src/components/services/services-navigator-page.tsx @@ -34,6 +34,7 @@ import { import { useSearchCommand } from "@/components/clinical-dashboard/search-command-context"; import { appModeHomeHref } from "@/lib/app-modes"; import { recordMatchesCommandScopes } from "@/lib/search-command-surface"; +import { DesktopComposerPortalSlot } from "@/components/desktop-composer-portal-slot"; import { modeHomeDesktopComposerSlotId } from "@/lib/mode-home-composer"; import { rankServiceRecords, type ServiceRecord, type ServiceStatusChip } from "@/lib/service-ranker"; import { canCompareServices, serviceNavigatorMetrics } from "@/lib/service-navigator-metrics"; @@ -624,7 +625,7 @@ export function ServicesNavigatorPage() { resultsLabel="Referral services" header={ <> -
diff --git a/src/lib/mode-home-composer.ts b/src/lib/mode-home-composer.ts index 096532af1a..cb1554cdc7 100644 --- a/src/lib/mode-home-composer.ts +++ b/src/lib/mode-home-composer.ts @@ -1,6 +1,14 @@ export const modeHomeDesktopComposerSlotId = "mode-home-desktop-composer-slot"; export const desktopPageComposerSlotId = "desktop-page-search-composer-slot"; +/** Set on page-owned composer slots only after the owning segment has hydrated. */ +export const desktopComposerSlotReadyAttr = "data-composer-slot-ready"; +export const desktopComposerSlotReadyValue = "true"; + +export function isDesktopComposerSlotReady(slot: Element | null | undefined): boolean { + return slot?.getAttribute(desktopComposerSlotReadyAttr) === desktopComposerSlotReadyValue; +} + /** Mobile/tablet search-composer slot for differentials compare actions. */ export const differentialsMobileCompareAddonSlotId = "differentials-mobile-compare-addon-slot"; diff --git a/tests/search-route-ownership.test.ts b/tests/search-route-ownership.test.ts index acadebea32..6727f06b87 100644 --- a/tests/search-route-ownership.test.ts +++ b/tests/search-route-ownership.test.ts @@ -120,6 +120,20 @@ describe("shared-search route ownership", () => { ); }); + it("waits for page-owned composer slots to hydrate before portal adoption", () => { + const headerSource = readFileSync( + resolve(process.cwd(), "src/components/clinical-dashboard/master-search-header.tsx"), + "utf8", + ); + const slotSource = readFileSync(resolve(process.cwd(), "src/components/desktop-composer-portal-slot.tsx"), "utf8"); + const composerLibSource = readFileSync(resolve(process.cwd(), "src/lib/mode-home-composer.ts"), "utf8"); + expect(composerLibSource).toContain('desktopComposerSlotReadyAttr = "data-composer-slot-ready"'); + expect(slotSource).toContain("desktopComposerSlotReadyAttr"); + expect(slotSource).toContain("useEffect"); + expect(headerSource).toContain("isDesktopComposerSlotReady(slot)"); + expect(headerSource).toContain("attributeFilter: [desktopComposerSlotReadyAttr]"); + }); + it("leaves the dashboard shell without eager chrome thrash", () => { const dashboardSource = readFileSync(resolve(process.cwd(), "src/components/ClinicalDashboard.tsx"), "utf8"); expect(dashboardSource).toContain("isDashboardModeHref"); diff --git a/tests/ui-phone-scroll.spec.ts b/tests/ui-phone-scroll.spec.ts index 96878579e3..6acf7241d8 100644 --- a/tests/ui-phone-scroll.spec.ts +++ b/tests/ui-phone-scroll.spec.ts @@ -80,6 +80,7 @@ interface ScrollGeometry { scrollTop: number; maxOffset: number; headerHidden: boolean; + bottomComposerHidden: boolean; docScrollableExcess: number; horizontalOverflow: number; reserveTransitionDuration: string; @@ -95,6 +96,7 @@ function readGeometry(page: Page): Promise { scrollTop: main?.scrollTop ?? 0, maxOffset: main ? Math.max(0, main.scrollHeight - main.clientHeight) : 0, headerHidden: header?.getAttribute("data-scroll-hidden") === "true", + bottomComposerHidden: main?.getAttribute("data-bottom-composer-hidden") === "true", docScrollableExcess: doc.scrollHeight - doc.clientHeight, horizontalOverflow: Math.max(doc.scrollWidth, document.body?.scrollWidth ?? 0) - window.innerWidth, reserveTransitionDuration: reserveHost ? getComputedStyle(reserveHost).transitionDuration : "", @@ -227,7 +229,9 @@ for (const route of [...modeHomeRoutes, ...dashboardRoutes, ...longRoutes]) { expect(initial.horizontalOverflow, "no horizontal overflow").toBeLessThanOrEqual(2); expect(initial.scrollTop).toBe(0); expect(initial.headerHidden, "header visible at the top").toBe(false); - expect(initial.reserveTransitionDuration, "phone reserve transition remains exercised").toContain("0.2s"); + // Mode/route reserve flips snap (0s). Padding only animates while + // data-bottom-composer-hidden="true" (scroll-hide), asserted below. + expect(initial.reserveTransitionDuration, "visible reserve must snap on mode/route flips").toBe("0s"); // Drag to the bottom in deliberate 24px steps, then let transitions settle. await dragScrollBy(page, initial.maxOffset + 400, 24); @@ -242,6 +246,11 @@ for (const route of [...modeHomeRoutes, ...dashboardRoutes, ...longRoutes]) { Math.abs(atBottom.scrollTop - atBottom.maxOffset), "settled scroll sits on the true bottom edge", ).toBeLessThanOrEqual(2); + if (atBottom.bottomComposerHidden) { + expect(atBottom.reserveTransitionDuration, "scroll-hide reserve transition remains exercised").toContain( + "0.24s", + ); + } // At most one chrome transition on a pure descent (hide, when the page is // long enough to afford it) — more means hide/reveal oscillation. expect(flipsAfterDescent, "no chrome oscillation while scrolling down").toBeLessThanOrEqual(1); From 965b821fd6b1988d2ef2873a3f59fad901839b06 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 26 Jul 2026 03:22:42 +0000 Subject: [PATCH 15/25] style: prettier-format portal ready-gate files Co-authored-by: BigSimmo --- src/components/clinical-dashboard/master-search-header.tsx | 5 +---- src/components/desktop-composer-portal-slot.tsx | 5 +---- tests/ui-phone-scroll.spec.ts | 4 +--- 3 files changed, 3 insertions(+), 11 deletions(-) diff --git a/src/components/clinical-dashboard/master-search-header.tsx b/src/components/clinical-dashboard/master-search-header.tsx index fd41a67fa5..3338c5ebd7 100644 --- a/src/components/clinical-dashboard/master-search-header.tsx +++ b/src/components/clinical-dashboard/master-search-header.tsx @@ -70,10 +70,7 @@ import { appModeIcons } from "@/lib/app-mode-icons"; import { resolveScrollBehavior } from "@/lib/scroll-behavior"; import type { ClinicalDocument, ClinicalQueryMode } from "@/lib/types"; import { type SearchScopeFilters } from "@/lib/search-scope"; -import { - desktopComposerSlotReadyAttr, - isDesktopComposerSlotReady, -} from "@/lib/mode-home-composer"; +import { desktopComposerSlotReadyAttr, isDesktopComposerSlotReady } from "@/lib/mode-home-composer"; import { tagSearchText } from "@/lib/document-tags"; // Shared between the composer input's aria-describedby and the rendered diff --git a/src/components/desktop-composer-portal-slot.tsx b/src/components/desktop-composer-portal-slot.tsx index 11b0aa20d0..c234109fd2 100644 --- a/src/components/desktop-composer-portal-slot.tsx +++ b/src/components/desktop-composer-portal-slot.tsx @@ -2,10 +2,7 @@ import { useEffect, useRef, type ComponentPropsWithoutRef } from "react"; -import { - desktopComposerSlotReadyAttr, - desktopComposerSlotReadyValue, -} from "@/lib/mode-home-composer"; +import { desktopComposerSlotReadyAttr, desktopComposerSlotReadyValue } from "@/lib/mode-home-composer"; type DesktopComposerPortalSlotProps = { id: string; diff --git a/tests/ui-phone-scroll.spec.ts b/tests/ui-phone-scroll.spec.ts index 6acf7241d8..a4ffad0854 100644 --- a/tests/ui-phone-scroll.spec.ts +++ b/tests/ui-phone-scroll.spec.ts @@ -247,9 +247,7 @@ for (const route of [...modeHomeRoutes, ...dashboardRoutes, ...longRoutes]) { "settled scroll sits on the true bottom edge", ).toBeLessThanOrEqual(2); if (atBottom.bottomComposerHidden) { - expect(atBottom.reserveTransitionDuration, "scroll-hide reserve transition remains exercised").toContain( - "0.24s", - ); + expect(atBottom.reserveTransitionDuration, "scroll-hide reserve transition remains exercised").toContain("0.24s"); } // At most one chrome transition on a pure descent (hide, when the page is // long enough to afford it) — more means hide/reveal oscillation. From deeaa6cb49ffbabcf3937e241396b31b26cba1d9 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sun, 26 Jul 2026 11:44:56 +0800 Subject: [PATCH 16/25] fix: keep document searches dashboard-owned and animate reserve reveal Treat /documents/search as in-shell for cross-mode sync, keep a short-lived reserve-transition marker through hide and reveal, omit readiness cards without a default slug, and preserve URL hashes when clearing private scope refs. --- docs/search-chrome-behaviour.md | 2 +- src/app/globals.css | 20 +++++------ src/components/ClinicalDashboard.tsx | 8 ++++- src/components/DocumentViewer.tsx | 10 ++++-- .../global-search-shell.tsx | 3 ++ .../clinical-dashboard/use-hide-on-scroll.ts | 30 +++++++++++++++++ src/components/forms/forms-home-page.tsx | 33 ++++++++++--------- src/lib/private-search-scope.ts | 6 ++-- src/lib/search-route-ownership.ts | 8 +++-- ...clinical-dashboard-merge-artifacts.test.ts | 6 ++-- tests/search-route-ownership.test.ts | 3 ++ 11 files changed, 94 insertions(+), 35 deletions(-) diff --git a/docs/search-chrome-behaviour.md b/docs/search-chrome-behaviour.md index 9dd88a86cb..ca424e4dcc 100644 --- a/docs/search-chrome-behaviour.md +++ b/docs/search-chrome-behaviour.md @@ -26,7 +26,7 @@ This repo uses one shared search experience across the global shell, dashboard r 9. Keep collapse-budget policy geometry-aware: an in-flow collapsing header needs enough remaining runway to absorb header + dock clearance, while a fixed overlay that only releases bottom reserve may hide when its post-collapse range retains the top reveal band plus deliberate hide intent _and_ the current offset already fits that post-collapse range (no material near-bottom clamp). Do not use synthetic page padding to make the stricter gate pass. 10. Detect reserve-transition clamps from geometry, not a wider pixel tolerance: if the scroll range shrinks and the previous offset no longer fits inside the new maximum, rebase that frame as layout feedback. Once the range stabilizes, the same upward movement must reveal normally. 11. Standalone mode-home detection (`isStandaloneModeHomePath`) is pathname-only. Do not gate hero vs dock on a React `searchMode` that can update before the router pathname lands — that one-frame mismatch animates reserve padding and reads as a choppy screen resize. -12. Phone `#main-content` / reserve-pad `padding-bottom` transitions apply only while `data-bottom-composer-hidden="true"` (scroll-hide). Mode and route reserve flips must snap. +12. Phone `#main-content` / reserve-pad `padding-bottom` transitions apply while `data-reserve-transitioning="true"` (scroll-hide and reveal). Mode and route reserve flips clear that marker immediately and must snap. 13. Shared shell must reset phone scroll offset and scroll-hide state on `pathname` change so mode homes do not inherit a mid-page offset or collapsed header. 14. Hero composer portal: keep the default composer mounted until the portal host is actually attached; do not hide on `slotId` alone (mode-home remounts otherwise flash a null gap). 15. Leaving the dashboard shell for a namespaced mode (`selectSearchMode` / `crossModeSearch`) must navigate without rewriting dashboard chrome first. diff --git a/src/app/globals.css b/src/app/globals.css index bd1e7a6111..e8a35e9266 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -2664,29 +2664,29 @@ html[data-motion="reduced"] .source-capsule-hit[aria-expanded="true"]:hover .sou } } -/* Phone reserve padding animates only while the bottom composer is - scroll-hidden. Mode/route flips of --mobile-composer-reserve (hero ↔ dock) - must snap — a 200ms padding transition there reads as a choppy screen resize. - DocumentViewer keeps its own Tailwind duration classes on scroll-hide. */ +/* Phone reserve padding animates while the short-lived reserve-transition + marker is active (hide and reveal). Mode/route flips of + --mobile-composer-reserve (hero ↔ dock) must snap — those clear the marker + immediately. DocumentViewer keeps its own Tailwind duration classes. */ @media (max-width: 639px) { - #main-content[data-bottom-composer-hidden="true"] { + #main-content[data-reserve-transitioning="true"] { will-change: padding-bottom; transition: padding-bottom 240ms var(--ease-out-soft); } - #main-content[data-bottom-composer-hidden="true"] [data-testid="mobile-composer-reserve-pad"] { + #main-content[data-reserve-transitioning="true"] [data-testid="mobile-composer-reserve-pad"] { will-change: padding-bottom; transition: padding-bottom 240ms var(--ease-out-soft); } - [data-testid="document-viewer-content"][data-scroll-hidden="true"] { + [data-testid="document-viewer-content"][data-reserve-transitioning="true"] { will-change: padding-bottom; transition: padding-bottom 240ms var(--ease-out-soft); } } @media (max-width: 639px) and (prefers-reduced-motion: reduce) { - #main-content[data-bottom-composer-hidden="true"], - #main-content[data-bottom-composer-hidden="true"] [data-testid="mobile-composer-reserve-pad"], - [data-testid="document-viewer-content"][data-scroll-hidden="true"] { + #main-content[data-reserve-transitioning="true"], + #main-content[data-reserve-transitioning="true"] [data-testid="mobile-composer-reserve-pad"], + [data-testid="document-viewer-content"][data-reserve-transitioning="true"] { transition: none; } } diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index 5b2324e8f5..9d075dbf62 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -87,7 +87,11 @@ import { UniversalSearchAlsoMatches } from "@/components/clinical-dashboard/univ import { FavouritesGuestGate } from "@/components/clinical-dashboard/favourites-guest-gate"; import { useDashboardShellActions } from "@/components/clinical-dashboard/use-dashboard-shell-actions"; import { focusComposerInput as scheduleComposerFocus } from "@/components/clinical-dashboard/focus-composer-input"; -import { readChromeCollapseMetrics, useScrollHideReporter } from "@/components/clinical-dashboard/use-hide-on-scroll"; +import { + readChromeCollapseMetrics, + useReserveTransitionMarker, + useScrollHideReporter, +} from "@/components/clinical-dashboard/use-hide-on-scroll"; import { SearchCommandProvider } from "@/components/clinical-dashboard/search-command-context"; import { answerReferencesDocument, @@ -361,6 +365,7 @@ export function ClinicalDashboard({ // mode swaps
's header reserve, so it also rebases the reporter. const chromeScrollHide = useScrollHideReporter(false, true, searchMode); const [bottomComposerHidden, setBottomComposerHidden] = useState(false); + const reserveTransitioning = useReserveTransitionMarker(bottomComposerHidden, searchMode); const reportChromeScrollHideRef = useRef(chromeScrollHide.reportScroll); reportChromeScrollHideRef.current = chromeScrollHide.reportScroll; const [modeSearchSubmitted, setModeSearchSubmitted] = useState(() => @@ -3424,6 +3429,7 @@ export function ClinicalDashboard({ // prettier-ignore onScroll={handleMainScroll} data-bottom-composer-hidden={bottomComposerHidden ? "true" : undefined} + data-reserve-transitioning={reserveTransitioning ? "true" : undefined} className={cn( "min-h-0 flex-1 overflow-x-hidden overflow-y-auto overscroll-contain [-webkit-overflow-scrolling:touch] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-[color:var(--focus)]", // Answer view: the glass header is absolute over this scroll container, diff --git a/src/components/DocumentViewer.tsx b/src/components/DocumentViewer.tsx index f8306b4493..90e7b37846 100644 --- a/src/components/DocumentViewer.tsx +++ b/src/components/DocumentViewer.tsx @@ -20,7 +20,10 @@ import { } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { documentDisplayTitle } from "@/components/DocumentOrganizationBadges"; -import { useHideOnScroll } from "@/components/clinical-dashboard/use-hide-on-scroll"; +import { + useHideOnScroll, + useReserveTransitionMarker, +} from "@/components/clinical-dashboard/use-hide-on-scroll"; import { AnswerProgressStepper } from "@/components/clinical-dashboard/answer-status"; import type { TimedAnswerProgressUpdate } from "@/components/clinical-dashboard/answer-progress"; import { readAnswerStream } from "@/components/clinical-dashboard/search-utils"; @@ -326,11 +329,13 @@ export function DocumentViewer({ observer.disconnect(); }; }, []); + const documentChromeResetKey = `${documentId}:${activePage}:${activeChunkId ?? ""}`; const scrollHidden = useHideOnScroll({ ...(shellScrollContainer ? { scrollContainer: shellScrollContainer } : {}), - resetKey: `${documentId}:${activePage}:${activeChunkId ?? ""}`, + resetKey: documentChromeResetKey, }); const composerScrollHidden = scrollHidden && !mobileActionsOpen && !composerChromeFocused; + const reserveTransitioning = useReserveTransitionMarker(composerScrollHidden, documentChromeResetKey); const [useNativePdfViewer, setUseNativePdfViewer] = useState(getDefaultPdfViewerMode); const [hasExplicitPdfViewerMode, setHasExplicitPdfViewerMode] = useState(false); const [viewerModeInitialized, setViewerModeInitialized] = useState(false); @@ -1246,6 +1251,7 @@ export function DocumentViewer({
{ reportChromeScrollHideRef.current = chromeScrollHide.reportScroll; @@ -792,6 +794,7 @@ function GlobalStandaloneSearchShellClient({ tabIndex={-1} onScroll={handleMainScroll} data-bottom-composer-hidden={bottomComposerHidden ? "true" : undefined} + data-reserve-transitioning={reserveTransitioning ? "true" : undefined} className={cn( // sm+ uses overflow-x-clip (not hidden): hidden forces overflow-y to // auto, which turns #main-content into the sticky scrollport while the diff --git a/src/components/clinical-dashboard/use-hide-on-scroll.ts b/src/components/clinical-dashboard/use-hide-on-scroll.ts index 1334b8a11f..0d8c0cccf8 100644 --- a/src/components/clinical-dashboard/use-hide-on-scroll.ts +++ b/src/components/clinical-dashboard/use-hide-on-scroll.ts @@ -246,6 +246,36 @@ function useScrollHideActive(disabled = false, allowAllBreakpoints = false) { return (allowAllBreakpoints || isPhone) && !disabled; } +/** Matches phone reserve padding transition duration in `globals.css`. */ +export const reserveTransitionMs = 240; + +/** + * Keeps a short-lived transition marker active through both composer hide and + * reveal so reserve padding can animate in both directions. Route/mode geometry + * changes (via `resetKey`) clear the marker immediately so those flips snap. + */ +export function useReserveTransitionMarker(hidden: boolean, resetKey?: unknown) { + const [transitioning, setTransitioning] = useState(false); + const previousHiddenRef = useRef(hidden); + const previousResetKeyRef = useRef(resetKey); + + useEffect(() => { + if (resetKey !== previousResetKeyRef.current) { + previousResetKeyRef.current = resetKey; + previousHiddenRef.current = hidden; + setTransitioning(false); + return; + } + if (hidden === previousHiddenRef.current) return; + previousHiddenRef.current = hidden; + setTransitioning(true); + const timer = window.setTimeout(() => setTransitioning(false), reserveTransitionMs); + return () => window.clearTimeout(timer); + }, [hidden, resetKey]); + + return transitioning; +} + /** * Imperative scroll-offset reporter for hosts that already own a React `onScroll` * handler on the scrolling element (for example ClinicalDashboard `
`). diff --git a/src/components/forms/forms-home-page.tsx b/src/components/forms/forms-home-page.tsx index 2a2018d128..55769e80a8 100644 --- a/src/components/forms/forms-home-page.tsx +++ b/src/components/forms/forms-home-page.tsx @@ -29,30 +29,33 @@ import { countVerifiedRegistryRecords, useRegistryRecords } from "@/lib/use-regi // as a prop: a direct `@/lib/forms` value import here would compile the full // forms catalog into this client route chunk. function buildTaskCards(defaultFormSlug: string | null): ModeHomeAction[] { - return [ + const cards: ModeHomeAction[] = [ { title: "Find a form", description: "Title, purpose, or workflow detail.", icon: Search, href: appModeHomeHref("forms", { focus: true }), }, - { + ]; + if (defaultFormSlug) { + cards.push({ title: "Readiness checks", description: "Review status, source, and local confirmation.", icon: ClipboardCheck, - href: `/forms/${defaultFormSlug ?? ""}`, - }, - { - title: "Check source status", - description: "Find records that still need local confirmation.", - icon: ShieldAlert, - href: appModeHomeHref("forms", { - query: "local confirmation required", - focus: true, - run: true, - }), - }, - ]; + href: `/forms/${defaultFormSlug}`, + }); + } + cards.push({ + title: "Check source status", + description: "Find records that still need local confirmation.", + icon: ShieldAlert, + href: appModeHomeHref("forms", { + query: "local confirmation required", + focus: true, + run: true, + }), + }); + return cards; } const commonTasks: ModeHomePill[] = [ diff --git a/src/lib/private-search-scope.ts b/src/lib/private-search-scope.ts index d2146a2451..ad49650e0c 100644 --- a/src/lib/private-search-scope.ts +++ b/src/lib/private-search-scope.ts @@ -73,9 +73,11 @@ export function restorePrivateSearchScope( } /** Drop a restored private-scope ref from the current URL without a navigation. */ -export function removePrivateScopeRefFromUrl(location: Pick = window.location) { +export function removePrivateScopeRefFromUrl( + location: Pick = window.location, +) { const params = new URLSearchParams(location.search); params.delete("scopeRef"); const next = params.toString(); - window.history.replaceState(null, "", `${location.pathname}${next ? `?${next}` : ""}`); + window.history.replaceState(null, "", `${location.pathname}${next ? `?${next}` : ""}${location.hash}`); } diff --git a/src/lib/search-route-ownership.ts b/src/lib/search-route-ownership.ts index 1f4796759f..4625d08492 100644 --- a/src/lib/search-route-ownership.ts +++ b/src/lib/search-route-ownership.ts @@ -39,9 +39,13 @@ export function isStandaloneModeHomePath(pathname: string): boolean { return standaloneModeHomePaths.has(pathname); } -/** Dashboard-owned hrefs stay on `/` with `?mode=`; everything else leaves the dashboard shell. */ +/** Dashboard-owned hrefs stay on `/` with `?mode=`, or the submitted documents search route. */ export function isDashboardModeHref(href: string): boolean { - return href === "/" || href.startsWith("/?"); + if (href === "/" || href.startsWith("/?")) return true; + // Submitted document searches still render ClinicalDashboard; treat them as + // in-shell so cross-mode navigation can sync searchMode/query before push. + const path = href.split(/[?#]/, 1)[0] ?? href; + return path === "/documents/search"; } export function shouldRenderDashboardSearch({ diff --git a/tests/clinical-dashboard-merge-artifacts.test.ts b/tests/clinical-dashboard-merge-artifacts.test.ts index 58af9ff8ce..98b1032f08 100644 --- a/tests/clinical-dashboard-merge-artifacts.test.ts +++ b/tests/clinical-dashboard-merge-artifacts.test.ts @@ -165,13 +165,15 @@ describe("ClinicalDashboard merge-artifact guards", () => { expect(documentViewerSource).toContain("max-sm:duration-[240ms]"); expect(documentViewerSource).toContain("max-sm:ease-[cubic-bezier(0.4,0,0.2,1)]"); expect(globalStylesSource).toContain("@media (max-width: 639px) and (prefers-reduced-motion: reduce)"); - expect(globalStylesSource).toContain('#main-content[data-bottom-composer-hidden="true"]'); + expect(globalStylesSource).toContain('#main-content[data-reserve-transitioning="true"]'); expect(globalStylesSource).toContain('[data-testid="mobile-composer-reserve-pad"]'); - // Mode/route reserve flips must snap; only scroll-hide animates padding. + // Mode/route reserve flips must snap; scroll hide/reveal animates via marker. expect(globalStylesSource).not.toMatch( /#main-content\s*\{\s*will-change: padding-bottom;\s*transition: padding-bottom 200ms/, ); expect(globalStylesSource).toContain("transition: padding-bottom 240ms var(--ease-out-soft)"); + expect(globalSearchShellSource).toContain("useReserveTransitionMarker"); + expect(clinicalDashboardSource).toContain("useReserveTransitionMarker"); expect(globalStylesSource).toContain("--phone-dock-differentials-compare-clearance: 12.5rem"); expect(globalStylesSource).toContain("var(--phone-dock-differentials-compare-clearance)"); // Child pages must not stack a second dock-sized safe-area pad under the diff --git a/tests/search-route-ownership.test.ts b/tests/search-route-ownership.test.ts index 6727f06b87..af28c18c8f 100644 --- a/tests/search-route-ownership.test.ts +++ b/tests/search-route-ownership.test.ts @@ -74,8 +74,11 @@ describe("shared-search route ownership", () => { expect(isDashboardModeHref("/")).toBe(true); expect(isDashboardModeHref("/?mode=answer")).toBe(true); expect(isDashboardModeHref("/?mode=documents&focus=1")).toBe(true); + expect(isDashboardModeHref("/documents/search")).toBe(true); + expect(isDashboardModeHref("/documents/search?q=lithium&run=1")).toBe(true); expect(isDashboardModeHref("/services")).toBe(false); expect(isDashboardModeHref("/differentials?q=mania&run=1")).toBe(false); + expect(isDashboardModeHref("/documents/searching")).toBe(false); }); it("keeps shell mode-home detection pathname-gated (no searchMode∧pathname AND)", () => { From 11cab0a83267a4acd5fcb4dc7084ecdc2639f4c1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 26 Jul 2026 03:45:17 +0000 Subject: [PATCH 17/25] fix: stop duplicate page-root testids from searchParams Suspense Nesting route children inside the shell's useSearchParams Suspense left a hidden Next streaming S: clone of forms/favourites/presentation roots under CI load. Gate always-standalone paths off that boundary and bridge params beside the shell body so mode-home RSC paint stays. Co-authored-by: BigSimmo --- docs/process-hardening.md | 6 + docs/search-chrome-behaviour.md | 2 +- .../global-search-shell.tsx | 103 +++++++++++++----- src/lib/search-route-ownership.ts | 24 ++++ tests/mode-home-loading-contract.test.ts | 23 ++++ tests/search-route-ownership.test.ts | 12 ++ 6 files changed, 144 insertions(+), 26 deletions(-) diff --git a/docs/process-hardening.md b/docs/process-hardening.md index 70f47468fd..983d3ea853 100644 --- a/docs/process-hardening.md +++ b/docs/process-hardening.md @@ -222,6 +222,12 @@ passes `p_worker_id`. Ordered apply steps, R17 manual `CONCURRENTLY` index, and - `GlobalMockupSearchShell` (aka `GlobalSearchShell`, used by the `forms`/`services`/`favourites`/`medications` layouts) wrapped `GlobalMockupSearchShellClient` in a `` whose **fallback also rendered `props.children` inside `#main-content`** — the same subtree the client body renders. Because `useSearchParams()` forces that boundary to the fallback on the server, the page subtree was emitted twice and both copies could persist, producing duplicate `id="main-content"` and duplicate `data-testid` on every shell page. It surfaced as `ui-smoke.spec.ts:1103` failing with a strict-mode violation (two `data-testid="acamprosate-medication-page"` `
` elements on `/medications/acamprosate`). - Fix: the Suspense fallback renders a **neutral placeholder only** — never `props.children`. Rule: do not render the resolved content inside its own Suspense fallback; the fallback is a loading state, not a second copy of the page. +## useSearchParams Suspense must not wrap route children (2026-07-26) + +- Removing `ClientHydrationBoundary` around standalone shell `{children}` restored mode-home RSC paint, but left route segments nested inside the shell’s `useSearchParams()` Suspense. Next streamed the page root into a hidden completion template (`