diff --git a/lib/features/connections/sdui_tree_builder.dart b/lib/features/connections/sdui_tree_builder.dart index 14bee9a1..ec4d346c 100644 --- a/lib/features/connections/sdui_tree_builder.dart +++ b/lib/features/connections/sdui_tree_builder.dart @@ -65,6 +65,10 @@ class SduiTreeBuilderState extends material.State { final Set _loaded = {}; final Set _expanded = {}; final Map _expandErrors = {}; + List<_VisibleRow>? _cachedVisibleRows; + + @material.visibleForTesting + List? get debugVisibleRows => _cachedVisibleRows; static const double _rowExtent = 28; @@ -83,6 +87,7 @@ class SduiTreeBuilderState extends material.State { _loaded.clear(); _expanded.clear(); _expandErrors.clear(); + _cachedVisibleRows = null; } } @@ -90,6 +95,7 @@ class SduiTreeBuilderState extends material.State { setState(() { _expanded.add(node.id); _expandErrors.remove(node.id); + _cachedVisibleRows = null; }); if (!node.expandable || _loaded.contains(node.id) || node.hasChildren) { return; @@ -112,18 +118,23 @@ class SduiTreeBuilderState extends material.State { if (children.isEmpty) { _expandErrors[node.id] = 'No child objects found.'; } + _cachedVisibleRows = null; }); } catch (e) { if (!mounted) return; setState(() { _loading.remove(node.id); _expandErrors[node.id] = e.toString(); + _cachedVisibleRows = null; }); } } void _onCollapse(SduiTreeNode node) { - setState(() => _expanded.remove(node.id)); + setState(() { + _expanded.remove(node.id); + _cachedVisibleRows = null; + }); } void _toggleExpand(SduiTreeNode node) { @@ -143,6 +154,7 @@ class SduiTreeBuilderState extends material.State { node.id, (n) => n.copyWith(children: const []), ); + _cachedVisibleRows = null; }); await _onExpand(node); } @@ -185,13 +197,13 @@ class SduiTreeBuilderState extends material.State { @override material.Widget build(material.BuildContext context) { - final rows = _flattenVisible(); + final rows = _cachedVisibleRows ??= _flattenVisible(); final list = material.ListView.builder( shrinkWrap: widget.maxHeight == null, physics: widget.maxHeight == null ? const material.NeverScrollableScrollPhysics() : const material.ClampingScrollPhysics(), - itemExtent: rows.any((r) => r.isError) ? null : _rowExtent, + itemExtent: _expandErrors.isNotEmpty ? null : _rowExtent, itemCount: rows.length, itemBuilder: (context, index) { final row = rows[index]; diff --git a/test/core/sdui/sdui_builders_test.dart b/test/core/sdui/sdui_builders_test.dart index fedaace8..18415b50 100644 --- a/test/core/sdui/sdui_builders_test.dart +++ b/test/core/sdui/sdui_builders_test.dart @@ -518,5 +518,67 @@ void main() { final text = tester.widget(find.text('users')); expect(text.style?.fontWeight, isNot(material.FontWeight.w600)); }); + + testWidgets( + 'caches visible rows and preserves itemExtent without scanning list on selection changes', + (tester) async { + final schema = SduiTreeSchema.fromJson(const { + 'roots': [ + { + 'id': 'folder.tables', + 'label': 'Tables', + 'expandable': true, + 'children': [ + { + 'id': 'table.users', + 'label': 'users', + 'node_type': 'table', + }, + ], + }, + ], + }); + + var selected = 'table.users'; + late material.StateSetter parentSetState; + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.StatefulBuilder( + builder: (context, setState) { + parentSetState = setState; + return material.Scaffold( + body: SduiTreeBuilder( + schema: schema, + selectedNodeId: selected, + ), + ); + }, + ), + ), + ); + await tester.pumpAndSettle(); + + final state = tester.state( + find.byType(SduiTreeBuilder), + ); + final initialRows = state.debugVisibleRows; + expect(initialRows, isNotNull); + expect(initialRows!.length, 1); + + // Verify itemExtent is set to 28 when there are no errors + final listView = tester.widget( + find.byType(material.ListView), + ); + expect(listView.itemExtent, 28); + + // Change selection in parent: rebuilds SduiTreeBuilder without changing roots/expansion + parentSetState(() => selected = 'other'); + await tester.pump(); + + // Cached rows must be reused by identity + expect(state.debugVisibleRows, same(initialRows)); + }); }); } +