From 293e854aad3741624fa297c46a30a726be73d195 Mon Sep 17 00:00:00 2001 From: Francisco Cruz Date: Tue, 17 Mar 2026 09:53:21 +0000 Subject: [PATCH] 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 whether 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 whether 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() == []