Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
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.
  • Loading branch information
Francisc0C committed Apr 6, 2026
commit 286c212b45a3e8cbe3543349ad6906b2e84f635e
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -37,6 +40,7 @@ export default () => {
page_action,
page_current,
page_size,
selected_rows,
sort_action,
sort_by,
viewport,
Expand Down Expand Up @@ -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<SanitizedAndDerivedProps> = {};

Expand Down
107 changes: 107 additions & 0 deletions components/dash-table/tests/selenium/test_selected_rows_custom.py
Original file line number Diff line number Diff line change
@@ -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,
Comment thread
Francisc0C marked this conversation as resolved.
Outdated
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() == []