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 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-16 17:24:30 +02:00
parent 39ed66c97f
commit 532692e0fb
3 changed files with 33 additions and 2 deletions

View File

@@ -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)}
/>

View File

@@ -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</button
>
<button
@@ -170,7 +172,7 @@ $effect(() => {
class="rounded px-2 py-0.5 text-xs font-bold tracking-widest uppercase transition-colors {tagOperator === 'OR' ? 'bg-primary text-primary-fg' : 'bg-muted text-ink-2 hover:bg-line'}"
onclick={() => {
tagOperator = 'OR';
onSearch();
(onSearchImmediate ?? onSearch)();
}}>OR</button
>
</div>

View File

@@ -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', () => {