From 532692e0fb26e85db113554ae5a350179817f4b5 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 16 Apr 2026 17:24:30 +0200 Subject: [PATCH] fix(#221): bypass debounce on AND/OR operator toggle to prevent race condition The tag-change $effect called triggerSearch() immediately (no debounce). When the user toggled AND/OR within the 500 ms debounce window, the prior navigation would complete and reset tagOperator back to AND before the debounced search fired. The toggle now calls onSearchImmediate, which clears any pending timer and fires triggerSearch() synchronously. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/routes/+page.svelte | 6 +++++ frontend/src/routes/SearchFilterBar.svelte | 6 +++-- .../src/routes/SearchFilterBar.svelte.spec.ts | 23 +++++++++++++++++++ 3 files changed, 33 insertions(+), 2 deletions(-) diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index 801b2280..af786574 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -61,6 +61,11 @@ function handleTextSearch() { searchTimer = setTimeout(() => triggerSearch(), 500); } +function handleImmediateSearch() { + clearTimeout(searchTimer); + triggerSearch(); +} + // Trigger search when tags change let prevTagStr = untrack(() => tagNames.map((t) => t.name).join(',')); $effect(() => { @@ -114,6 +119,7 @@ const showRightColumn = $derived(data.canWrite || (data.incompleteDocs?.length ? initialReceiverName={data.initialValues?.receiverName} isLoading={navigating.to !== null} onSearch={handleTextSearch} + onSearchImmediate={handleImmediateSearch} onfocus={() => (qFocused = true)} onblur={() => (qFocused = false)} /> diff --git a/frontend/src/routes/SearchFilterBar.svelte b/frontend/src/routes/SearchFilterBar.svelte index 9786bec6..9c815bca 100644 --- a/frontend/src/routes/SearchFilterBar.svelte +++ b/frontend/src/routes/SearchFilterBar.svelte @@ -22,6 +22,7 @@ let { initialReceiverName = '', isLoading = false, onSearch, + onSearchImmediate, onfocus, onblur }: { @@ -40,6 +41,7 @@ let { initialReceiverName?: string; isLoading?: boolean; onSearch: () => void; + onSearchImmediate?: () => void; onfocus?: () => void; onblur?: () => void; } = $props(); @@ -162,7 +164,7 @@ $effect(() => { class="rounded px-2 py-0.5 text-xs font-bold tracking-widest uppercase transition-colors {tagOperator === 'AND' ? 'bg-primary text-primary-fg' : 'bg-muted text-ink-2 hover:bg-line'}" onclick={() => { tagOperator = 'AND'; - onSearch(); + (onSearchImmediate ?? onSearch)(); }}>AND diff --git a/frontend/src/routes/SearchFilterBar.svelte.spec.ts b/frontend/src/routes/SearchFilterBar.svelte.spec.ts index 86075e09..065f1721 100644 --- a/frontend/src/routes/SearchFilterBar.svelte.spec.ts +++ b/frontend/src/routes/SearchFilterBar.svelte.spec.ts @@ -103,6 +103,29 @@ describe('SearchFilterBar – AND/OR tag operator toggle', () => { await expect.poll(() => onSearch.mock.calls.length).toBeGreaterThan(0); vi.unstubAllGlobals(); }); + + it('calls onSearchImmediate instead of onSearch when operator is toggled and onSearchImmediate is provided', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([]) }) + ); + const onSearch = vi.fn(); + const onSearchImmediate = vi.fn(); + render(SearchFilterBar, { + ...defaultProps, + onSearch, + onSearchImmediate, + sort: 'DATE', + dir: 'desc', + tagNames: [{ name: 'Tag1' }, { name: 'Tag2' }] + }); + await openAdvanced(); + const toggle = page.getByTestId('and-or-toggle'); + await toggle.getByRole('button', { name: 'OR' }).click(); + await expect.poll(() => onSearchImmediate.mock.calls.length).toBeGreaterThan(0); + expect(onSearch).not.toHaveBeenCalled(); + vi.unstubAllGlobals(); + }); }); describe('SearchFilterBar – tagQ live filter', () => {