Skip to content

Commit f8b1f76

Browse files
fix(grid): evict and bound _rowWidgets memo cache on buffer swap and scroll (#1010)
1 parent bae1437 commit f8b1f76

2 files changed

Lines changed: 160 additions & 0 deletions

File tree

‎lib/features/workspace/result_grid_view.dart‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -721,6 +721,10 @@ class _VirtualResultGridState extends material.State<VirtualResultGrid> {
721721
/// Last widget built per visual row, reused when a rebuild would produce the
722722
/// same row (see [_DataRow.sameAs]).
723723
final Map<int, _DataRow> _rowWidgets = {};
724+
725+
@material.visibleForTesting
726+
int get rowWidgetsCacheCount => _rowWidgets.length;
727+
724728
List<List<String>> _sortedRows = const [];
725729
List<int> _sortedToModelIndices = const [];
726730

@@ -806,6 +810,7 @@ class _VirtualResultGridState extends material.State<VirtualResultGrid> {
806810
(i) => rows[order[i]],
807811
growable: false,
808812
);
813+
_rowWidgets.removeWhere((idx, _) => idx >= _sortedRows.length);
809814
}
810815

811816
@override
@@ -820,6 +825,7 @@ class _VirtualResultGridState extends material.State<VirtualResultGrid> {
820825
if (oldWidget.stagingBuffer != widget.stagingBuffer) {
821826
oldWidget.stagingBuffer?.removeListener(_onStagingBufferChanged);
822827
widget.stagingBuffer?.addListener(_onStagingBufferChanged);
828+
_rowWidgets.clear();
823829
}
824830
if (oldWidget.columns != widget.columns ||
825831
oldWidget.rows != widget.rows ||
@@ -834,6 +840,7 @@ class _VirtualResultGridState extends material.State<VirtualResultGrid> {
834840
_selectionFocus = null;
835841
_selection = null;
836842
_editingCell = null;
843+
_rowWidgets.clear();
837844
widget.onRowSelected?.call(null);
838845
}
839846
// With a staging buffer and no filter mapping the displayed rows come from
@@ -853,6 +860,7 @@ class _VirtualResultGridState extends material.State<VirtualResultGrid> {
853860
@override
854861
void dispose() {
855862
_stopAutoScroll();
863+
_rowWidgets.clear();
856864
widget.stagingBuffer?.removeListener(_onStagingBufferChanged);
857865
_horizontalController.removeListener(_onHorizontalScroll);
858866
_horizontalController.dispose();
@@ -1122,6 +1130,7 @@ class _VirtualResultGridState extends material.State<VirtualResultGrid> {
11221130
}
11231131

11241132
void _updateSortedRows({bool asyncIfLarge = true}) {
1133+
_rowWidgets.clear();
11251134
final rows = _baseRows;
11261135
if (_sortColumnIndex == null || _sortOrder == null) {
11271136
_sortedRows = rows;
@@ -1172,6 +1181,7 @@ class _VirtualResultGridState extends material.State<VirtualResultGrid> {
11721181

11731182
void _applySortedData(
11741183
SortedResultGridData sortedData, List<List<String>> rows) {
1184+
_rowWidgets.clear();
11751185
_sortedRows = sortedData.rows;
11761186
if (widget.rowIndicesMapping != null) {
11771187
final mapping = widget.rowIndicesMapping!;
@@ -2285,6 +2295,12 @@ class _VirtualResultGridState extends material.State<VirtualResultGrid> {
22852295
return previous;
22862296
}
22872297
_rowWidgets[rowIndex] = candidate;
2298+
if (_rowWidgets.length > 200) {
2299+
_rowWidgets.removeWhere(
2300+
(idx, _) =>
2301+
(idx - rowIndex).abs() > 100,
2302+
);
2303+
}
22882304
return candidate;
22892305
},
22902306
),
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import 'package:flutter/material.dart' as material;
2+
import 'package:flutter_test/flutter_test.dart';
3+
import 'package:querya_desktop/features/workspace/data_grid_staging_buffer.dart';
4+
import 'package:querya_desktop/features/workspace/result_grid_view.dart';
5+
6+
import '../../support/querya_theme_test_shell.dart';
7+
8+
material.Widget _testShell({required material.Widget child}) {
9+
return queryaThemeTestShell(
10+
child: material.Scaffold(
11+
body: child,
12+
),
13+
);
14+
}
15+
16+
void main() {
17+
group('VirtualResultGrid row memo cache eviction (#1010)', () {
18+
testWidgets(
19+
'caches built rows and clears cache when stagingBuffer is swapped',
20+
(tester) async {
21+
final buffer1 = DataGridStagingBuffer(
22+
columns: const ['id', 'name'],
23+
rows: List.generate(20, (i) => ['$i', 'Item $i']),
24+
);
25+
final buffer2 = DataGridStagingBuffer(
26+
columns: const ['id', 'name'],
27+
rows: List.generate(10, (i) => ['new_$i', 'New $i']),
28+
);
29+
30+
await tester.pumpWidget(
31+
_testShell(
32+
child: material.SizedBox(
33+
width: 800,
34+
height: 400,
35+
child: VirtualResultGrid(
36+
columns: const ['id', 'name'],
37+
rows: buffer1.effectiveRows,
38+
stagingBuffer: buffer1,
39+
),
40+
),
41+
),
42+
);
43+
await tester.pumpAndSettle();
44+
45+
final dynamic state = tester.state(find.byType(VirtualResultGrid));
46+
expect(state.rowWidgetsCacheCount, greaterThan(0));
47+
48+
// Swap to buffer2
49+
await tester.pumpWidget(
50+
_testShell(
51+
child: material.SizedBox(
52+
width: 800,
53+
height: 400,
54+
child: VirtualResultGrid(
55+
columns: const ['id', 'name'],
56+
rows: buffer2.effectiveRows,
57+
stagingBuffer: buffer2,
58+
),
59+
),
60+
),
61+
);
62+
await tester.pumpAndSettle();
63+
64+
expect(state.rowWidgetsCacheCount, greaterThan(0));
65+
expect(state.rowWidgetsCacheCount, lessThanOrEqualTo(10));
66+
});
67+
68+
testWidgets('clears cache when columns change', (tester) async {
69+
await tester.pumpWidget(
70+
_testShell(
71+
child: const material.SizedBox(
72+
width: 800,
73+
height: 400,
74+
child: VirtualResultGrid(
75+
columns: ['id', 'name'],
76+
rows: [
77+
['1', 'Alpha'],
78+
['2', 'Beta'],
79+
],
80+
),
81+
),
82+
),
83+
);
84+
await tester.pumpAndSettle();
85+
86+
final dynamic state = tester.state(find.byType(VirtualResultGrid));
87+
expect(state.rowWidgetsCacheCount, 2);
88+
89+
await tester.pumpWidget(
90+
_testShell(
91+
child: const material.SizedBox(
92+
width: 800,
93+
height: 400,
94+
child: VirtualResultGrid(
95+
columns: ['col_a', 'col_b', 'col_c'],
96+
rows: [
97+
['1', 'Alpha', 'Extra'],
98+
['2', 'Beta', 'Extra'],
99+
],
100+
),
101+
),
102+
),
103+
);
104+
await tester.pumpAndSettle();
105+
106+
expect(state.rowWidgetsCacheCount, 2);
107+
});
108+
109+
testWidgets(
110+
'bounds cache size and evicts far rows during heavy vertical scroll',
111+
(tester) async {
112+
final rows = List.generate(350, (i) => ['$i', 'Value $i']);
113+
await tester.pumpWidget(
114+
_testShell(
115+
child: material.SizedBox(
116+
width: 800,
117+
height: 400,
118+
child: VirtualResultGrid(
119+
columns: const ['id', 'value'],
120+
rows: rows,
121+
),
122+
),
123+
),
124+
);
125+
await tester.pumpAndSettle();
126+
127+
final dynamic state = tester.state(find.byType(VirtualResultGrid));
128+
expect(state.rowWidgetsCacheCount, lessThan(100));
129+
130+
// Scroll down across hundreds of rows
131+
for (int i = 0; i < 15; i++) {
132+
await tester.drag(
133+
find.byType(VirtualResultGrid),
134+
const material.Offset(0, -600),
135+
);
136+
await tester.pump();
137+
}
138+
await tester.pumpAndSettle();
139+
140+
// Even after scrolling through 350 rows, the cache must remain bounded (<= 200)
141+
expect(state.rowWidgetsCacheCount, lessThanOrEqualTo(200));
142+
});
143+
});
144+
}

0 commit comments

Comments
 (0)