From 74464184c566b4f6071bc2c4a11d46a46dd0fb85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 00:15:24 +0100 Subject: [PATCH] fix(labs): keep what is typed in the search field when the URL catches up The labs search writes its trimmed query to the URL after a short pause, and every change of the URL query replaced the field's text. The field's own write came back as well: a trailing space vanished, so "vitamin " followed by "d" searched for "vitamind", and a letter typed while the write landed was lost. A query the field wrote itself now leaves the field alone; one from back/forward, a link or Reset still replaces it. --- .../lab-list-url-sync.browser.test.tsx | 41 +++++++++++++++++++ src/components/labs/use-lab-list-filters.ts | 21 +++++++--- 2 files changed, 56 insertions(+), 6 deletions(-) diff --git a/src/components/labs/__tests__/lab-list-url-sync.browser.test.tsx b/src/components/labs/__tests__/lab-list-url-sync.browser.test.tsx index 028a4648a..160b8e4e5 100644 --- a/src/components/labs/__tests__/lab-list-url-sync.browser.test.tsx +++ b/src/components/labs/__tests__/lab-list-url-sync.browser.test.tsx @@ -289,6 +289,47 @@ describe("LabList stale category and group header handling", () => { expect(readStoredOpen("collapsed")).toEqual({}); }); + it("keeps a trailing space in the search field after the URL takes the query", async () => { + setSearch(""); + const queryClient = labQueryClient(); + queryClient.setQueryData( + queryKeys.labResultsOverview("en"), + overviewWithAbnormalMarker, + ); + + const { screen } = await renderWithApp(, { queryClient }); + + const search = screen.getByRole("searchbox", { name: "Search biomarkers" }); + await search.fill("iron "); + await expect.poll(currentSearch).toBe("?q=iron"); + // One more frame for the URL change to reach the hook. + await new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(resolve)), + ); + await expect.element(search).toHaveValue("iron "); + }); + + it("takes the query from the URL on back navigation", async () => { + setSearch("?q=iron"); + const queryClient = labQueryClient(); + queryClient.setQueryData( + queryKeys.labResultsOverview("en"), + overviewWithAbnormalMarker, + ); + + const { screen } = await renderWithApp(, { queryClient }); + const search = screen.getByRole("searchbox", { name: "Search biomarkers" }); + await expect.element(search).toHaveValue("iron"); + + await screen.getByRole("button", { name: "Reset" }).click(); + await expect.poll(currentSearch).toBe(""); + await expect.element(search).toHaveValue(""); + + window.history.back(); + await expect.poll(currentSearch).toBe("?q=iron"); + await expect.element(search).toHaveValue("iron"); + }); + it("memoizes setCategoryFilter callback identity across re-renders", async () => { const callbacks: unknown[] = []; const { screen } = await renderWithApp( diff --git a/src/components/labs/use-lab-list-filters.ts b/src/components/labs/use-lab-list-filters.ts index 5140488fe..4de016c89 100644 --- a/src/components/labs/use-lab-list-filters.ts +++ b/src/components/labs/use-lab-list-filters.ts @@ -19,20 +19,29 @@ export function useLabListFilters() { serialise: labListFiltersToSearch, }); - // Debounced search draft - const [searchDraft, setSearchDraft] = useState(filters.q ?? ""); - const [lastUrlQ, setLastUrlQ] = useState(filters.q ?? ""); + // The field edits a draft; the URL takes it, trimmed, 200 ms after the + // last keystroke. A `q` the URL gains from elsewhere (back/forward, a + // shared link, Reset) replaces the draft. One the draft wrote itself + // does not: adopting it would drop a trailing space ("vitamin " became + // "vitamin", and the next letter ran on), or a letter typed while the + // write was landing. + const urlQ = filters.q ?? ""; + const [searchDraft, setSearchDraft] = useState(urlQ); + const [lastUrlQ, setLastUrlQ] = useState(urlQ); + const [ownWrite, setOwnWrite] = useState(null); const searchInputRef = useRef(null); - if ((filters.q ?? "") !== lastUrlQ) { - setLastUrlQ(filters.q ?? ""); - setSearchDraft(filters.q ?? ""); + if (urlQ !== lastUrlQ) { + setLastUrlQ(urlQ); + if (urlQ === ownWrite) setOwnWrite(null); + else if (urlQ !== searchDraft.trim()) setSearchDraft(urlQ); } useEffect(() => { const handle = setTimeout(() => { const trimmed = searchDraft.trim(); if (trimmed === (filters.q ?? "")) return; + setOwnWrite(trimmed); applyFilters( (current) => trimmed === (current.q ?? "")