Skip to content

Commit 886cb30

Browse files
perf(grid): offload GridFilterEngine to background isolate and debounce in ResultsTab (#1347) (#1469)
1 parent 3d7c724 commit 886cb30

3 files changed

Lines changed: 225 additions & 21 deletions

File tree

‎lib/features/workspace/grid_filter_engine.dart‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,67 @@
1+
import 'dart:async';
2+
import 'package:flutter/foundation.dart' as foundation;
3+
4+
/// Default threshold for offloading data grid filtering to a background isolate.
5+
const int kFilterIsolateThreshold = 3000;
6+
7+
class FilterIsolateParams {
8+
const FilterIsolateParams({
9+
required this.filterText,
10+
required this.columns,
11+
required this.rows,
12+
});
13+
14+
final String filterText;
15+
final List<String> columns;
16+
final List<List<String>> rows;
17+
}
18+
19+
List<int> _filterRowIndicesIsolate(FilterIsolateParams params) {
20+
return GridFilterEngine.filterRowIndices(
21+
filterText: params.filterText,
22+
columns: params.columns,
23+
rows: params.rows,
24+
);
25+
}
26+
127
/// Client-side filter engine for Data Grid.
228
///
329
/// Evaluates complex multi-clause expressions with AND / OR / NOT, parentheses,
430
/// column predicates (`col = val`, `col > 10`, `col LIKE '%test%'`, `col IN ('a', 'b')`,
531
/// `col IS NULL`, `col BETWEEN x AND y`), and free-text substring search.
632
abstract final class GridFilterEngine {
33+
static const int defaultIsolateThreshold = kFilterIsolateThreshold;
34+
35+
/// Adaptively evaluates [filterText] against [rows]:
36+
/// synchronously for small datasets (< [threshold]), and offloaded to a
37+
/// background isolate via [foundation.compute] for larger datasets to avoid blocking UI frames.
38+
static Future<List<int>> filterRowIndicesAdaptive({
39+
required String filterText,
40+
required List<String> columns,
41+
required List<List<String>> rows,
42+
int threshold = defaultIsolateThreshold,
43+
}) async {
44+
final trimmed = filterText.trim();
45+
if (trimmed.isEmpty || columns.isEmpty || rows.isEmpty) {
46+
return List<int>.generate(rows.length, (i) => i);
47+
}
48+
if (rows.length < threshold) {
49+
return filterRowIndices(
50+
filterText: filterText,
51+
columns: columns,
52+
rows: rows,
53+
);
54+
}
55+
return foundation.compute(
56+
_filterRowIndicesIsolate,
57+
FilterIsolateParams(
58+
filterText: filterText,
59+
columns: columns,
60+
rows: rows,
61+
),
62+
);
63+
}
64+
765
/// Evaluates [filterText] against [rows] with respect to [columns].
866
/// Returns the list of matching row indices.
967
static List<int> filterRowIndices({

‎lib/features/workspace/results_tab.dart‎

Lines changed: 137 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,96 @@ class _ResultsTabState extends material.State<ResultsTab> {
109109
List<List<String>>? _memoEffectiveRows;
110110
List<List<String>> _cachedFilteredRows = const [];
111111
List<int>? _cachedFilteredIndices;
112+
int _filterSeq = 0;
113+
Timer? _filterDebounceTimer;
114+
115+
void _onFilterChanged(String text) {
116+
if (_filterText == text) return;
117+
_filterText = text;
118+
final effectiveRows = widget.stagingBuffer != null
119+
? widget.stagingBuffer!.effectiveRows
120+
: widget.rows;
121+
_applyFilter(effectiveRows, widget.columns);
122+
}
123+
124+
void _onFilterClosed() {
125+
_showFilterBar = false;
126+
_filterDebounceTimer?.cancel();
127+
_filterSeq++;
128+
_filterText = '';
129+
final effectiveRows = widget.stagingBuffer != null
130+
? widget.stagingBuffer!.effectiveRows
131+
: widget.rows;
132+
_memoFilterText = '';
133+
_memoEffectiveRows = effectiveRows;
134+
_memoColumns = widget.columns;
135+
_cachedFilteredRows = effectiveRows;
136+
_cachedFilteredIndices = null;
137+
setState(() {});
138+
}
139+
140+
void _applyFilter(
141+
List<List<String>> effectiveRows,
142+
List<String> columns, {
143+
bool forceSyncIfSmall = true,
144+
}) {
145+
final seq = ++_filterSeq;
146+
_filterDebounceTimer?.cancel();
147+
148+
if (_filterText.trim().isEmpty) {
149+
_memoFilterText = _filterText;
150+
_memoEffectiveRows = effectiveRows;
151+
_memoColumns = columns;
152+
_cachedFilteredRows = effectiveRows;
153+
_cachedFilteredIndices = null;
154+
if (mounted) setState(() {});
155+
return;
156+
}
157+
158+
if (forceSyncIfSmall &&
159+
effectiveRows.length < GridFilterEngine.defaultIsolateThreshold) {
160+
final filteredIndices = GridFilterEngine.filterRowIndices(
161+
filterText: _filterText,
162+
columns: columns,
163+
rows: effectiveRows,
164+
);
165+
final isFiltered = filteredIndices.length != effectiveRows.length;
166+
final filteredRows = !isFiltered
167+
? effectiveRows
168+
: filteredIndices.map((i) => effectiveRows[i]).toList();
169+
170+
_memoFilterText = _filterText;
171+
_memoEffectiveRows = effectiveRows;
172+
_memoColumns = columns;
173+
_cachedFilteredRows = filteredRows;
174+
_cachedFilteredIndices = isFiltered ? filteredIndices : null;
175+
if (mounted) setState(() {});
176+
return;
177+
}
178+
179+
_filterDebounceTimer = Timer(const Duration(milliseconds: 150), () {
180+
unawaited(
181+
GridFilterEngine.filterRowIndicesAdaptive(
182+
filterText: _filterText,
183+
columns: columns,
184+
rows: effectiveRows,
185+
).then((filteredIndices) {
186+
if (!mounted || seq != _filterSeq) return;
187+
final isFiltered = filteredIndices.length != effectiveRows.length;
188+
final filteredRows = !isFiltered
189+
? effectiveRows
190+
: filteredIndices.map((i) => effectiveRows[i]).toList();
191+
setState(() {
192+
_memoFilterText = _filterText;
193+
_memoEffectiveRows = effectiveRows;
194+
_memoColumns = columns;
195+
_cachedFilteredRows = filteredRows;
196+
_cachedFilteredIndices = isFiltered ? filteredIndices : null;
197+
});
198+
}),
199+
);
200+
});
201+
}
112202

113203
List<List<String>> _getFilteredRows(
114204
List<List<String>> effectiveRows,
@@ -120,24 +210,35 @@ class _ResultsTabState extends material.State<ResultsTab> {
120210
return _cachedFilteredRows;
121211
}
122212

123-
final filteredIndices = GridFilterEngine.filterRowIndices(
124-
filterText: _filterText,
125-
columns: columns,
126-
rows: effectiveRows,
127-
);
128-
129-
final isFiltered = filteredIndices.length != effectiveRows.length;
130-
final filteredRows = !isFiltered
131-
? effectiveRows
132-
: filteredIndices.map((i) => effectiveRows[i]).toList();
213+
if (_filterText.trim().isEmpty) {
214+
_memoFilterText = _filterText;
215+
_memoEffectiveRows = effectiveRows;
216+
_memoColumns = columns;
217+
_cachedFilteredRows = effectiveRows;
218+
_cachedFilteredIndices = null;
219+
return effectiveRows;
220+
}
133221

134-
_memoFilterText = _filterText;
135-
_memoEffectiveRows = effectiveRows;
136-
_memoColumns = columns;
137-
_cachedFilteredRows = filteredRows;
138-
_cachedFilteredIndices = isFiltered ? filteredIndices : null;
222+
if (effectiveRows.length < GridFilterEngine.defaultIsolateThreshold) {
223+
final filteredIndices = GridFilterEngine.filterRowIndices(
224+
filterText: _filterText,
225+
columns: columns,
226+
rows: effectiveRows,
227+
);
228+
final isFiltered = filteredIndices.length != effectiveRows.length;
229+
final filteredRows = !isFiltered
230+
? effectiveRows
231+
: filteredIndices.map((i) => effectiveRows[i]).toList();
232+
_memoFilterText = _filterText;
233+
_memoEffectiveRows = effectiveRows;
234+
_memoColumns = columns;
235+
_cachedFilteredRows = filteredRows;
236+
_cachedFilteredIndices = isFiltered ? filteredIndices : null;
237+
return filteredRows;
238+
}
139239

140-
return filteredRows;
240+
_applyFilter(effectiveRows, columns, forceSyncIfSmall: false);
241+
return _cachedFilteredRows.isNotEmpty ? _cachedFilteredRows : effectiveRows;
141242
}
142243

143244
@override
@@ -150,6 +251,22 @@ class _ResultsTabState extends material.State<ResultsTab> {
150251
void didUpdateWidget(covariant ResultsTab oldWidget) {
151252
super.didUpdateWidget(oldWidget);
152253
_syncGridBridge();
254+
if (!identical(oldWidget.rows, widget.rows) ||
255+
!identical(oldWidget.columns, widget.columns) ||
256+
oldWidget.stagingBuffer != widget.stagingBuffer) {
257+
final effectiveRows = widget.stagingBuffer != null
258+
? widget.stagingBuffer!.effectiveRows
259+
: widget.rows;
260+
if (_filterText.isNotEmpty) {
261+
_applyFilter(effectiveRows, widget.columns);
262+
} else {
263+
_memoFilterText = '';
264+
_memoEffectiveRows = effectiveRows;
265+
_memoColumns = widget.columns;
266+
_cachedFilteredRows = effectiveRows;
267+
_cachedFilteredIndices = null;
268+
}
269+
}
153270
}
154271

155272
void _syncGridBridge() {
@@ -247,6 +364,8 @@ class _ResultsTabState extends material.State<ResultsTab> {
247364

248365
@override
249366
void dispose() {
367+
_filterDebounceTimer?.cancel();
368+
_filterSeq++;
250369
_statsDebounce?.cancel();
251370
_statsSeq++;
252371
_selectedRowIndex.dispose();
@@ -542,14 +661,11 @@ class _ResultsTabState extends material.State<ResultsTab> {
542661
expanded: _showFilterBar || _filterText.isNotEmpty,
543662
child: DataGridFilterBar(
544663
filterText: _filterText,
545-
onFilterChanged: (text) => setState(() => _filterText = text),
664+
onFilterChanged: _onFilterChanged,
546665
totalRowCount: effectiveRows.length,
547666
filteredRowCount: filteredRows.length,
548667
columns: widget.columns,
549-
onClose: () => setState(() {
550-
_showFilterBar = false;
551-
_filterText = '';
552-
}),
668+
onClose: _onFilterClosed,
553669
),
554670
),
555671

‎test/features/workspace/data_grid_engines_test.dart‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,36 @@ void main() {
219219
);
220220
expect(res, equals([0]));
221221
});
222+
223+
test('filterRowIndicesAdaptive executes synchronously below threshold', () async {
224+
final res = await GridFilterEngine.filterRowIndicesAdaptive(
225+
filterText: 'status = ACTIVE',
226+
columns: columns,
227+
rows: rows,
228+
threshold: 10,
229+
);
230+
expect(res, equals([0, 2]));
231+
});
232+
233+
test('filterRowIndicesAdaptive executes via isolate at or above threshold', () async {
234+
final res = await GridFilterEngine.filterRowIndicesAdaptive(
235+
filterText: 'status = ACTIVE',
236+
columns: columns,
237+
rows: rows,
238+
threshold: 2,
239+
);
240+
expect(res, equals([0, 2]));
241+
});
242+
243+
test('filterRowIndicesAdaptive returns all indices immediately on empty filter', () async {
244+
final res = await GridFilterEngine.filterRowIndicesAdaptive(
245+
filterText: ' ',
246+
columns: columns,
247+
rows: rows,
248+
threshold: 2,
249+
);
250+
expect(res, equals([0, 1, 2, 3]));
251+
});
222252
});
223253

224254
group('GridSelectionCalcEngine', () {

0 commit comments

Comments
 (0)