From b08d7914a516a8dd349913eda4f8f0e904006f8c Mon Sep 17 00:00:00 2001 From: David Matejka Date: Wed, 4 Nov 2026 17:04:51 +0100 Subject: [PATCH] fix(bindx-dataview): drop a pending search debounce on every change useDataViewTextFilterInput debounced only non-empty values and left a pending timer running when the box was emptied, so the stale timer applied the previous letter after the filter was cleared, and the sync effect copied it back into the input. A reset from outside while a timer was pending was skipped, and the pending value landed afterwards. The input now cancels the pending timer on every change, including the one that empties the box, and remembers the last query it applied itself: a different query in the filter state came from outside, so the pending value is dropped and the input shows the new query. Fixes #142 Co-Authored-By: Claude Opus 5.5 --- packages/bindx-dataview/src/filterHooks.ts | 32 +++-- .../dataview/textFilterInputDebounce.test.tsx | 121 ++++++++++++++++++ 2 files changed, 144 insertions(+), 9 deletions(-) create mode 100644 tests/react/dataview/textFilterInputDebounce.test.tsx diff --git a/packages/bindx-dataview/src/filterHooks.ts b/packages/bindx-dataview/src/filterHooks.ts index 11b81614..feefb6bb 100644 --- a/packages/bindx-dataview/src/filterHooks.ts +++ b/packages/bindx-dataview/src/filterHooks.ts @@ -264,28 +264,42 @@ export function useDataViewTextFilterInput({ name, debounceMs = 500 }: { debounceMs?: number }): UseDataViewTextFilterInputResult { const [state, setFilter] = useDataViewFilter(name) - const [value, setValue] = useState(state?.query ?? '') + const query = state?.query ?? '' + const [value, setValue] = useState(query) const timerRef = useRef | undefined>(undefined) + // The last query this input applied; a different one in the filter state came from outside. + const appliedQueryRef = useRef(query) + + const cancelPendingQuery = useCallback((): void => { + clearTimeout(timerRef.current) + timerRef.current = undefined + }, []) useEffect(() => { - if (!timerRef.current) { - setValue(state?.query ?? '') - } - }, [state?.query]) + if (query === appliedQueryRef.current) return + appliedQueryRef.current = query + cancelPendingQuery() + setValue(query) + }, [query, cancelPendingQuery]) + + const applyQuery = useCallback((newQuery: string): void => { + appliedQueryRef.current = newQuery + setFilter(it => ({ mode: 'contains' as const, ...it, query: newQuery })) + }, [setFilter]) const onChange = useCallback((e: ChangeEvent): void => { const newValue = e.target.value + cancelPendingQuery() if (debounceMs && newValue) { - if (timerRef.current) clearTimeout(timerRef.current) timerRef.current = setTimeout(() => { timerRef.current = undefined - setFilter(it => ({ mode: 'contains' as const, ...it, query: newValue })) + applyQuery(newValue) }, debounceMs) } else { - setFilter(it => ({ mode: 'contains' as const, ...it, query: newValue })) + applyQuery(newValue) } setValue(newValue) - }, [debounceMs, setFilter]) + }, [debounceMs, applyQuery, cancelPendingQuery]) return { value, onChange } } diff --git a/tests/react/dataview/textFilterInputDebounce.test.tsx b/tests/react/dataview/textFilterInputDebounce.test.tsx new file mode 100644 index 00000000..ca0e28f2 --- /dev/null +++ b/tests/react/dataview/textFilterInputDebounce.test.tsx @@ -0,0 +1,121 @@ +// Issue #142: a debounce timer left pending by the text filter input applied a stale query +// after the box was emptied or the filter was reset from outside. +import '../../setup' +import { describe, test, expect, afterEach } from 'bun:test' +import { render, waitFor, cleanup, act, fireEvent } from '@testing-library/react' +import React, { type ReactElement } from 'react' +import { BindxProvider, MockAdapter, defineSchema, scalar } from '@contember/bindx-react' +import { entityDef } from '@contember/bindx' +import type { TextFilterArtifact } from '@contember/bindx' +import { + DataGrid, + DataGridTextColumn, + DataViewTextFilterInput, + QUERY_FILTER_NAME, + useDataViewFilter, +} from '@contember/bindx-dataview' +import { getByTestId, queryByTestId, TestTable } from './helpers.js' + +afterEach(() => { + cleanup() +}) + +interface Article { + id: string + title: string +} + +const localSchema = defineSchema<{ Article: Article }>({ + entities: { + Article: { + fields: { + id: scalar(), + title: scalar(), + }, + }, + }, +}) + +const ArticleEntity = entityDef
('Article') +const DEBOUNCE_MS = 20 + +function AppliedQuery(): ReactElement { + const [state, setFilter] = useDataViewFilter(QUERY_FILTER_NAME) + return ( + <> + {state?.query ?? ''} + + + ) +} + +function renderGrid(): HTMLElement { + const adapter = new MockAdapter({ + Article: { a1: { id: 'a1', title: 'Alpha' } }, + }, { delay: 0 }) + const { container } = render( + + + {it => ( + <> + + + + + + + + )} + + , + ) + return container +} + +function type(container: HTMLElement, value: string): void { + fireEvent.change(getByTestId(container, 'search'), { target: { value } }) +} + +function inputValue(container: HTMLElement): string { + const input = getByTestId(container, 'search') + if (!(input instanceof HTMLInputElement)) throw new Error('search is not an input') + return input.value +} + +async function waitPastDebounce(): Promise { + await act(async () => { + await new Promise(resolve => setTimeout(resolve, DEBOUNCE_MS * 3)) + }) +} + +describe('text filter input debounce', () => { + test('emptying the box within the debounce does not bring the previous letter back', async () => { + const container = renderGrid() + await waitFor(() => expect(queryByTestId(container, 'datagrid-table')).not.toBeNull()) + + type(container, 'ab') + await waitFor(() => expect(getByTestId(container, 'applied-query').textContent).toBe('ab')) + + type(container, 'a') + type(container, '') + await waitPastDebounce() + + expect(getByTestId(container, 'applied-query').textContent).toBe('') + expect(inputValue(container)).toBe('') + }) + + test('a reset from outside within the debounce drops the pending value', async () => { + const container = renderGrid() + await waitFor(() => expect(queryByTestId(container, 'datagrid-table')).not.toBeNull()) + + type(container, 'ab') + await waitFor(() => expect(getByTestId(container, 'applied-query').textContent).toBe('ab')) + + type(container, 'abc') + fireEvent.click(getByTestId(container, 'reset')) + await waitPastDebounce() + + expect(getByTestId(container, 'applied-query').textContent).toBe('') + expect(inputValue(container)).toBe('') + }) +})