From 106c9e405502b7211e714e49a7bad01e37b41557 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 25 Sep 2026 13:56:33 +0300 Subject: [PATCH] perf(grid): do not re-sort the whole result on every staged cell edit (Closes #875) With a sort column active, every staging notification re-sorted all rows (O(N log N), an isolate copy from 3000 rows), and the parent handing over a fresh rows list re-sorted a second time through didUpdateWidget. A cell edit does not change the row count, so keep the current visual order and rebuild the displayed row list from the existing permutation in O(N); the edited row no longer jumps while typing. Re-sort still happens on a sort-column change, row insert/delete, or a filter mapping. didUpdateWidget skips the sort when only the rows list changed and the rows come from the staging buffer, and an in-flight isolate sort is invalidated so it cannot revert fresher values. --- lib/features/workspace/result_grid_view.dart | 45 ++++++++- .../result_grid_sorting_model_test.dart | 91 +++++++++++++++++++ 2 files changed, 132 insertions(+), 4 deletions(-) diff --git a/lib/features/workspace/result_grid_view.dart b/lib/features/workspace/result_grid_view.dart index 9712b03d..0baf7afe 100644 --- a/lib/features/workspace/result_grid_view.dart +++ b/lib/features/workspace/result_grid_view.dart @@ -735,15 +735,42 @@ class _VirtualResultGridState extends material.State { _lastKnownStagedRowCount = currentRows.length; setState(() { - if (_sortColumnIndex != null || rowCountChanged) { - _updateSortedRows(); - } if (rowCountChanged) { + _updateSortedRows(); _widthsNeedUpdate = true; + } else if (widget.rowIndicesMapping == null) { + _refreshSortedRowValues(currentRows); + } else if (_sortColumnIndex != null) { + _updateSortedRows(); } }); } + /// Applies staged cell values to the displayed rows without re-sorting. + /// + /// A cell edit does not add or remove rows, so the current visual order is + /// kept (the edited row does not jump while typing) and only the row list is + /// rebuilt in O(N). Sorting again happens on a sort-column change, a row + /// insert / delete, or when the user re-applies the sort. + void _refreshSortedRowValues(List> rows) { + if (_sortColumnIndex == null || _sortOrder == null) { + _sortedRows = rows; + return; + } + final order = _sortedToModelIndices; + if (order.length != rows.length) { + _updateSortedRows(); + return; + } + // Drop any in-flight isolate sort: it was computed from older values. + _sortVersion++; + _sortedRows = List>.generate( + order.length, + (i) => rows[order[i]], + growable: false, + ); + } + @override void didChangeDependencies() { super.didChangeDependencies(); @@ -772,7 +799,17 @@ class _VirtualResultGridState extends material.State { _editingCell = null; widget.onRowSelected?.call(null); } - _updateSortedRows(); + // With a staging buffer and no filter mapping the displayed rows come from + // the buffer, and a cell edit is applied by [_onStagingBufferChanged]. The + // parent handing over a fresh `rows` list for that edit must not re-sort. + final onlyRowsChanged = oldWidget.columns == widget.columns && + oldWidget.stagingBuffer == widget.stagingBuffer && + oldWidget.rowIndicesMapping == widget.rowIndicesMapping; + final rowsFollowBuffer = + widget.stagingBuffer != null && widget.rowIndicesMapping == null; + if (!(onlyRowsChanged && rowsFollowBuffer)) { + _updateSortedRows(); + } } } diff --git a/test/features/workspace/result_grid_sorting_model_test.dart b/test/features/workspace/result_grid_sorting_model_test.dart index 88ae1b98..b55619a4 100644 --- a/test/features/workspace/result_grid_sorting_model_test.dart +++ b/test/features/workspace/result_grid_sorting_model_test.dart @@ -228,4 +228,95 @@ void main() { expect(stagingBuffer.getCellValue(0, 0), equals('Charlie')); }); }); + + group('VirtualResultGrid keeps sorted order stable while editing (#875)', () { + Future pumpSorted( + WidgetTester tester, { + required bool rebuildParent, + }) async { + final columns = ['name', 'age']; + final buffer = DataGridStagingBuffer( + columns: columns, + rows: [ + ['Charlie', '30'], // model 0 + ['Alice', '10'], // model 1 + ['Bob', '20'], // model 2 + ], + ); + addTearDown(buffer.dispose); + + await tester.pumpWidget( + _testShell( + child: material.SizedBox( + width: 800, + height: 600, + child: rebuildParent + // Like ResultsTab: a fresh `rows` list on every buffer change. + ? material.ListenableBuilder( + listenable: buffer, + builder: (_, __) => VirtualResultGrid( + columns: columns, + rows: buffer.effectiveRows, + stagingBuffer: buffer, + ), + ) + : VirtualResultGrid( + columns: columns, + rows: buffer.effectiveRows, + stagingBuffer: buffer, + ), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('name')); + await tester.pumpAndSettle(); + return buffer; + } + + double y(WidgetTester tester, String text) => + tester.getTopLeft(find.text(text)).dy; + + for (final rebuildParent in [false, true]) { + testWidgets( + 'editing a cell keeps the row in place and shows the new value ' + '(parent rebuilds: $rebuildParent)', (tester) async { + final buffer = await pumpSorted(tester, rebuildParent: rebuildParent); + // Sorted ascending: Alice, Bob, Charlie. + expect(y(tester, 'Alice') < y(tester, 'Bob'), isTrue); + + buffer.setCell(1, 0, 'Zed'); // Alice -> Zed (model row 1) + await tester.pumpAndSettle(); + + expect(find.text('Alice'), findsNothing); + // Zed stays first instead of jumping below Charlie. + expect(y(tester, 'Zed') < y(tester, 'Bob'), isTrue); + expect(y(tester, 'Bob') < y(tester, 'Charlie'), isTrue); + }); + + testWidgets( + 'edits keep mapping visual rows to the right model row ' + '(parent rebuilds: $rebuildParent)', (tester) async { + final buffer = await pumpSorted(tester, rebuildParent: rebuildParent); + + buffer.setCell(1, 1, '99'); + await tester.pumpAndSettle(); + await _secondaryClick(tester, find.text('Alice')); + await tester.tap(find.text('Delete Row')); + await tester.pumpAndSettle(); + + expect(buffer.getRowStatus(1), StagedRowStatus.deleted); + expect(buffer.getRowStatus(0), StagedRowStatus.unchanged); + }); + } + + testWidgets('inserting a row still re-sorts', (tester) async { + final buffer = await pumpSorted(tester, rebuildParent: true); + + buffer.addRow(['Aaron', '5']); + await tester.pumpAndSettle(); + + expect(y(tester, 'Aaron') < y(tester, 'Alice'), isTrue); + }); + }); }