From a771d01a92089ade6eebe707071d0773cf67e0de Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Tue, 4 Aug 2026 19:01:51 +0800 Subject: [PATCH 1/5] fix(ui): stabilize and accelerate mode navigation --- .../global-search-shell.tsx | 50 +++++++++++++++++-- .../master-search-header.tsx | 14 +++++- tests/mode-menu-prefetch.dom.test.tsx | 27 ++++++++-- 3 files changed, 83 insertions(+), 8 deletions(-) diff --git a/src/components/clinical-dashboard/global-search-shell.tsx b/src/components/clinical-dashboard/global-search-shell.tsx index 4c03417f34..7123e3b31b 100644 --- a/src/components/clinical-dashboard/global-search-shell.tsx +++ b/src/components/clinical-dashboard/global-search-shell.tsx @@ -56,6 +56,7 @@ import { import { useSettingsGuideFlow } from "@/components/clinical-dashboard/use-settings-guide-flow"; import { cn } from "@/components/ui-primitives"; import { + appModeDefinition, appModeHomeHref, isAppModeId, isAppModeVisible, @@ -117,6 +118,11 @@ type GlobalSearchShellProps = { fallback?: ReactNode; }; +type PendingModeNavigation = { + mode: AppModeId; + pathname: string; +}; + export function GlobalSearchShell(props: GlobalSearchShellProps) { const pathname = usePathname() ?? "/"; @@ -387,6 +393,7 @@ function GlobalStandaloneSearchShellBody({ const [syncedSearchParamString, setSyncedSearchParamString] = useState(searchParamString); const [syncedPathname, setSyncedPathname] = useState(pathname); const [searchMode, setSearchMode] = useState(resolvedSearchMode); + const [pendingModeNavigation, setPendingModeNavigation] = useState(null); const [queryMode, setQueryMode] = useState( () => readSearchNavigationContext(searchParams).queryMode, ); @@ -475,6 +482,29 @@ function GlobalStandaloneSearchShellBody({ setScopeFilters(nextSearchContext.scopeFilters); } + // Imperative mode-menu navigation does not have Link's immediate pending UI: + // Next keeps the previous RSC page visible while it waits for the destination + // payload. Replace that stale page with the neutral route skeleton as soon as + // a mode is chosen, then release it only when both the destination path and + // URL-derived mode have landed. Checking both matters for `/` modes such as + // Answer, Documents, and Medication, whose navigation changes only `?mode=`. + if ( + pendingModeNavigation && + pathname === pendingModeNavigation.pathname && + resolvedSearchMode === pendingModeNavigation.mode + ) { + setPendingModeNavigation(null); + } + + useEffect(() => { + if (!pendingModeNavigation) return undefined; + // A failed/blocked client navigation must not strand the application behind + // a permanent loading surface. Normal prefetched mode switches clear this as + // soon as the URL lands; this is only a conservative recovery path. + const timeout = window.setTimeout(() => setPendingModeNavigation(null), 10_000); + return () => window.clearTimeout(timeout); + }, [pendingModeNavigation]); + useEffect(() => { // Submitted result views must not keep the dock focused. Composer focus // pins both chrome edges (keyboard safety), which is what left Forms / @@ -616,11 +646,14 @@ function GlobalStandaloneSearchShellBody({ openAccountSetup("favourites"); return; } + if (mode === searchMode) return; setQuery(""); setMobileMenuOpen(false); // Let the URL sync (render-time) own searchMode. Optimistic setSearchMode // before pathname updates was the namespaced mode-switch reserve flip. - navigateToMode(mode); + const href = appModeHomeHref(mode, { queryMode, scopeFilters }); + setPendingModeNavigation({ mode, pathname: new URL(href, window.location.origin).pathname }); + router.push(href); } function startNewAnswerChat() { @@ -764,7 +797,7 @@ function GlobalStandaloneSearchShellBody({ documentTotal={0} query={query} searchMode={searchMode} - loading={false} + loading={pendingModeNavigation !== null} selectedDocumentIds={[]} queryMode={queryMode} scopeFilters={scopeFilters} @@ -930,7 +963,7 @@ function GlobalStandaloneSearchShellBody({ Rendered in normal flow (sticky={false}) so it never contends with the universal collapsing header or page-flow search chrome. */} - {searchMode !== "specifiers" && searchMode !== "formulation" ? ( + {!pendingModeNavigation && searchMode !== "specifiers" && searchMode !== "formulation" ? ( {children} + + {pendingModeNavigation ? ( +
+ Loading {appModeDefinition(pendingModeNavigation.mode).label} + +
+ ) : ( + children + )} +
diff --git a/src/components/clinical-dashboard/master-search-header.tsx b/src/components/clinical-dashboard/master-search-header.tsx index 72a6426044..1543a5478f 100644 --- a/src/components/clinical-dashboard/master-search-header.tsx +++ b/src/components/clinical-dashboard/master-search-header.tsx @@ -863,7 +863,19 @@ export function MasterSearchHeader({ const href = appModeHomeHref(modeId); if (prefetchedModeHrefsRef.current.has(href)) return; prefetchedModeHrefsRef.current.add(href); - router.prefetch(href); + router.prefetch(href, { + // Next's client cache can invalidate a prefetched RSC payload while this + // long-lived shared header remains mounted. Let the next pointer/focus + // intent warm it again instead of permanently treating the stale entry as + // prefetched for the rest of the session. + onInvalidate: () => { + prefetchedModeHrefsRef.current.delete(href); + }, + // Next 16.2.12's public guide documents onInvalidate as the only optional + // field, while its bundled AppRouterInstance type incorrectly exposes the + // internal required `kind`. Keep the public API shape without importing a + // private router enum. + } as Parameters[1]); } function openModeMenuWithFocus(index: number) { diff --git a/tests/mode-menu-prefetch.dom.test.tsx b/tests/mode-menu-prefetch.dom.test.tsx index 15835ed887..68d045c381 100644 --- a/tests/mode-menu-prefetch.dom.test.tsx +++ b/tests/mode-menu-prefetch.dom.test.tsx @@ -91,14 +91,35 @@ describe("mode menu home prefetch", () => { const menu = await screen.findByRole("menu", { name: "Choose app mode" }); // Opening on the current mode is a no-op; scanning another option warms it. - expect(router.prefetch).not.toHaveBeenCalledWith(documentsHref); + expect(router.prefetch.mock.calls.some(([href]) => href === documentsHref)).toBe(false); await user.hover(within(menu).getByRole("menuitemradio", { name: /Documents/i })); - expect(router.prefetch).toHaveBeenCalledWith(documentsHref); + expect(router.prefetch.mock.calls.some(([href]) => href === documentsHref)).toBe(true); const prefetched = new Set(router.prefetch.mock.calls.map(([href]) => href as string)); expect(prefetched.has(documentsHref)).toBe(true); expect(prefetched.size).toBeLessThan(guestModeHomes().length); }); + it("warms a mode again after Next invalidates its cached payload", async () => { + const user = userEvent.setup(); + const documentsHref = appModeHomeHref("documents"); + + render(); + await user.click(screen.getByRole("button", { name: /Mode Answer/i })); + const documentsOption = within(await screen.findByRole("menu", { name: "Choose app mode" })).getByRole( + "menuitemradio", + { name: /Documents/i }, + ); + await user.hover(documentsOption); + + const [, options] = router.prefetch.mock.calls.find(([href]) => href === documentsHref) ?? []; + expect(options?.onInvalidate).toBeTypeOf("function"); + options.onInvalidate(); + await user.unhover(documentsOption); + await user.hover(documentsOption); + + expect(router.prefetch.mock.calls.filter(([href]) => href === documentsHref)).toHaveLength(2); + }); + it("prefetches the highlighted mode when openModeMenuWithFocus targets another home", async () => { const user = userEvent.setup(); const modes = guestModeHomes(); @@ -114,7 +135,7 @@ describe("mode menu home prefetch", () => { await user.keyboard("{ArrowUp}"); await screen.findByRole("menu", { name: "Choose app mode" }); - expect(router.prefetch).toHaveBeenCalledWith(previousHref); + expect(router.prefetch.mock.calls.some(([href]) => href === previousHref)).toBe(true); expect(new Set(router.prefetch.mock.calls.map(([href]) => href)).size).toBe(1); }); }); From e2c9b00375dc952d1afc11afce4663888e1e31f7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 4 Aug 2026 14:35:08 +0000 Subject: [PATCH 2/5] fix(ui): restore same-mode home nav and clear superseded pending The mode-equality early return blocked ModeActionPopup and active-mode menu picks from returning to a clean mode home. Skip only true no-ops, clear pending mode navigation when any other URL commits, and update the source contracts CI was failing on. Co-authored-by: BigSimmo --- .../global-search-shell.tsx | 64 ++++++++--- .../audit-navigation-auth-regressions.test.ts | 3 +- tests/mode-home-loading-contract.test.ts | 8 +- tests/search-route-ownership.test.ts | 12 +- tests/shared-search-shell-url-sync.test.ts | 106 ++++++++++++++++++ 5 files changed, 175 insertions(+), 18 deletions(-) diff --git a/src/components/clinical-dashboard/global-search-shell.tsx b/src/components/clinical-dashboard/global-search-shell.tsx index 7123e3b31b..242bfbe17f 100644 --- a/src/components/clinical-dashboard/global-search-shell.tsx +++ b/src/components/clinical-dashboard/global-search-shell.tsx @@ -121,6 +121,11 @@ type GlobalSearchShellProps = { type PendingModeNavigation = { mode: AppModeId; pathname: string; + /** Destination search string (no leading `?`) so same-pathname homes wait for query clear. */ + searchParamString: string; + /** URL at the moment the mode push was issued — used to detect superseding navigations. */ + sourcePathname: string; + sourceSearchParamString: string; }; export function GlobalSearchShell(props: GlobalSearchShellProps) { @@ -485,15 +490,23 @@ function GlobalStandaloneSearchShellBody({ // Imperative mode-menu navigation does not have Link's immediate pending UI: // Next keeps the previous RSC page visible while it waits for the destination // payload. Replace that stale page with the neutral route skeleton as soon as - // a mode is chosen, then release it only when both the destination path and - // URL-derived mode have landed. Checking both matters for `/` modes such as - // Answer, Documents, and Medication, whose navigation changes only `?mode=`. - if ( - pendingModeNavigation && - pathname === pendingModeNavigation.pathname && - resolvedSearchMode === pendingModeNavigation.mode - ) { - setPendingModeNavigation(null); + // a mode is chosen, then release it when the destination lands — or when any + // other committed URL change supersedes the in-flight mode push (Back, New + // chat, sidebar link, a second mode pick). Destination checks include the + // query string so same-pathname returns (e.g. `/services?q=&run=1` → `/services`) + // keep the skeleton until the home URL actually commits; mode is still checked + // for `/` modes such as Answer, Documents, and Medication. + if (pendingModeNavigation) { + const reachedDestination = + pathname === pendingModeNavigation.pathname && + resolvedSearchMode === pendingModeNavigation.mode && + searchParamString === pendingModeNavigation.searchParamString; + const supersededWhilePending = + pathname !== pendingModeNavigation.sourcePathname || + searchParamString !== pendingModeNavigation.sourceSearchParamString; + if (reachedDestination || supersededWhilePending) { + setPendingModeNavigation(null); + } } useEffect(() => { @@ -646,13 +659,38 @@ function GlobalStandaloneSearchShellBody({ openAccountSetup("favourites"); return; } - if (mode === searchMode) return; - setQuery(""); + // Same-mode picks are load-bearing: the checked mode-menu option and every + // ModeActionPopup quick action route through changeMode to leave a detail / + // submitted URL and land on the clean mode home. Skip only a true no-op + // (already exactly on that home) when nothing else is in flight. + const href = appModeHomeHref(mode, { queryMode, scopeFilters }); + const destination = new URL(href, window.location.origin); + const destinationSearch = destination.search.startsWith("?") ? destination.search.slice(1) : destination.search; + const alreadyOnDestination = pathname === destination.pathname && searchParamString === destinationSearch; + setMobileMenuOpen(false); + + if (alreadyOnDestination) { + // Re-selecting the current mode while a different mode push is in flight + // must cancel the pending skeleton and re-affirm the current home so the + // in-flight navigation does not leave the user on the wrong page. + if (pendingModeNavigation && pendingModeNavigation.mode !== mode) { + setPendingModeNavigation(null); + router.push(href); + } + return; + } + + setQuery(""); // Let the URL sync (render-time) own searchMode. Optimistic setSearchMode // before pathname updates was the namespaced mode-switch reserve flip. - const href = appModeHomeHref(mode, { queryMode, scopeFilters }); - setPendingModeNavigation({ mode, pathname: new URL(href, window.location.origin).pathname }); + setPendingModeNavigation({ + mode, + pathname: destination.pathname, + searchParamString: destinationSearch, + sourcePathname: pathname, + sourceSearchParamString: searchParamString, + }); router.push(href); } diff --git a/tests/audit-navigation-auth-regressions.test.ts b/tests/audit-navigation-auth-regressions.test.ts index bfc15b223c..91d5b1a008 100644 --- a/tests/audit-navigation-auth-regressions.test.ts +++ b/tests/audit-navigation-auth-regressions.test.ts @@ -134,7 +134,8 @@ describe("audit navigation and auth regressions", () => { ); expect(masterSearchHeaderSource).toContain("function prefetchModeHome(modeId: AppModeId)"); - expect(masterSearchHeaderSource).toContain("router.prefetch(href)"); + expect(masterSearchHeaderSource).toContain("router.prefetch(href,"); + expect(masterSearchHeaderSource).toContain("onInvalidate:"); expect(modeOptions).toContain("onFocus={() => prefetchModeHome(mode.id)}"); expect(modeOptions).toContain("onPointerEnter={() => prefetchModeHome(mode.id)}"); // Menu-open paths warm only the highlighted option — never every visible home. diff --git a/tests/mode-home-loading-contract.test.ts b/tests/mode-home-loading-contract.test.ts index 24198ff2fd..44abe02ac1 100644 --- a/tests/mode-home-loading-contract.test.ts +++ b/tests/mode-home-loading-contract.test.ts @@ -35,7 +35,11 @@ describe("mode-home loading contract", () => { ); expect(shellSource).not.toMatch(/import\s*\{[^}]*ClientHydrationBoundary/); expect(shellSource).not.toMatch(/{children}"); + // Children stay under SearchCommandProvider; pending mode navigation may + // temporarily swap in ModeHomeRouteLoading instead of blanking the provider. + expect(shellSource).toMatch( + /[\s\S]*?\{pendingModeNavigation \? \([\s\S]*?[\s\S]*?\) : \(\s*children\s*\)\}/, + ); }); it("keeps route children outside useSearchParams Suspense on standalone shells", () => { @@ -56,7 +60,7 @@ describe("mode-home loading contract", () => { /function GlobalStandaloneSearchShellClient[\s\S]*?[\s\S]*?ShellSearchParamsBridge/, ); expect(shellSource).toMatch( - /function GlobalStandaloneSearchShellBody[\s\S]*?SearchCommandProvider value=\{searchCommandContextValue\}>\{children\}/, + /function GlobalStandaloneSearchShellBody[\s\S]*?[\s\S]*?\{pendingModeNavigation \? \([\s\S]*?[\s\S]*?\) : \(\s*children\s*\)\}/, ); expect(shellSource).not.toMatch(/function GlobalStandaloneSearchShellBody[\s\S]*?useSearchParams\(\)/); // Secondary nav is mounted inside the standalone body; it must consume the diff --git a/tests/search-route-ownership.test.ts b/tests/search-route-ownership.test.ts index f249bae280..9104694e13 100644 --- a/tests/search-route-ownership.test.ts +++ b/tests/search-route-ownership.test.ts @@ -101,9 +101,17 @@ describe("shared-search route ownership", () => { 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 \}/); + // Same-mode picks still navigate to the mode home unless already there; + // cross-mode / leaving a detail URL uses router.push with pending state. + expect(shellSource).toMatch(/function changeMode\(mode: AppModeId\) \{[\s\S]*?router\.push\(href\);\n \}/); expect(shellSource).not.toMatch( - /function changeMode\(mode: AppModeId\) \{[\s\S]*?setSearchMode\(mode\);[\s\S]*?navigateToMode\(mode\);/, + /function changeMode\(mode: AppModeId\) \{[\s\S]*?setSearchMode\(mode\);[\s\S]*?router\.push/, + ); + expect(shellSource).toContain("alreadyOnDestination"); + expect(shellSource).toContain("setPendingModeNavigation"); + // Blanket mode-equality early return would break same-mode home returns. + expect(shellSource).not.toMatch( + /function changeMode\(mode: AppModeId\) \{[\s\S]*?if \(mode === searchMode\) return;/, ); }); diff --git a/tests/shared-search-shell-url-sync.test.ts b/tests/shared-search-shell-url-sync.test.ts index e79a9927eb..5bd278410d 100644 --- a/tests/shared-search-shell-url-sync.test.ts +++ b/tests/shared-search-shell-url-sync.test.ts @@ -3,6 +3,9 @@ * switches (empty query string) must still update searchMode. Sync runs during * render (not in an effect) so the composer does not paint one stale-mode frame. * This unit covers the sync predicate so a params-only check cannot return unnoticed. + * + * Also locks the pending-mode-navigation release predicate: destination match + * (including query string) or any superseding URL change — never destination-only. */ import { describe, expect, it } from "vitest"; @@ -51,3 +54,106 @@ describe("shared search-shell URL sync predicate", () => { ).toBe(true); }); }); + +describe("pending mode-navigation release predicate", () => { + type Pending = { + mode: string; + pathname: string; + searchParamString: string; + sourcePathname: string; + sourceSearchParamString: string; + }; + + function shouldClearPending(args: { + pending: Pending; + pathname: string; + resolvedSearchMode: string; + searchParamString: string; + }) { + const { pending, pathname, resolvedSearchMode, searchParamString } = args; + const reachedDestination = + pathname === pending.pathname && + resolvedSearchMode === pending.mode && + searchParamString === pending.searchParamString; + const supersededWhilePending = + pathname !== pending.sourcePathname || searchParamString !== pending.sourceSearchParamString; + return reachedDestination || supersededWhilePending; + } + + it("keeps pending while still on the source URL mid-navigation", () => { + expect( + shouldClearPending({ + pending: { + mode: "forms", + pathname: "/forms", + searchParamString: "", + sourcePathname: "/services", + sourceSearchParamString: "", + }, + pathname: "/services", + resolvedSearchMode: "services", + searchParamString: "", + }), + ).toBe(false); + }); + + it("clears pending when the intended destination lands", () => { + expect( + shouldClearPending({ + pending: { + mode: "forms", + pathname: "/forms", + searchParamString: "", + sourcePathname: "/services", + sourceSearchParamString: "", + }, + pathname: "/forms", + resolvedSearchMode: "forms", + searchParamString: "", + }), + ).toBe(true); + }); + + it("clears pending when a different navigation supersedes the mode push", () => { + expect( + shouldClearPending({ + pending: { + mode: "forms", + pathname: "/forms", + searchParamString: "", + sourcePathname: "/services", + sourceSearchParamString: "", + }, + pathname: "/", + resolvedSearchMode: "answer", + searchParamString: "mode=answer&focus=1", + }), + ).toBe(true); + }); + + it("keeps pending on same-pathname submitted→home until the query string clears", () => { + const pending = { + mode: "services", + pathname: "/services", + searchParamString: "", + sourcePathname: "/services", + sourceSearchParamString: "q=13YARN&run=1", + }; + expect( + shouldClearPending({ + pending, + pathname: "/services", + resolvedSearchMode: "services", + searchParamString: "q=13YARN&run=1", + }), + ).toBe(false); + expect( + shouldClearPending({ + pending, + pathname: "/services", + resolvedSearchMode: "services", + searchParamString: "", + }), + ).toBe(true); + }); +}); From 633794e98000f74fa4826976aa78f051dd01a2f7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 4 Aug 2026 17:04:09 +0000 Subject: [PATCH 3/5] chore(ledger): record PR #1607 unblock snapshot at 3e3b224a Local-only ledger append for unblock/fix scope; not pushed alone. 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 676e40e033..c4c89484cc 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -623,3 +623,4 @@ Records before 2026-07-28 were written by hand and had drifted: 146 lines carrie | 2026-08-04 | claude/top-search-design-mockups-w53znc | b432448e4893a42d07558aff0dc04be797971231 | PR #1611 — results-band shelf Clear filter-only, memo deps, restored tests | Fixed two Qodo findings from merged #1555; mutation-tested guard added | tsc 0; eslint 0; vitest 4 files/59 tests; verify:pr-local blocked by lock parity (node 24.13 vs jsdom@30) | | 2026-08-04 | claude/search-bar-decisions-doc | a7dea7f777255ade72878820a636413aaf9588af | search-bar handoff doc replacement + review fixes | Docs-only review fixes: mode/shelf accounting, Sort consumers, #230/#170 precision; removed unquoted-output claim from prior row | prettier --check . ; check:outstanding-issues ; docs:check-links ; docs:check-index | | 2026-08-04 | claude/search-bar-decisions-doc | 3b4cd6e6bf1f36fb8aff098ce7d333641e0859d3 | search-bar handoff doc replacement + review fixes | Fixed CodeRabbit/Codex findings; Bugbot hosted stuck queued, local Bugbot-equivalent confirmed two P2 doc errors and rejected sheets-are-target finding. verify:pr-local PASS (docs scope). Decisive: prettier All matched files use Prettier code style!; outstanding-issues 228 rows next-id=231; docs link check passed: 1615; docs/codebase-index coverage OK | verify:pr-local (docs); prettier --check; check:outstanding-issues; docs:check-links; docs:check-index; check:branch-review-ledger | +| 2026-08-04 | codex/fix-mode-switching-and-loading-issues | 3e3b224a2ec13928d1e28173b1fc4c75d202d7d2 | PR #1607 unblock/fix | clean — behind 0, merge-tree clean, 0 unresolved threads, required CI in progress (no code fix) | merge-tree clean; behind_by 0; Unit/Build/Static/ProdUI in progress; no failing required | From 04f6cdc878eb5bc88621f0b1026acba4f9906240 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 4 Aug 2026 17:35:54 +0000 Subject: [PATCH 4/5] fix(ui): keep deep-linked source chunk disclosures open Production UI flake: nested citation details relied on imperative .open and could stay collapsed across re-renders. Control the auto-open target via React open state and cover it with a DOM test. Co-authored-by: BigSimmo --- .../document-viewer/source-panels.tsx | 29 +++++++++----- tests/document-section-summary.dom.test.tsx | 38 ++++++++++++++++++- 2 files changed, 56 insertions(+), 11 deletions(-) diff --git a/src/components/document-viewer/source-panels.tsx b/src/components/document-viewer/source-panels.tsx index 47144e9582..1ead7999e7 100644 --- a/src/components/document-viewer/source-panels.tsx +++ b/src/components/document-viewer/source-panels.tsx @@ -797,7 +797,10 @@ export const IndexedTextPanel = memo(function IndexedTextPanel({ .map((chunk) => chunk.id) .join(",")}`; const previousAutoOpenDriverRef = useRef(null); - const manualClosedDriverRef = useRef(null); + // Track manual close in state (not only a ref) so selected/active chunk + // disclosures can stay React-controlled — imperative `.open = true` alone was + // lost across re-renders and left deep-linked hits collapsed in Production UI. + const [manualClosedDriver, setManualClosedDriver] = useState(null); const [compactOpen, setCompactOpen] = useState(Boolean(selectedChunkId)); // Deep-linked chunks and in-document search must keep the panel revealed even // when the exclusive accordion briefly closes it (section jumps / sibling @@ -808,20 +811,21 @@ export const IndexedTextPanel = memo(function IndexedTextPanel({ setPrevForceReveal(forceReveal); if (forceReveal) setCompactOpen(true); } + if (previousAutoOpenDriverRef.current !== autoOpenDriver) { + previousAutoOpenDriverRef.current = autoOpenDriver; + if (manualClosedDriver !== null) setManualClosedDriver(null); + } + const autoOpenSuppressed = Boolean(autoOpenDriver) && manualClosedDriver === autoOpenDriver; useEffect(() => { - if (previousAutoOpenDriverRef.current !== autoOpenDriver) { - previousAutoOpenDriverRef.current = autoOpenDriver; - manualClosedDriverRef.current = null; - } - if (!autoOpenDriver || !autoOpenTargetId || manualClosedDriverRef.current === autoOpenDriver) return; + if (!autoOpenDriver || !autoOpenTargetId || autoOpenSuppressed) return; const targetDisclosure = document.getElementById(`${idPrefix}-${autoOpenTargetId}`); if (!(targetDisclosure instanceof HTMLDetailsElement)) return; if (topLevelDisclosureRef.current) topLevelDisclosureRef.current.open = true; const wasOpen = targetDisclosure.open; openNestedSourceDisclosure(topLevelDisclosureRef.current, targetDisclosure); if (!wasOpen) targetDisclosure.scrollIntoView({ block: "nearest", behavior: resolveScrollBehavior() }); - }, [autoOpenDriver, autoOpenTargetId, idPrefix, targetAvailability]); + }, [autoOpenDriver, autoOpenTargetId, autoOpenSuppressed, idPrefix, targetAvailability]); function moveHit(delta: number) { if (visibleChunks.length === 0) return; @@ -835,14 +839,14 @@ export const IndexedTextPanel = memo(function IndexedTextPanel({ const isDriverDisclosure = disclosure.id === `${idPrefix}-${autoOpenTargetId}`; if (disclosure.open) { disclosure.open = false; - if (isDriverDisclosure && autoOpenDriver) manualClosedDriverRef.current = autoOpenDriver; + if (isDriverDisclosure && autoOpenDriver) setManualClosedDriver(autoOpenDriver); return; } - if (isDriverDisclosure && autoOpenDriver) manualClosedDriverRef.current = null; + if (isDriverDisclosure && autoOpenDriver) setManualClosedDriver(null); if (!isDriverDisclosure && autoOpenDriver && autoOpenTargetId) { const driverDisclosure = document.getElementById(`${idPrefix}-${autoOpenTargetId}`); if (driverDisclosure instanceof HTMLDetailsElement && driverDisclosure.open) { - manualClosedDriverRef.current = autoOpenDriver; + setManualClosedDriver(autoOpenDriver); } } openNestedSourceDisclosure(topLevelDisclosureRef.current, disclosure); @@ -998,6 +1002,10 @@ export const IndexedTextPanel = memo(function IndexedTextPanel({ visibleChunks.map((chunk) => { const selected = selectedChunkId === chunk.id; const active = activeHit?.id === chunk.id; + const isAutoOpenTarget = Boolean(autoOpenTargetId) && chunk.id === autoOpenTargetId; + // Keep deep-linked / active-hit passages React-controlled so a + // later render cannot collapse the citation the URL asked for. + const forceChunkOpen = isAutoOpenTarget && !autoOpenSuppressed; const status = selected ? "Highlighted quoted passage" : active @@ -1012,6 +1020,7 @@ export const IndexedTextPanel = memo(function IndexedTextPanel({ data-testid={selected ? "highlighted-indexed-source-chunk" : "indexed-source-passage-disclosure"} data-source-chunk-id={chunk.id} data-source-active-hit={active || undefined} + open={forceChunkOpen ? true : undefined} className={cn( sourceCard, "group/source-row overflow-hidden p-0 transition source-print", diff --git a/tests/document-section-summary.dom.test.tsx b/tests/document-section-summary.dom.test.tsx index 19bf217b41..17e746209c 100644 --- a/tests/document-section-summary.dom.test.tsx +++ b/tests/document-section-summary.dom.test.tsx @@ -70,7 +70,9 @@ describe("IndexedTextPanel condensed reveal", () => { const panel = screen.getByTestId("source-chunk-indexed-text-panel") as HTMLDetailsElement; expect(panel.open).toBe(true); - expect(screen.getByTestId("highlighted-indexed-source-chunk")).toBeVisible(); + const highlighted = screen.getByTestId("highlighted-indexed-source-chunk") as HTMLDetailsElement; + expect(highlighted).toBeVisible(); + await waitFor(() => expect(highlighted.open).toBe(true)); expect(panel.querySelector("summary")).toHaveAttribute("aria-disabled", "true"); fireEvent.click(panel.querySelector("summary")!); @@ -82,6 +84,7 @@ describe("IndexedTextPanel condensed reveal", () => { }); await waitFor(() => expect(panel.open).toBe(true)); expect(screen.getByTestId("highlighted-indexed-source-chunk")).toBeVisible(); + expect(highlighted.open).toBe(true); }); it("keeps in-document search results revealed without a selected chunk", async () => { @@ -128,6 +131,39 @@ describe("IndexedTextPanel condensed reveal", () => { expect(screen.getByText("Hit 1 of 1")).toBeVisible(); }); + it("keeps the deep-linked nested chunk disclosure open under condensed view", async () => { + const props = { + loading: false, + selectedPage: basePage, + chunks: [ + baseChunk, + { + ...baseChunk, + id: "chunk-2", + chunk_index: 1, + content: "Lithium levels are checked 5 to 7 days after initiation", + }, + ], + search: "", + documentSearchResults: [] as [], + searchingDocument: false, + documentSearchError: null, + idPrefix: "source-chunk", + sectionId: "source-text" as const, + selectedChunkId: "chunk-1", + onSearchChange: vi.fn(), + compact: true, + }; + const { rerender } = render(); + + const highlighted = screen.getByTestId("highlighted-indexed-source-chunk") as HTMLDetailsElement; + await waitFor(() => expect(highlighted.open).toBe(true)); + + // A re-render must not collapse the React-controlled deep-link disclosure. + rerender(); + expect(highlighted.open).toBe(true); + }); + it("allows plain condensed panels to collapse and stay collapsed", async () => { render( Date: Wed, 5 Aug 2026 13:18:07 +0800 Subject: [PATCH 5/5] test: force memoized disclosure rerender --- tests/document-section-summary.dom.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/document-section-summary.dom.test.tsx b/tests/document-section-summary.dom.test.tsx index 17e746209c..02fe0e22ee 100644 --- a/tests/document-section-summary.dom.test.tsx +++ b/tests/document-section-summary.dom.test.tsx @@ -160,7 +160,7 @@ describe("IndexedTextPanel condensed reveal", () => { await waitFor(() => expect(highlighted.open).toBe(true)); // A re-render must not collapse the React-controlled deep-link disclosure. - rerender(); + rerender(); expect(highlighted.open).toBe(true); });