From 286c212b45a3e8cbe3543349ad6906b2e84f635e Mon Sep 17 00:00:00 2001 From: Francisco Cruz Date: Tue, 17 Mar 2026 09:53:21 +0000 Subject: [PATCH 1/3] Fix #3030: Selection for DataTable cleared with custom action settings In derivedPropsHelper.ts, selected rows may be invalidated when sorting, filtering or changing pages, while using custom action settings. Invalidation happens when sorting, filtering or pagination actions are set to custom and their values change. The code does not check wether the same callback also provides a new selected_rows value. Because invalidation runs inside a setTimeout(..., 0), when a callback updates both selection and sorting, filtering or pagination, the selection briefly appears and clears, causing a visible "flicker". To fix this, before invalidating the selection, we simply have to check wether selected_rows actually changed in the current callback. The selection is only cleared if it did not change, preventing the invalidation of the sent selection. --- .../components/Table/derivedPropsHelper.ts | 20 ++-- .../selenium/test_selected_rows_custom.py | 107 ++++++++++++++++++ 2 files changed, 120 insertions(+), 7 deletions(-) create mode 100644 components/dash-table/tests/selenium/test_selected_rows_custom.py diff --git a/components/dash-table/src/dash-table/components/Table/derivedPropsHelper.ts b/components/dash-table/src/dash-table/components/Table/derivedPropsHelper.ts index 1107942987..a381f3f1cf 100644 --- a/components/dash-table/src/dash-table/components/Table/derivedPropsHelper.ts +++ b/components/dash-table/src/dash-table/components/Table/derivedPropsHelper.ts @@ -18,6 +18,9 @@ export default () => { page_current, page_size ]); + const selectedRowsCache = memoizeOneWithFlag( + selected_rows => selected_rows + ); const sortCache = memoizeOneWithFlag(sort => sort); const viewportCache = memoizeOneWithFlag(viewport => viewport); const viewportSelectedColumnsCache = memoizeOneWithFlag( @@ -37,6 +40,7 @@ export default () => { page_action, page_current, page_size, + selected_rows, sort_action, sort_by, viewport, @@ -64,17 +68,19 @@ export default () => { const invalidatedFilter = filterCache(filter_query); const invalidatedPagination = paginationCache(page_current, page_size); const invalidatedSort = sortCache(sort_by); + const invalidatedSelectedRows = selectedRowsCache(selected_rows); const invalidateSelection = - (!invalidatedFilter.cached && + invalidatedSelectedRows.cached && + ((!invalidatedFilter.cached && !invalidatedFilter.first && filter_action.type === TableAction.Custom) || - (!invalidatedPagination.cached && - !invalidatedPagination.first && - page_action === TableAction.Custom) || - (!invalidatedSort.cached && - !invalidatedSort.first && - sort_action === TableAction.Custom); + (!invalidatedPagination.cached && + !invalidatedPagination.first && + page_action === TableAction.Custom) || + (!invalidatedSort.cached && + !invalidatedSort.first && + sort_action === TableAction.Custom)); const newProps: Partial = {}; diff --git a/components/dash-table/tests/selenium/test_selected_rows_custom.py b/components/dash-table/tests/selenium/test_selected_rows_custom.py new file mode 100644 index 0000000000..ab54f146da --- /dev/null +++ b/components/dash-table/tests/selenium/test_selected_rows_custom.py @@ -0,0 +1,107 @@ +import dash +from dash.dependencies import Input, Output +from dash import html +from dash.dash_table import DataTable + +import json +import time +import pandas as pd + +url = "https://github.com/plotly/datasets/raw/master/" "26k-consumer-complaints.csv" +rawDf = pd.read_csv(url, nrows=100) +rawDf["id"] = rawDf.index + 3000 +df = rawDf.to_dict("records") + + +def get_app(): + app = dash.Dash(__name__) + + app.layout = html.Div( + [ + DataTable( + id="table", + columns=[{"name": i, "id": i} for i in rawDf.columns], + data=df, + row_selectable=True, + selected_rows=[], + filter_action="custom", + filter_query="", + sort_action="custom", + sort_by=[], + page_action="custom", + page_current=0, + page_size=10, + style_cell=dict(width=100, min_width=100, max_width=100), + ), + html.Button("Set selected + sort_by", id="sort"), + html.Button("Set selected + filter", id="filter"), + html.Button("Set selected + page", id="page"), + html.Div(id="selected_rows_output"), + ] + ) + + @app.callback( + Output("selected_rows_output", "children"), + Input("table", "selected_rows"), + ) + def show_selected_rows(selected_rows): + return json.dumps(selected_rows) if selected_rows is not None else "None" + + @app.callback( + Output("table", "selected_rows"), + Output("table", "sort_by"), + Input("sort", "n_clicks"), + prevent_initial_call=True, + ) + def set_selected_and_sort(_): + return [0, 1, 2], [{"column_id": rawDf.columns[0], "direction": "asc"}] + + @app.callback( + Output("table", "selected_rows", allow_duplicate=True), + Output("table", "filter_query"), + Input("filter", "n_clicks"), + prevent_initial_call=True, + ) + def set_selected_and_filter(_): + return [0, 1, 2], "{} > 1".format(rawDf.columns[0]) + + @app.callback( + Output("table", "selected_rows", allow_duplicate=True), + Output("table", "page_current"), + Input("page", "n_clicks"), + prevent_initial_call=True, + ) + def set_selected_and_page(_): + return [0, 1, 2], 1 + + return app + + +def test_tsrc001_selected_rows_persists_with_sort_by(test): + test.start_server(get_app()) + + test.find_element("#sort").click() + time.sleep(1) + + assert test.find_element("#selected_rows_output").text == json.dumps([0, 1, 2]) + assert test.get_log_errors() == [] + + +def test_tsrc002_selected_rows_persists_with_filter_query(test): + test.start_server(get_app()) + + test.find_element("#filter").click() + time.sleep(1) + + assert test.find_element("#selected_rows_output").text == json.dumps([0, 1, 2]) + assert test.get_log_errors() == [] + + +def test_tsrc003_selected_rows_persists_with_page_current(test): + test.start_server(get_app()) + + test.find_element("#page").click() + time.sleep(1) + + assert test.find_element("#selected_rows_output").text == json.dumps([0, 1, 2]) + assert test.get_log_errors() == [] From 519526c15116853f8e6831c7faf87d5526faf58c Mon Sep 17 00:00:00 2001 From: Francisco Cruz Date: Mon, 6 Apr 2026 17:40:16 +0100 Subject: [PATCH 2/3] Fix plotly#3030: Apply suggestion from @AnnMarieW Co-authored-by: Ann Marie Ward <72614349+AnnMarieW@users.noreply.github.com> --- .../dash-table/tests/selenium/test_selected_rows_custom.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/dash-table/tests/selenium/test_selected_rows_custom.py b/components/dash-table/tests/selenium/test_selected_rows_custom.py index ab54f146da..49d17a4c19 100644 --- a/components/dash-table/tests/selenium/test_selected_rows_custom.py +++ b/components/dash-table/tests/selenium/test_selected_rows_custom.py @@ -22,7 +22,7 @@ def get_app(): id="table", columns=[{"name": i, "id": i} for i in rawDf.columns], data=df, - row_selectable=True, + row_selectable="multi", selected_rows=[], filter_action="custom", filter_query="", From 7a23cf8e7a97a0ffeafa6f2189423e90c8c8d2df Mon Sep 17 00:00:00 2001 From: Francisco Cruz Date: Tue, 7 Apr 2026 13:39:01 +0100 Subject: [PATCH 3/3] Fix plotly#3030: Add changelog entry --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c3dc4e9284..663e6f1b23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ This project adheres to [Semantic Versioning](https://semver.org/). ## [UNRELEASED] +## Added +- [#3669](https://github.com/plotly/dash/pull/3669) Selection for DataTable cleared with custom action settings + ## Added - [#3523](https://github.com/plotly/dash/pull/3523) Fall back to background callback function names if source cannot be found