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 ?? "")