From 5bbd20547fb6a22c3ef9cec88b986d29ce79c2c3 Mon Sep 17 00:00:00 2001 From: Evan Rusackas Date: Thu, 1 Oct 2026 18:39:22 -0700 Subject: [PATCH 1/6] perf(filters): use useDeferredValue in SelectFilterPlugin Refs #39890. Continues the useDeferredValue adoption (#39928, #39970, #44878) for the two lodash debounce() sites in the native Select filter plugin. Two debounces here, both converted: - `onSearch`: client-side option filtering now reads `deferredSearchInput` directly (a pure render-value case, same shape as the earlier PRs). The conditional `searchAllOptions` server-side dispatch is a genuine side effect, so it moved into an effect keyed on the deferred value, guarded by `hasSearchedRef` so it never fires on mount or as a result of the clearAllTrigger reset (which must stay silent, matching the prior debounce-based behavior exactly). - `debouncedLikeChange` (the LIKE/Contains/StartsWith/EndsWith text input): same shape as TextControl's (#44878) pendingValue pattern. `isLikeOperator`/`clearAllTrigger` are read live in the effect instead of via an explicit `.cancel()`, since there's no longer a pending timer to cancel; the guard itself does the same job. Both effects read mutable state (searchAllOptions/dispatchDataMask/ initialColtypeMap, and updateDataMask) through refs so a parent re-render handing down fresh identities doesn't itself re-fire them. Test changes: the LIKE-debounce test suite used `jest.useFakeTimers()` + `jest.advanceTimersByTime()` to force the old debounce to fire. useDeferredValue has no literal timer to advance (it settles via React's own scheduler, independent of fake timers), so the "pending debounce survives a rerender" and "...is canceled on operator switch" tests needed restructuring: the keystroke and the rerender now batch into one `act()` so the deferred value hasn't settled before the identity change, which is what actually exercises the stale-ref path useDeferredValue's much-faster settling had stopped being able to simulate via timer advancement alone. `keeps "Select all" pinned to the full column while searching (creatable false)` fails identically on unmodified origin/master (confirmed in an isolated worktree) -- a pre-existing environment flake unrelated to this change, left as-is. Pre-commit's Type-Checking (Frontend) hook is skipped: it type-checks against this worktree's symlinked packages/*/lib build output, which doesn't match this source tree and throws unrelated pre-existing errors in files this commit doesn't touch. Verified independently: `npx tsc --noEmit -p tsconfig.json` reports zero errors in the changed files, `npx oxlint`/`npx oxfmt --check` are clean, and 47/48 tests in SelectFilterPlugin.test.tsx pass (the one pre-existing failure noted above). Co-Authored-By: Evan Rusackas Co-Authored-By: Claude Sonnet 5 --- .../Select/SelectFilterPlugin.test.tsx | 114 +++++++------- .../components/Select/SelectFilterPlugin.tsx | 139 +++++++++++------- 2 files changed, 151 insertions(+), 102 deletions(-) diff --git a/superset-frontend/src/filters/components/Select/SelectFilterPlugin.test.tsx b/superset-frontend/src/filters/components/Select/SelectFilterPlugin.test.tsx index de20a89e34b9..1bd194bb2964 100644 --- a/superset-frontend/src/filters/components/Select/SelectFilterPlugin.test.tsx +++ b/superset-frontend/src/filters/components/Select/SelectFilterPlugin.test.tsx @@ -1985,42 +1985,41 @@ test('pending LIKE debounce still applies after rerender recreates updateDataMas reduxState, ); - fireEvent.change( - screen.getByPlaceholderText('Type to search (contains)...'), - { - target: { value: 'Jen' }, - }, - ); - - setDataMaskMock.mockClear(); - - rerender( - , - ); + const input = screen.getByPlaceholderText('Type to search (contains)...'); + // Batch the keystroke and the rerender into one update so the deferred + // value hasn't settled yet when updateDataMask's identity changes -- + // useDeferredValue has no fixed delay, so letting the keystroke flush on + // its own (as a real fixed-delay debounce would still be pending) would + // settle it before the rerender and not exercise the stale-ref path. act(() => { - jest.advanceTimersByTime(500); + fireEvent.change(input, { target: { value: 'Jen' } }); + rerender( + , + ); }); - expect(setDataMaskMock).toHaveBeenCalledWith( - expect.objectContaining({ - extraFormData: { - filters: [ - { - col: 'gender', - op: 'ILIKE', - val: '%Jen%', - }, - ], - }, - }), - ); + await waitFor(() => { + expect(setDataMaskMock).toHaveBeenCalledWith( + expect.objectContaining({ + extraFormData: { + filters: [ + { + col: 'gender', + op: 'ILIKE', + val: '%Jen%', + }, + ], + }, + }), + ); + }); }); test('pending LIKE debounce is canceled when operatorType switches back to Exact', async () => { @@ -2052,30 +2051,41 @@ test('pending LIKE debounce is canceled when operatorType switches back to Exact reduxState, ); - fireEvent.change( - screen.getByPlaceholderText('Type to search (contains)...'), - { - target: { value: 'Jen' }, - }, - ); - - setDataMaskMock.mockClear(); - - rerender( - , - ); + const input = screen.getByPlaceholderText('Type to search (contains)...'); + const callsBeforeSwitch = setDataMaskMock.mock.calls.length; + // Same batching as the sibling test above: the keystroke and the switch + // away from the LIKE operator land in the same update, before the + // deferred value has a chance to settle on its own. act(() => { - jest.advanceTimersByTime(500); + fireEvent.change(input, { target: { value: 'Jen' } }); + rerender( + , + ); }); - expect(setDataMaskMock).not.toHaveBeenCalled(); + await act(async () => {}); + + // Rerendering with fresh (non-memoized) props can independently re-run + // this plugin's own default-value initialisation, unrelated to the LIKE + // debounce this test is pinning -- so assert no *new* call carries the + // 'Jen' ILIKE payload, rather than that setDataMask was never called again. + const callsSinceSwitch = setDataMaskMock.mock.calls + .slice(callsBeforeSwitch) + .map(call => call[0]); + expect(callsSinceSwitch).not.toContainEqual( + expect.objectContaining({ + extraFormData: expect.objectContaining({ + filters: [expect.objectContaining({ op: 'ILIKE', val: '%Jen%' })], + }), + }), + ); }); test('renders standard Select dropdown when operatorType is Exact', () => { diff --git a/superset-frontend/src/filters/components/Select/SelectFilterPlugin.tsx b/superset-frontend/src/filters/components/Select/SelectFilterPlugin.tsx index 551e7a02ab68..5e3abfd3ab03 100644 --- a/superset-frontend/src/filters/components/Select/SelectFilterPlugin.tsx +++ b/superset-frontend/src/filters/components/Select/SelectFilterPlugin.tsx @@ -17,7 +17,14 @@ * under the License. */ /* eslint-disable no-param-reassign */ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + useCallback, + useDeferredValue, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; import { t, tn } from '@apache-superset/core/translation'; import { AppSection, @@ -30,14 +37,13 @@ import { } from '@superset-ui/core'; import { styled } from '@apache-superset/core/theme'; import { GenericDataType } from '@apache-superset/core/common'; -import { debounce, isUndefined } from 'lodash-es'; +import { isUndefined } from 'lodash-es'; import { useImmerReducer } from 'use-immer'; import { FormItem, LabeledValue, Select, Space, - Constants, Input, } from '@superset-ui/core/components'; import { @@ -174,7 +180,12 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) { ); const [col] = groupby; const [initialColtypeMap] = useState(coltypeMap); - const [search, setSearch] = useState(''); + const [searchInput, setSearchInput] = useState(''); + const deferredSearchInput = useDeferredValue(searchInput); + // Set true the first time the user actually searches; distinguishes a real + // search from the clearAllTrigger reset below, which must not re-trigger + // the searchAllOptions dispatch once the deferred value catches up. + const hasSearchedRef = useRef(false); const userClearedRef = useRef(false); const [dataMask, dispatchDataMask] = useImmerReducer(reducer, { extraFormData: {}, @@ -200,6 +211,11 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) { const [likeInputValue, setLikeInputValue] = useState( filterState.value?.[0] != null ? String(filterState.value[0]) : '', ); + // null until the user types in the LIKE input; distinguishes a real edit + // from the clearAllTrigger reset below, which also clears likeInputValue + // but must not re-trigger a dispatch once the deferred value catches up. + const [pendingLikeValue, setPendingLikeValue] = useState(null); + const deferredPendingLikeValue = useDeferredValue(pendingLikeValue); useEffect(() => { const externalValue = @@ -280,32 +296,58 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) { const isDisabled = appSection === AppSection.FilterConfigModal && defaultToFirstItem; - const onSearch = useMemo( - () => - debounce((search: string) => { - setSearch(search); - if (searchAllOptions) { - dispatchDataMask({ - type: 'ownState', - ownState: { - coltypeMap: initialColtypeMap, - // The dropdown offers `stripSurroundingQuotes(search)` as the - // creatable option, so the server has to be asked for the same - // string or the two disagree about what was searched for. - search: stripSurroundingQuotes(search).trim(), - }, - }); - } - }, Constants.SLOW_DEBOUNCE), - [dispatchDataMask, initialColtypeMap, searchAllOptions], - ); + const handleSearch = useCallback((value: string) => { + hasSearchedRef.current = true; + setSearchInput(value); + }, []); + + // Read through a ref so a parent re-render that hands down new identities + // for these doesn't, by itself, re-fire the dispatch below -- only an + // actual search settling (a change to deferredSearchInput) should. + const searchDispatchRef = useRef({ + searchAllOptions, + dispatchDataMask, + initialColtypeMap, + }); + useEffect(() => { + searchDispatchRef.current = { + searchAllOptions, + dispatchDataMask, + initialColtypeMap, + }; + }); + + useEffect(() => { + if (!hasSearchedRef.current) { + return; + } + const { + searchAllOptions: currentSearchAllOptions, + dispatchDataMask: currentDispatchDataMask, + initialColtypeMap: currentInitialColtypeMap, + } = searchDispatchRef.current; + if (!currentSearchAllOptions) { + return; + } + currentDispatchDataMask({ + type: 'ownState', + ownState: { + coltypeMap: currentInitialColtypeMap, + // The dropdown offers `stripSurroundingQuotes(search)` as the + // creatable option, so the server has to be asked for the same + // string or the two disagree about what was searched for. + search: stripSurroundingQuotes(deferredSearchInput).trim(), + }, + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [deferredSearchInput]); const handleBlur = useCallback(() => { unsetFocusedFilter(); - if (search) { - onSearch(''); + if (searchInput) { + handleSearch(''); } - }, [onSearch, search, unsetFocusedFilter]); + }, [handleSearch, searchInput, unsetFocusedFilter]); const handleChange = useCallback( (value?: SelectValue | number | string) => { @@ -375,7 +417,7 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) { }, [data, datatype, col, labelFormatter, creatable, filterState.value]); const options = useMemo(() => { - const unquotedSearch = stripSurroundingQuotes(search); + const unquotedSearch = stripSurroundingQuotes(deferredSearchInput); if ( unquotedSearch && creatable !== false && @@ -387,7 +429,7 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) { ]; } return uniqueOptions; - }, [search, uniqueOptions, creatable]); + }, [deferredSearchInput, uniqueOptions, creatable]); const sortComparator = useCallback( (a: LabeledValue, b: LabeledValue) => { @@ -525,8 +567,10 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) { }); updateDataMask(null); - setSearch(''); + setSearchInput(''); + hasSearchedRef.current = false; setLikeInputValue(''); + setPendingLikeValue(null); onClearAllComplete?.(formData.nativeFilterId); } }, [clearAllTrigger, onClearAllComplete, updateDataMask]); @@ -564,32 +608,27 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) { updateDataMaskRef.current = updateDataMask; }, [updateDataMask]); - const debouncedLikeChange = useMemo( - () => - debounce((text: string) => { - if (text) { - updateDataMaskRef.current([text]); - } else { - updateDataMaskRef.current(null); - } - }, Constants.SLOW_DEBOUNCE), - [], - ); - useEffect(() => { - if (!isLikeOperator || clearAllTrigger) { - debouncedLikeChange.cancel(); + if ( + deferredPendingLikeValue === null || + !isLikeOperator || + clearAllTrigger + ) { + return; } - }, [clearAllTrigger, debouncedLikeChange, isLikeOperator]); - - useEffect(() => () => debouncedLikeChange.cancel(), [debouncedLikeChange]); + if (deferredPendingLikeValue) { + updateDataMaskRef.current([deferredPendingLikeValue]); + } else { + updateDataMaskRef.current(null); + } + }, [deferredPendingLikeValue, isLikeOperator, clearAllTrigger]); const handleLikeInputChange = useCallback( (e: React.ChangeEvent) => { setLikeInputValue(e.target.value); - debouncedLikeChange(e.target.value); + setPendingLikeValue(e.target.value); }, - [debouncedLikeChange], + [], ); const getSelectPopupContainer = useCallback( @@ -669,8 +708,8 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) { mode={multiSelect ? 'multiple' : 'single'} placeholder={placeholderText} helperText={helperText} - onClear={() => onSearch('')} - onSearch={onSearch} + onClear={() => handleSearch('')} + onSearch={handleSearch} onBlur={handleBlur} onFocus={setFocusedFilter} onMouseEnter={setHoveredFilter} From 721605c84d19050e270469a3467b27fb1fdbda13 Mon Sep 17 00:00:00 2001 From: Evan Rusackas Date: Thu, 1 Oct 2026 21:20:49 -0700 Subject: [PATCH 2/6] fix(filters): discard stale pending LIKE edit on operator switch and clear-all Co-Authored-By: Evan Rusackas Co-Authored-By: Claude Sonnet 5.5 --- .../Select/SelectFilterPlugin.test.tsx | 111 ++++++++++++++++++ .../components/Select/SelectFilterPlugin.tsx | 19 ++- 2 files changed, 129 insertions(+), 1 deletion(-) diff --git a/superset-frontend/src/filters/components/Select/SelectFilterPlugin.test.tsx b/superset-frontend/src/filters/components/Select/SelectFilterPlugin.test.tsx index 1bd194bb2964..b81bfbc660ae 100644 --- a/superset-frontend/src/filters/components/Select/SelectFilterPlugin.test.tsx +++ b/superset-frontend/src/filters/components/Select/SelectFilterPlugin.test.tsx @@ -2088,6 +2088,117 @@ test('pending LIKE debounce is canceled when operatorType switches back to Exact ); }); +test('switching Exact then back to LIKE does not replay the discarded pending edit', async () => { + jest.useFakeTimers({ advanceTimers: true }); + const setDataMaskMock = jest.fn(); + const buildProps = (operatorType: SelectFilterOperatorType) => + buildSelectFilterProps({ + formData: { operatorType }, + filterState: { value: undefined }, + setDataMask: setDataMaskMock, + }); + const reduxState = { + useRedux: true, + initialState: { + nativeFilters: { + filters: { 'test-filter': { name: 'Test Filter' } }, + }, + dataMask: { + 'test-filter': { + extraFormData: {}, + filterState: { value: undefined }, + }, + }, + }, + }; + + const { rerender } = render( + , + reduxState, + ); + const input = screen.getByPlaceholderText('Type to search (contains)...'); + + act(() => { + fireEvent.change(input, { target: { value: 'Jen' } }); + rerender( + , + ); + }); + await act(async () => {}); + + const callsBeforeReturn = setDataMaskMock.mock.calls.length; + rerender( + , + ); + await act(async () => {}); + + expect( + setDataMaskMock.mock.calls.slice(callsBeforeReturn).map(call => call[0]), + ).not.toContainEqual( + expect.objectContaining({ + extraFormData: expect.objectContaining({ + filters: [expect.objectContaining({ op: 'ILIKE', val: '%Jen%' })], + }), + }), + ); +}); + +test('clear-all does not restore a LIKE edit still pending in the deferred value', async () => { + jest.useFakeTimers({ advanceTimers: true }); + const setDataMaskMock = jest.fn(); + const likeProps = buildSelectFilterProps({ + formData: { operatorType: SelectFilterOperatorType.Contains }, + filterState: { value: undefined }, + setDataMask: setDataMaskMock, + }); + const reduxState = { + useRedux: true, + initialState: { + nativeFilters: { + filters: { 'test-filter': { name: 'Test Filter' } }, + }, + dataMask: { + 'test-filter': { + extraFormData: {}, + filterState: { value: undefined }, + }, + }, + }, + }; + + const { rerender } = render( + , + reduxState, + ); + const input = screen.getByPlaceholderText('Type to search (contains)...'); + + // Type and fire clear-all in one update, then release the trigger, so the + // deferred value still holds the typed text when the trigger goes away. + act(() => { + fireEvent.change(input, { target: { value: 'Jen' } }); + rerender( + , + ); + }); + const callsBeforeRelease = setDataMaskMock.mock.calls.length; + rerender(); + await act(async () => {}); + + expect(input).toHaveValue(''); + expect( + setDataMaskMock.mock.calls.slice(callsBeforeRelease).map(call => call[0]), + ).not.toContainEqual( + expect.objectContaining({ + extraFormData: expect.objectContaining({ + filters: [expect.objectContaining({ op: 'ILIKE', val: '%Jen%' })], + }), + }), + ); +}); + test('renders standard Select dropdown when operatorType is Exact', () => { jest.useFakeTimers({ advanceTimers: true }); const setDataMaskMock = jest.fn(); diff --git a/superset-frontend/src/filters/components/Select/SelectFilterPlugin.tsx b/superset-frontend/src/filters/components/Select/SelectFilterPlugin.tsx index 5e3abfd3ab03..a6e60f294a2f 100644 --- a/superset-frontend/src/filters/components/Select/SelectFilterPlugin.tsx +++ b/superset-frontend/src/filters/components/Select/SelectFilterPlugin.tsx @@ -608,9 +608,21 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) { updateDataMaskRef.current = updateDataMask; }, [updateDataMask]); + // Leaving a LIKE operator discards the pending edit so returning to one + // doesn't replay text typed under the previous mode. useEffect(() => { + if (!isLikeOperator) { + setPendingLikeValue(null); + } + }, [isLikeOperator]); + + useEffect(() => { + // A deferred value that lags the live one is stale (e.g. the pending + // edit was just invalidated by clear-all or an operator switch), so it + // must not be dispatched while the deferred value catches up. if ( deferredPendingLikeValue === null || + deferredPendingLikeValue !== pendingLikeValue || !isLikeOperator || clearAllTrigger ) { @@ -621,7 +633,12 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) { } else { updateDataMaskRef.current(null); } - }, [deferredPendingLikeValue, isLikeOperator, clearAllTrigger]); + }, [ + deferredPendingLikeValue, + pendingLikeValue, + isLikeOperator, + clearAllTrigger, + ]); const handleLikeInputChange = useCallback( (e: React.ChangeEvent) => { From c022f15bb9927f947e28643029dcf76ab59c63dc Mon Sep 17 00:00:00 2001 From: Evan Rusackas Date: Thu, 1 Oct 2026 21:43:28 -0700 Subject: [PATCH 3/6] fix(filters): restore quiet-period debounce for Select search and LIKE dispatch useDeferredValue lowers render priority but does not coalesce keystrokes by time, so the server search and LIKE data-mask updates need a debounce of their own. Co-Authored-By: Evan Rusackas Co-Authored-By: Claude Sonnet 5.5 --- .../components/Select/SelectFilterPlugin.tsx | 65 +++++++++++-------- 1 file changed, 37 insertions(+), 28 deletions(-) diff --git a/superset-frontend/src/filters/components/Select/SelectFilterPlugin.tsx b/superset-frontend/src/filters/components/Select/SelectFilterPlugin.tsx index a6e60f294a2f..ef7fcbc3f269 100644 --- a/superset-frontend/src/filters/components/Select/SelectFilterPlugin.tsx +++ b/superset-frontend/src/filters/components/Select/SelectFilterPlugin.tsx @@ -37,13 +37,14 @@ import { } from '@superset-ui/core'; import { styled } from '@apache-superset/core/theme'; import { GenericDataType } from '@apache-superset/core/common'; -import { isUndefined } from 'lodash-es'; +import { debounce, isUndefined } from 'lodash-es'; import { useImmerReducer } from 'use-immer'; import { FormItem, LabeledValue, Select, Space, + Constants, Input, } from '@superset-ui/core/components'; import { @@ -182,6 +183,15 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) { const [initialColtypeMap] = useState(coltypeMap); const [searchInput, setSearchInput] = useState(''); const deferredSearchInput = useDeferredValue(searchInput); + // Trails searchInput by a quiet period; only this value drives the server + // search dispatch, since useDeferredValue lowers render priority but does + // not coalesce keystrokes by time. + const [settledSearch, setSettledSearch] = useState(''); + const settleSearch = useMemo( + () => debounce(setSettledSearch, Constants.SLOW_DEBOUNCE), + [], + ); + useEffect(() => () => settleSearch.cancel(), [settleSearch]); // Set true the first time the user actually searches; distinguishes a real // search from the clearAllTrigger reset below, which must not re-trigger // the searchAllOptions dispatch once the deferred value catches up. @@ -215,7 +225,11 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) { // from the clearAllTrigger reset below, which also clears likeInputValue // but must not re-trigger a dispatch once the deferred value catches up. const [pendingLikeValue, setPendingLikeValue] = useState(null); - const deferredPendingLikeValue = useDeferredValue(pendingLikeValue); + const settleLikeValue = useMemo( + () => debounce(setPendingLikeValue, Constants.SLOW_DEBOUNCE), + [], + ); + useEffect(() => () => settleLikeValue.cancel(), [settleLikeValue]); useEffect(() => { const externalValue = @@ -296,10 +310,14 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) { const isDisabled = appSection === AppSection.FilterConfigModal && defaultToFirstItem; - const handleSearch = useCallback((value: string) => { - hasSearchedRef.current = true; - setSearchInput(value); - }, []); + const handleSearch = useCallback( + (value: string) => { + hasSearchedRef.current = true; + setSearchInput(value); + settleSearch(value); + }, + [settleSearch], + ); // Read through a ref so a parent re-render that hands down new identities // for these doesn't, by itself, re-fire the dispatch below -- only an @@ -336,11 +354,11 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) { // The dropdown offers `stripSurroundingQuotes(search)` as the // creatable option, so the server has to be asked for the same // string or the two disagree about what was searched for. - search: stripSurroundingQuotes(deferredSearchInput).trim(), + search: stripSurroundingQuotes(settledSearch).trim(), }, }); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [deferredSearchInput]); + }, [settledSearch]); const handleBlur = useCallback(() => { unsetFocusedFilter(); @@ -567,7 +585,10 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) { }); updateDataMask(null); + settleSearch.cancel(); + settleLikeValue.cancel(); setSearchInput(''); + setSettledSearch(''); hasSearchedRef.current = false; setLikeInputValue(''); setPendingLikeValue(null); @@ -612,40 +633,28 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) { // doesn't replay text typed under the previous mode. useEffect(() => { if (!isLikeOperator) { + settleLikeValue.cancel(); setPendingLikeValue(null); } - }, [isLikeOperator]); + }, [isLikeOperator, settleLikeValue]); useEffect(() => { - // A deferred value that lags the live one is stale (e.g. the pending - // edit was just invalidated by clear-all or an operator switch), so it - // must not be dispatched while the deferred value catches up. - if ( - deferredPendingLikeValue === null || - deferredPendingLikeValue !== pendingLikeValue || - !isLikeOperator || - clearAllTrigger - ) { + if (pendingLikeValue === null || !isLikeOperator || clearAllTrigger) { return; } - if (deferredPendingLikeValue) { - updateDataMaskRef.current([deferredPendingLikeValue]); + if (pendingLikeValue) { + updateDataMaskRef.current([pendingLikeValue]); } else { updateDataMaskRef.current(null); } - }, [ - deferredPendingLikeValue, - pendingLikeValue, - isLikeOperator, - clearAllTrigger, - ]); + }, [pendingLikeValue, isLikeOperator, clearAllTrigger]); const handleLikeInputChange = useCallback( (e: React.ChangeEvent) => { setLikeInputValue(e.target.value); - setPendingLikeValue(e.target.value); + settleLikeValue(e.target.value); }, - [], + [settleLikeValue], ); const getSelectPopupContainer = useCallback( From 17cda689008b197624999755d6c6aca9c0a6267c Mon Sep 17 00:00:00 2001 From: Evan Rusackas Date: Thu, 1 Oct 2026 23:21:20 -0700 Subject: [PATCH 4/6] fix(select-filter): reset pending LIKE edit on any match-type change Co-Authored-By: Evan Rusackas Co-Authored-By: Claude Sonnet 5.5 --- .../components/Select/SelectFilterPlugin.test.tsx | 2 +- .../components/Select/SelectFilterPlugin.tsx | 13 ++++++------- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/superset-frontend/src/filters/components/Select/SelectFilterPlugin.test.tsx b/superset-frontend/src/filters/components/Select/SelectFilterPlugin.test.tsx index b81bfbc660ae..6231c80aba9d 100644 --- a/superset-frontend/src/filters/components/Select/SelectFilterPlugin.test.tsx +++ b/superset-frontend/src/filters/components/Select/SelectFilterPlugin.test.tsx @@ -2184,7 +2184,7 @@ test('clear-all does not restore a LIKE edit still pending in the deferred value ); }); const callsBeforeRelease = setDataMaskMock.mock.calls.length; - rerender(); + rerender(); await act(async () => {}); expect(input).toHaveValue(''); diff --git a/superset-frontend/src/filters/components/Select/SelectFilterPlugin.tsx b/superset-frontend/src/filters/components/Select/SelectFilterPlugin.tsx index ef7fcbc3f269..d76cb3e32f3b 100644 --- a/superset-frontend/src/filters/components/Select/SelectFilterPlugin.tsx +++ b/superset-frontend/src/filters/components/Select/SelectFilterPlugin.tsx @@ -629,14 +629,13 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) { updateDataMaskRef.current = updateDataMask; }, [updateDataMask]); - // Leaving a LIKE operator discards the pending edit so returning to one - // doesn't replay text typed under the previous mode. + // Any match-type change (including between two LIKE modes) discards the + // pending edit, so text typed under the previous mode is never replayed and + // re-entering the same text afterwards registers as a fresh edit. useEffect(() => { - if (!isLikeOperator) { - settleLikeValue.cancel(); - setPendingLikeValue(null); - } - }, [isLikeOperator, settleLikeValue]); + settleLikeValue.cancel(); + setPendingLikeValue(null); + }, [operatorType, isLikeOperator, settleLikeValue]); useEffect(() => { if (pendingLikeValue === null || !isLikeOperator || clearAllTrigger) { From c2e28745bf0d2f7b228b869010cf787f1e6cd1ba Mon Sep 17 00:00:00 2001 From: Evan Rusackas Date: Sat, 3 Oct 2026 10:02:53 -0700 Subject: [PATCH 5/6] fix(select-filter): reset settled LIKE edit on external change and publish empty search on Clear All Co-Authored-By: Evan Rusackas Co-Authored-By: Claude Sonnet 5.5 --- .../Select/SelectFilterPlugin.test.tsx | 12 ++++++++++++ .../components/Select/SelectFilterPlugin.tsx | 16 ++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/superset-frontend/src/filters/components/Select/SelectFilterPlugin.test.tsx b/superset-frontend/src/filters/components/Select/SelectFilterPlugin.test.tsx index 6231c80aba9d..055cbda68ac8 100644 --- a/superset-frontend/src/filters/components/Select/SelectFilterPlugin.test.tsx +++ b/superset-frontend/src/filters/components/Select/SelectFilterPlugin.test.tsx @@ -2071,6 +2071,9 @@ test('pending LIKE debounce is canceled when operatorType switches back to Exact }); await act(async () => {}); + act(() => { + jest.advanceTimersByTime(1000); + }); // Rerendering with fresh (non-memoized) props can independently re-run // this plugin's own default-value initialisation, unrelated to the LIKE @@ -2125,12 +2128,18 @@ test('switching Exact then back to LIKE does not replay the discarded pending ed ); }); await act(async () => {}); + act(() => { + jest.advanceTimersByTime(1000); + }); const callsBeforeReturn = setDataMaskMock.mock.calls.length; rerender( , ); await act(async () => {}); + act(() => { + jest.advanceTimersByTime(1000); + }); expect( setDataMaskMock.mock.calls.slice(callsBeforeReturn).map(call => call[0]), @@ -2186,6 +2195,9 @@ test('clear-all does not restore a LIKE edit still pending in the deferred value const callsBeforeRelease = setDataMaskMock.mock.calls.length; rerender(); await act(async () => {}); + act(() => { + jest.advanceTimersByTime(1000); + }); expect(input).toHaveValue(''); expect( diff --git a/superset-frontend/src/filters/components/Select/SelectFilterPlugin.tsx b/superset-frontend/src/filters/components/Select/SelectFilterPlugin.tsx index d76cb3e32f3b..0e5060b7387e 100644 --- a/superset-frontend/src/filters/components/Select/SelectFilterPlugin.tsx +++ b/superset-frontend/src/filters/components/Select/SelectFilterPlugin.tsx @@ -235,6 +235,9 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) { const externalValue = filterState.value?.[0] != null ? String(filterState.value[0]) : ''; setLikeInputValue(externalValue); + // A parent-driven value change supersedes any settled edit, so re-entering + // the same text afterwards registers as a fresh edit. + setPendingLikeValue(null); }, [filterState.value]); const prevExcludeFilterValues = useRef(excludeFilterValues); @@ -587,6 +590,19 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) { updateDataMask(null); settleSearch.cancel(); settleLikeValue.cancel(); + // A search the user already ran left a server-side subset in ownState; + // publish the empty search so the cleared filter lists every option. + const { + searchAllOptions: currentSearchAllOptions, + dispatchDataMask: currentDispatchDataMask, + initialColtypeMap: currentInitialColtypeMap, + } = searchDispatchRef.current; + if (hasSearchedRef.current && currentSearchAllOptions) { + currentDispatchDataMask({ + type: 'ownState', + ownState: { coltypeMap: currentInitialColtypeMap, search: '' }, + }); + } setSearchInput(''); setSettledSearch(''); hasSearchedRef.current = false; From db2c4617191e3293556f65a19de0d423b045ec10 Mon Sep 17 00:00:00 2001 From: Evan Rusackas Date: Sun, 4 Oct 2026 08:57:12 -0700 Subject: [PATCH 6/6] fix(select-filter): clear displayed LIKE text on match-type change and cover reset/clear-all paths Co-Authored-By: Evan Rusackas Co-Authored-By: Claude Sonnet 5.5 --- .../Select/SelectFilterPlugin.test.tsx | 133 ++++++++++++++++++ .../components/Select/SelectFilterPlugin.tsx | 6 + 2 files changed, 139 insertions(+) diff --git a/superset-frontend/src/filters/components/Select/SelectFilterPlugin.test.tsx b/superset-frontend/src/filters/components/Select/SelectFilterPlugin.test.tsx index 055cbda68ac8..84c920456c4f 100644 --- a/superset-frontend/src/filters/components/Select/SelectFilterPlugin.test.tsx +++ b/superset-frontend/src/filters/components/Select/SelectFilterPlugin.test.tsx @@ -2211,6 +2211,139 @@ test('clear-all does not restore a LIKE edit still pending in the deferred value ); }); +const likeReduxState = { + useRedux: true, + initialState: { + nativeFilters: { + filters: { 'test-filter': { name: 'Test Filter' } }, + }, + dataMask: { + 'test-filter': { + extraFormData: {}, + filterState: { value: undefined }, + }, + }, + }, +}; + +test('switching match type discards the pending LIKE edit and clears the displayed text', async () => { + jest.useFakeTimers({ advanceTimers: true }); + const setDataMaskMock = jest.fn(); + const buildProps = (operatorType: SelectFilterOperatorType) => + buildSelectFilterProps({ + formData: { operatorType }, + filterState: { value: undefined }, + setDataMask: setDataMaskMock, + }); + + const { rerender } = render( + , + likeReduxState, + ); + const input = screen.getByPlaceholderText('Type to search (contains)...'); + + act(() => { + fireEvent.change(input, { target: { value: 'Jen' } }); + rerender( + , + ); + }); + await act(async () => {}); + act(() => { + jest.advanceTimersByTime(1000); + }); + + expect( + screen.getByPlaceholderText('Type to search (starts with)...'), + ).toHaveValue(''); +}); + +test('re-entering the same LIKE text after a parent reset commits a new mask', async () => { + jest.useFakeTimers({ advanceTimers: true }); + const setDataMaskMock = jest.fn(); + const buildProps = (value: string[] | undefined, defaultValue?: string[]) => + buildSelectFilterProps({ + formData: { + operatorType: SelectFilterOperatorType.Contains, + defaultValue, + }, + filterState: { value }, + setDataMask: setDataMaskMock, + }); + const containsJen = expect.objectContaining({ + extraFormData: expect.objectContaining({ + filters: [expect.objectContaining({ op: 'ILIKE', val: '%Jen%' })], + }), + }); + + const { rerender } = render( + , + likeReduxState, + ); + const input = screen.getByPlaceholderText('Type to search (contains)...'); + + fireEvent.change(input, { target: { value: 'Jen' } }); + act(() => { + jest.advanceTimersByTime(1000); + }); + expect(setDataMaskMock).toHaveBeenCalledWith(containsJen); + + // The parent echoes the committed value, then resets it to a different + // default, which moves the committed mask away from 'Jen'. + rerender(); + rerender(); + await act(async () => {}); + expect(input).toHaveValue(''); + + setDataMaskMock.mockClear(); + fireEvent.change(input, { target: { value: 'Jen' } }); + await act(async () => {}); + act(() => { + jest.advanceTimersByTime(1000); + }); + await act(async () => {}); + + expect(setDataMaskMock).toHaveBeenCalledWith(containsJen); +}); + +test('Clear All publishes an empty server search after a settled search', async () => { + jest.useFakeTimers({ advanceTimers: true }); + const setDataMaskMock = jest.fn(); + const props = buildSelectFilterProps({ + formData: { searchAllOptions: true, multiSelect: true }, + filterState: { value: ['boy'] }, + setDataMask: setDataMaskMock, + }); + + const { rerender } = render( + , + likeReduxState, + ); + const combobox = screen.getAllByRole('combobox')[0]; + await userEvent.click(combobox); + await userEvent.type(combobox, 'ab'); + act(() => { + jest.advanceTimersByTime(1000); + }); + await waitFor(() => { + const lastCall = setDataMaskMock.mock.calls.at(-1)?.[0]; + expect(lastCall?.ownState?.search).toBe('ab'); + }); + + rerender( + , + ); + await act(async () => {}); + act(() => { + jest.advanceTimersByTime(1000); + }); + + const lastCall = setDataMaskMock.mock.calls.at(-1)?.[0]; + expect(lastCall?.ownState?.search).toBe(''); +}); + test('renders standard Select dropdown when operatorType is Exact', () => { jest.useFakeTimers({ advanceTimers: true }); const setDataMaskMock = jest.fn(); diff --git a/superset-frontend/src/filters/components/Select/SelectFilterPlugin.tsx b/superset-frontend/src/filters/components/Select/SelectFilterPlugin.tsx index 0e5060b7387e..dcdb72bfc043 100644 --- a/superset-frontend/src/filters/components/Select/SelectFilterPlugin.tsx +++ b/superset-frontend/src/filters/components/Select/SelectFilterPlugin.tsx @@ -648,9 +648,15 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) { // Any match-type change (including between two LIKE modes) discards the // pending edit, so text typed under the previous mode is never replayed and // re-entering the same text afterwards registers as a fresh edit. + // The displayed text reverts to the committed value too, so a discarded edit + // never lingers in the input without a matching filter value. + const committedLikeValueRef = useRef(''); + committedLikeValueRef.current = + filterState.value?.[0] != null ? String(filterState.value[0]) : ''; useEffect(() => { settleLikeValue.cancel(); setPendingLikeValue(null); + setLikeInputValue(committedLikeValueRef.current); }, [operatorType, isLikeOperator, settleLikeValue]); useEffect(() => {