diff --git a/marimo/_plugins/ui/_impl/table.py b/marimo/_plugins/ui/_impl/table.py index d37b39d7e9d..c5d3ba35c9f 100644 --- a/marimo/_plugins/ui/_impl/table.py +++ b/marimo/_plugins/ui/_impl/table.py @@ -1353,7 +1353,13 @@ def clamp_rows_and_columns(manager: TableManager[Any]) -> str: # Do not clamp if max_columns is None if max_columns is not None and len(column_names) > max_columns: - data = data.select_columns(column_names[:max_columns]) + columns_to_select = column_names[:max_columns] + # Always include _marimo_row_id so the frontend can use + # stable row IDs for selection, even when columns are clamped. + # Without this, filtering + selecting returns wrong rows. + if self._has_stable_row_id: + columns_to_select = [INDEX_COLUMN_NAME] + columns_to_select + data = data.select_columns(columns_to_select) try: return data.to_json_str(self._format_mapping) diff --git a/tests/_plugins/ui/_impl/test_table.py b/tests/_plugins/ui/_impl/test_table.py index 82d53d52f92..cc9b9b0c704 100644 --- a/tests/_plugins/ui/_impl/test_table.py +++ b/tests/_plugins/ui/_impl/test_table.py @@ -1582,7 +1582,9 @@ def test_column_clamping_with_dataframes(df: Any): assert table._component_args["max-columns"] == DEFAULT_MAX_COLUMNS json_data = json.loads(table._component_args["data"]) headers = json_data[0].keys() - assert len(headers) == DEFAULT_MAX_COLUMNS # 50 columns + assert ( + len(headers) == DEFAULT_MAX_COLUMNS + 1 + ) # 50 columns + _marimo_row_id # Field types are not clamped assert len(table._component_args["field-types"]) == 60 @@ -1594,7 +1596,7 @@ def test_column_clamping_with_dataframes(df: Any): assert table._component_args["max-columns"] == 40 json_data = json.loads(table._component_args["data"]) headers = json_data[0].keys() - assert len(headers) == 40 # 40 columns + assert len(headers) == 41 # 40 columns + _marimo_row_id # Field types aren't clamped assert len(table._component_args["field-types"]) == 60 @@ -1611,6 +1613,57 @@ def test_column_clamping_with_dataframes(df: Any): assert len(table._component_args["field-types"]) == 60 +@pytest.mark.parametrize( + "df", + create_dataframes( + { + "person": ["Alice", "Bob", "Charlie"], + "age": [20, 30, 40], + **{f"col{i}": [1, 2, 3] for i in range(49)}, + }, + exclude=NON_EAGER_LIBS, + ), +) +def test_selection_with_clamped_columns_and_filter(df: Any): + """Regression test for #8029: selection returns wrong row when table + has more columns than max_columns and is filtered.""" + import narwhals as nw + + table = ui.table(df, max_columns=50) + + # The data sent to frontend should include _marimo_row_id + # even when columns are clamped + json_data = json.loads(table._component_args["data"]) + assert INDEX_COLUMN_NAME in json_data[0] + + # Apply a filter to show only rows where age >= 30 + # (should return Bob and Charlie, with _marimo_row_id 1 and 2) + search_args = SearchTableArgs( + page_size=10, + page_number=0, + filters=[Condition(column_id="age", operator=">=", value=30)], + ) + response = table._search(search_args) + result_data = json.loads(response.data) + + # Filtered data should still include _marimo_row_id + assert INDEX_COLUMN_NAME in result_data[0] + assert len(result_data) == 2 # Bob and Charlie + + # Select row with _marimo_row_id=2 (Charlie) + value = table._convert_value(["2"]) + assert not isinstance(value, nw.DataFrame) + nw_value = nw.from_native(value) + assert nw_value["person"][0] == "Charlie" + assert nw_value["age"][0] == 40 + + # Select row with _marimo_row_id=1 (Bob) + value = table._convert_value(["1"]) + nw_value = nw.from_native(value) + assert nw_value["person"][0] == "Bob" + assert nw_value["age"][0] == 30 + + @pytest.mark.skipif( not DependencyManager.pandas.has(), reason="Pandas not installed" )