From c2bf83b1e6cde08c9a8f5725f5306eb088c1a079 Mon Sep 17 00:00:00 2001 From: AnnMarueW Date: Mon, 2 Mar 2026 14:21:21 -0800 Subject: [PATCH 1/8] add debounce to Dropdown --- .../src/fragments/Dropdown.tsx | 78 +++++++++++++------ components/dash-core-components/src/types.ts | 5 ++ 2 files changed, 59 insertions(+), 24 deletions(-) diff --git a/components/dash-core-components/src/fragments/Dropdown.tsx b/components/dash-core-components/src/fragments/Dropdown.tsx index 8338d03689..deb73031fa 100644 --- a/components/dash-core-components/src/fragments/Dropdown.tsx +++ b/components/dash-core-components/src/fragments/Dropdown.tsx @@ -27,6 +27,7 @@ const Dropdown = (props: DropdownProps) => { className, closeOnSelect, clearable, + debounce, disabled, labels, maxHeight, @@ -42,6 +43,7 @@ const Dropdown = (props: DropdownProps) => { const [optionsCheck, setOptionsCheck] = useState(); const [isOpen, setIsOpen] = useState(false); const [displayOptions, setDisplayOptions] = useState([]); + const [val, setVal] = useState(value); const persistentOptions = useRef([]); const dropdownContainerRef = useRef(null); const dropdownContentRef = useRef( @@ -52,6 +54,13 @@ const Dropdown = (props: DropdownProps) => { const ctx = window.dash_component_api.useDashContext(); const loading = ctx.useLoading(); + // Sync val when external value prop changes + useEffect(() => { + if (!isEqual(value, val)) { + setVal(value); + } + }, [value]); + if (!persistentOptions || !isEqual(options, persistentOptions.current)) { persistentOptions.current = options; } @@ -67,14 +76,27 @@ const Dropdown = (props: DropdownProps) => { ); const sanitizedValues: OptionValue[] = useMemo(() => { - if (value instanceof Array) { - return value; + if (val instanceof Array) { + return val; } - if (isNil(value)) { + if (isNil(val)) { return []; } - return [value]; - }, [value]); + return [val]; + }, [val]); + + const handleSetProps = useCallback( + (newValue: DropdownProps['value']) => { + if (debounce && isOpen) { + // local only + setVal(newValue); + } else { + setVal(newValue); + setProps({ value: newValue }); + } + }, + [debounce, isOpen, setProps] + ); const updateSelection = useCallback( (selection: OptionValue[]) => { @@ -87,30 +109,28 @@ const Dropdown = (props: DropdownProps) => { if (selection.length === 0) { // Empty selection: only allow if clearable is true if (clearable) { - setProps({value: []}); + handleSetProps([]); } // If clearable is false and trying to set empty, do nothing // return; } else { - // Non-empty selection: always allowed in multi-select - setProps({value: selection}); + handleSetProps(selection); } } else { // For single-select, take the first value or null if (selection.length === 0) { // Empty selection: only allow if clearable is true if (clearable) { - setProps({value: null}); + handleSetProps(null); } // If clearable is false and trying to set empty, do nothing // return; } else { - // Take the first value for single-select - setProps({value: selection[selection.length - 1]}); + handleSetProps(selection[selection.length - 1]); } } }, - [multi, clearable, closeOnSelect] + [multi, clearable, closeOnSelect, handleSetProps] ); const onInputChange = useCallback( @@ -179,8 +199,8 @@ const Dropdown = (props: DropdownProps) => { const handleClear = useCallback(() => { const finalValue: DropdownProps['value'] = multi ? [] : null; - setProps({value: finalValue}); - }, [multi]); + handleSetProps(finalValue); + }, [multi, handleSetProps]); const handleSelectAll = useCallback(() => { if (multi) { @@ -189,12 +209,12 @@ const Dropdown = (props: DropdownProps) => { .filter(option => !sanitizedValues.includes(option.value)) .map(option => option.value) ); - setProps({value: allValues}); + handleSetProps(allValues); } if (closeOnSelect) { setIsOpen(false); } - }, [multi, displayOptions, sanitizedValues, closeOnSelect]); + }, [multi, displayOptions, sanitizedValues, closeOnSelect, handleSetProps]); const handleDeselectAll = useCallback(() => { if (multi) { @@ -203,12 +223,12 @@ const Dropdown = (props: DropdownProps) => { displayOption => displayOption.value === option ); }); - setProps({value: withDeselected}); + handleSetProps(withDeselected); } if (closeOnSelect) { setIsOpen(false); } - }, [multi, displayOptions, sanitizedValues, closeOnSelect]); + }, [multi, displayOptions, sanitizedValues, closeOnSelect, handleSetProps]); // Sort options when popover opens - selected options first // Update display options when filtered options or selection changes @@ -233,7 +253,7 @@ const Dropdown = (props: DropdownProps) => { setDisplayOptions(sortedOptions); } - }, [filteredOptions, isOpen]); + }, [filteredOptions, isOpen, sanitizedValues, multi]); // Focus first selected item or search input when dropdown opens useEffect(() => { @@ -264,7 +284,7 @@ const Dropdown = (props: DropdownProps) => { searchInputRef.current.focus(); } }); - }, [isOpen, multi, displayOptions]); + }, [isOpen, multi, displayOptions, sanitizedValues]); // Handle keyboard navigation in popover const handleKeyDown = useCallback((e: React.KeyboardEvent) => { @@ -354,15 +374,25 @@ const Dropdown = (props: DropdownProps) => { }, []); // Handle popover open/close - const handleOpenChange = useCallback( + const handleOpenChange = useCallback( (open: boolean) => { setIsOpen(open); - if (!open) { - setProps({search_value: undefined}); + const updates: Partial = {}; + + if (!isNil(search_value)) { + updates.search_value = undefined; + } + + if (!open && debounce && !isEqual(value, val)) { + updates.value = val; + } + + if (Object.keys(updates).length > 0) { + setProps(updates); } }, - [filteredOptions, sanitizedValues] + [debounce, value, val, search_value, setProps] ); const accessibleId = id ?? uuid(); diff --git a/components/dash-core-components/src/types.ts b/components/dash-core-components/src/types.ts index 0948d474cf..c993676cb0 100644 --- a/components/dash-core-components/src/types.ts +++ b/components/dash-core-components/src/types.ts @@ -741,6 +741,11 @@ export interface DropdownProps extends BaseDccProps { clear_selection?: string; no_options_found?: string; }; + /** + * If True, changes to input values will be sent back to the Dash server only when dropdown menu closes. + * Use with `closeOnSelect=False` + */ + debounce?: boolean; } export interface ChecklistProps extends BaseDccProps { From 2adad3a065ada5281f15de28064d0ce18a08fc3e Mon Sep 17 00:00:00 2001 From: AnnMarueW Date: Mon, 2 Mar 2026 15:03:32 -0800 Subject: [PATCH 2/8] fix search_value --- .../src/fragments/Dropdown.tsx | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/components/dash-core-components/src/fragments/Dropdown.tsx b/components/dash-core-components/src/fragments/Dropdown.tsx index deb73031fa..1374d14978 100644 --- a/components/dash-core-components/src/fragments/Dropdown.tsx +++ b/components/dash-core-components/src/fragments/Dropdown.tsx @@ -375,25 +375,28 @@ const Dropdown = (props: DropdownProps) => { // Handle popover open/close const handleOpenChange = useCallback( - (open: boolean) => { - setIsOpen(open); + (open: boolean) => { + setIsOpen(open); + if (!open) { const updates: Partial = {}; - if (!isNil(search_value)) { + if (!isNil(search_value)) { updates.search_value = undefined; } - if (!open && debounce && !isEqual(value, val)) { + // Commit debounced value on close only + if (debounce && !isEqual(value, val)) { updates.value = val; } if (Object.keys(updates).length > 0) { setProps(updates); } - }, - [debounce, value, val, search_value, setProps] - ); + } + }, + [debounce, value, val, search_value, setProps] +); const accessibleId = id ?? uuid(); const positioningContainerRef = useRef(null); From a47d354949967425b38912d840a91d716f358da5 Mon Sep 17 00:00:00 2001 From: AnnMarueW Date: Mon, 2 Mar 2026 16:04:47 -0800 Subject: [PATCH 3/8] fix tests --- components/dash-core-components/src/fragments/Dropdown.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/components/dash-core-components/src/fragments/Dropdown.tsx b/components/dash-core-components/src/fragments/Dropdown.tsx index 1374d14978..1860977eab 100644 --- a/components/dash-core-components/src/fragments/Dropdown.tsx +++ b/components/dash-core-components/src/fragments/Dropdown.tsx @@ -253,7 +253,7 @@ const Dropdown = (props: DropdownProps) => { setDisplayOptions(sortedOptions); } - }, [filteredOptions, isOpen, sanitizedValues, multi]); + }, [filteredOptions, isOpen]); // Focus first selected item or search input when dropdown opens useEffect(() => { @@ -284,7 +284,7 @@ const Dropdown = (props: DropdownProps) => { searchInputRef.current.focus(); } }); - }, [isOpen, multi, displayOptions, sanitizedValues]); + }, [isOpen, multi, displayOptions]); // Handle keyboard navigation in popover const handleKeyDown = useCallback((e: React.KeyboardEvent) => { From 060fe3c8a58dd59677df86147c4a14f09d824289 Mon Sep 17 00:00:00 2001 From: AnnMarueW Date: Mon, 2 Mar 2026 17:18:56 -0800 Subject: [PATCH 4/8] add test --- .../dropdown/test_dropdown_debounce.py | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 components/dash-core-components/tests/integration/dropdown/test_dropdown_debounce.py diff --git a/components/dash-core-components/tests/integration/dropdown/test_dropdown_debounce.py b/components/dash-core-components/tests/integration/dropdown/test_dropdown_debounce.py new file mode 100644 index 0000000000..5421aaa4c0 --- /dev/null +++ b/components/dash-core-components/tests/integration/dropdown/test_dropdown_debounce.py @@ -0,0 +1,59 @@ +import pytest +from dash import Dash, Input, Output, dcc, html +from selenium.common.exceptions import TimeoutException +from selenium.webdriver.common.keys import Keys +from selenium.webdriver.common.action_chains import ActionChains +import time + +def test_ddde001_dropdown_debounce(dash_duo): + app = Dash(__name__) + app.layout = html.Div( + [ + dcc.Dropdown( + id="dropdown", + options=[ + {"label": "New York City", "value": "NYC"}, + {"label": "Montreal", "value": "MTL"}, + {"label": "San Francisco", "value": "SF"}, + ], + value=["MTL", "SF"], + multi=True, + closeOnSelect=False, + debounce=True, + ), + html.Div(id="dropdown-value-out", style={"height": "10px", "width": "10px"}), + ] + ) + + @app.callback( + Output("dropdown-value-out", "children"), + Input("dropdown", "value"), + ) + def update_value(val): + return ", ".join(val) + + dash_duo.start_server(app) + + assert dash_duo.find_element("#dropdown-value-out").text == "MTL, SF" + + dash_duo.find_element("#dropdown").click() + + # deselect first item + selected = dash_duo.find_elements(".dash-dropdown-options input[checked]") + selected[0].click() + + # UI should update immediately (local state updated) + assert dash_duo.find_element("#dropdown-value").text == "San Francisco" + + # Callback output should not change while dropdown is still open + assert dash_duo.find_element("#dropdown-value-out").text == "MTL, SF" + + # Close the dropdown (ESC simulates user dismiss) + actions = ActionChains(dash_duo.driver) + actions.send_keys(Keys.ESCAPE).perform() + time.sleep(0.1) + + # After closing, the callback output should be updated + assert dash_duo.find_element("#dropdown-value-out").text == "SF" + + assert dash_duo.get_logs() == [] From 36d6cd67088802ade20cd73a6f3ef4c11a7c4259 Mon Sep 17 00:00:00 2001 From: AnnMarueW Date: Mon, 2 Mar 2026 17:23:09 -0800 Subject: [PATCH 5/8] add changelog --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c46576d2c0..516bbc8ce6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ This project adheres to [Semantic Versioning](https://semver.org/). ## [UNRELEASED] +## Added +- [#3637](https://github.com/plotly/dash/pull/3637) Added `debounce` prop to `Dropdown`. + ## Fixed - [#3629](https://github.com/plotly/dash/pull/3629) Fix date pickers not showing date when initially rendered in a hidden container. From bac54a85905a153364e6439ae1262d4e0b812955 Mon Sep 17 00:00:00 2001 From: AnnMarueW Date: Mon, 2 Mar 2026 18:41:21 -0800 Subject: [PATCH 6/8] lint --- .../tests/integration/dropdown/test_dropdown_debounce.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/components/dash-core-components/tests/integration/dropdown/test_dropdown_debounce.py b/components/dash-core-components/tests/integration/dropdown/test_dropdown_debounce.py index 5421aaa4c0..1ceb09afed 100644 --- a/components/dash-core-components/tests/integration/dropdown/test_dropdown_debounce.py +++ b/components/dash-core-components/tests/integration/dropdown/test_dropdown_debounce.py @@ -5,6 +5,7 @@ from selenium.webdriver.common.action_chains import ActionChains import time + def test_ddde001_dropdown_debounce(dash_duo): app = Dash(__name__) app.layout = html.Div( @@ -21,7 +22,9 @@ def test_ddde001_dropdown_debounce(dash_duo): closeOnSelect=False, debounce=True, ), - html.Div(id="dropdown-value-out", style={"height": "10px", "width": "10px"}), + html.Div( + id="dropdown-value-out", style={"height": "10px", "width": "10px"} + ), ] ) From 737370f70edb13a14fa9a2e6bda56f6850dc8e19 Mon Sep 17 00:00:00 2001 From: AnnMarueW Date: Mon, 2 Mar 2026 19:37:59 -0800 Subject: [PATCH 7/8] lint --- .../tests/integration/dropdown/test_dropdown_debounce.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/components/dash-core-components/tests/integration/dropdown/test_dropdown_debounce.py b/components/dash-core-components/tests/integration/dropdown/test_dropdown_debounce.py index 1ceb09afed..e5e97cbd91 100644 --- a/components/dash-core-components/tests/integration/dropdown/test_dropdown_debounce.py +++ b/components/dash-core-components/tests/integration/dropdown/test_dropdown_debounce.py @@ -1,6 +1,4 @@ -import pytest from dash import Dash, Input, Output, dcc, html -from selenium.common.exceptions import TimeoutException from selenium.webdriver.common.keys import Keys from selenium.webdriver.common.action_chains import ActionChains import time From 0b23cb701a9851e6a213a0dcd9b7cc4ff409df1e Mon Sep 17 00:00:00 2001 From: AnnMarueW Date: Tue, 3 Mar 2026 06:33:26 -0800 Subject: [PATCH 8/8] re-run tests --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 516bbc8ce6..6b470b0cc1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ This project adheres to [Semantic Versioning](https://semver.org/). - [#3629](https://github.com/plotly/dash/pull/3629) Fix date pickers not showing date when initially rendered in a hidden container. + ## [4.0.0] - 2026-02-03 ## Added