diff --git a/docs/process-hardening.md b/docs/process-hardening.md index d41ba98b2..1ceafa386 100644 --- a/docs/process-hardening.md +++ b/docs/process-hardening.md @@ -87,6 +87,6 @@ This document turns the current process review into phased, durable repo practic - Making the dashboard and document viewer client-only via `dynamic(..., { ssr: false })` (PRs #144/#147) meant "page loaded" no longer implies "app mounted". Firefox/WebKit paint the client chunk later than Chromium, so three release-browser-matrix specs raced and failed on `main` while Chromium stayed green. All three were test-timing gaps, not product regressions — the app renders correctly in every browser. - `tests/ui-overlap.spec.ts`: `gotoHome` waited on `networkidle` (Playwright discourages it) and then measured the header, which had not mounted yet — every failure was `header#search not found` (count 0), never a real overlap. Now waits for `header#search` to be visible before measuring. - - `tests/ui-tools.spec.ts` (forms detail → shared search): the shell re-syncs its query from the URL on mount via `requestAnimationFrame`, which on Firefox/WebKit can land just after a programmatic `fill` and wipe the value (button stays disabled) or drop the submit before the router navigates. The fill-and-submit now runs as one `toPass` unit that retries until the search routes. + - `tests/ui-tools.spec.ts` (forms detail → shared search): the shell re-syncs its query from the URL on mount via `requestAnimationFrame`, which on Firefox/WebKit can land just after a programmatic `fill` and wipe the value (button stays disabled) or drop the submit before the router navigates. The fill-and-submit now runs as one `toPass` unit that retries until the search routes. Root-cause fix: the `requestAnimationFrame` effect in `GlobalMockupSearchShellClient` now skips the `setQuery("")` reset on detail pages (where the URL carries no `q`/`query` param), so the programmatic fill is never wiped. - `tests/ui-stress.spec.ts` (desktop evidence panel): the evidence `
` is opened by focusing its `` and pressing Enter; in CI WebKit the key event could fire before focus landed, so it never toggled. Now asserts `toBeFocused()` before pressing Enter. - Rule of thumb for these client-only surfaces: never gate an interaction on `networkidle` or a bare `goto`. Wait for the specific mounted element, and wrap fill→submit→navigate races in `toPass` (the same idiom `openAppModeMenu`/`openDailyActions` already use). The `verify` + `ui-smoke` PR gates run Chromium only, so Firefox/WebKit-specific races surface solely in the gated `release-browser-matrix` (main/release/dispatch/schedule) — keep that job green rather than letting these re-accumulate. diff --git a/src/components/clinical-dashboard/global-mockup-search-shell.tsx b/src/components/clinical-dashboard/global-mockup-search-shell.tsx index 20058df26..38906b3bc 100644 --- a/src/components/clinical-dashboard/global-mockup-search-shell.tsx +++ b/src/components/clinical-dashboard/global-mockup-search-shell.tsx @@ -84,10 +84,12 @@ function GlobalMockupSearchShellClient({ const initialSearchMode = availableModeIds?.length && !availableModeIds.includes(initialMode) ? fallbackMode : initialMode; const requestedRun = searchParams.get("run") === "1"; + const currentUrlHasQuery = searchParams.has("q") || searchParams.has("query"); const requestedQuery = (searchParams.get("q") ?? searchParams.get("query") ?? "").trim(); const requestedMode = searchParams.get("mode"); const searchParamString = searchParams.toString(); const [query, setQuery] = useState(requestedQuery); + const previousUrlHadQueryRef = useRef(currentUrlHasQuery); const [searchMode, setSearchMode] = useState(initialSearchMode); const [queryMode, setQueryMode] = useState("auto"); const [scopeFilters, setScopeFilters] = useState({}); @@ -114,6 +116,10 @@ function GlobalMockupSearchShellClient({ (searchMode === "favourites" && pathname === "/favourites") || (searchMode === "differentials" && pathname === "/differentials")); const isDifferentialPresentationWorkflow = pathname.startsWith("/differentials/presentations"); + // True when on a sub-route of a mode home (e.g. /forms/transport-crisis-form, + // /services/13yarn) rather than the mode home itself (/forms, /services). + const isDetailPage = + /^\/(forms|services|favourites)\/.+/.test(pathname) || /^\/differentials\/diagnoses\/.+/.test(pathname); useEffect(() => { const frame = window.requestAnimationFrame(() => { @@ -127,13 +133,24 @@ function GlobalMockupSearchShellClient({ : initialSearchMode; setSearchMode(nextMode); - const requestedQuery = (params.get("q") ?? params.get("query"))?.trim(); - setQuery(requestedQuery ?? ""); + const urlHasQuery = params.has("q") || params.has("query"); + const hadQueryBeforeThisSync = previousUrlHadQueryRef.current; + previousUrlHadQueryRef.current = urlHasQuery; + if (urlHasQuery) { + // Sync the controlled query state from the URL query param. + const requestedQuery = (params.get("q") ?? params.get("query"))?.trim(); + setQuery(requestedQuery ?? ""); + } else if (!isDetailPage || hadQueryBeforeThisSync) { + // On no-query routes, clear any stale URL-derived query. Initial detail + // page mounts still skip the deferred clear so programmatic fills are + // not wiped by the WebKit requestAnimationFrame race. + setQuery(""); + } if (params.get("focus") === "1") inputRef.current?.focus({ preventScroll: true }); }); return () => window.cancelAnimationFrame(frame); - }, [availableModeIds, initialSearchMode, pathname, searchParamString]); + }, [availableModeIds, initialSearchMode, isDetailPage, pathname, searchParamString]); useEffect(() => { let cancelled = false;