From 6c0cca629a4c35018b3be9494d3b4a4a0893a660 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Thu, 15 Oct 2026 07:09:05 +0300 Subject: [PATCH] perf(widgets): split VirtualSelectableTextView lines only when the text changes build() ran text.split('\n') on every rebuild, so a result or routine body of thousands of lines was re-split on each resize, theme change or focus move. The widget is stateful now and keeps the split lines, refreshed in didUpdateWidget when the text differs. Closes #1532 --- .../widgets/virtual_selectable_text_view.dart | 36 ++++++++--- .../virtual_selectable_text_view_test.dart | 60 +++++++++++++++++++ 2 files changed, 88 insertions(+), 8 deletions(-) diff --git a/lib/core/widgets/virtual_selectable_text_view.dart b/lib/core/widgets/virtual_selectable_text_view.dart index 0cbca49b..648df5aa 100644 --- a/lib/core/widgets/virtual_selectable_text_view.dart +++ b/lib/core/widgets/virtual_selectable_text_view.dart @@ -5,7 +5,7 @@ import 'package:flutter/material.dart' as material; /// the entire block works seamlessly. /// If line count > [threshold], virtualizes lines using `ListView.builder` to /// ensure 60 FPS scrolling and rendering without UI jank. -class VirtualSelectableTextView extends material.StatelessWidget { +class VirtualSelectableTextView extends material.StatefulWidget { const VirtualSelectableTextView({ super.key, required this.text, @@ -19,26 +19,46 @@ class VirtualSelectableTextView extends material.StatelessWidget { final int threshold; final material.EdgeInsets padding; + @override + material.State createState() => + _VirtualSelectableTextViewState(); +} + +class _VirtualSelectableTextViewState + extends material.State { + /// `widget.text` split on `\n`. Computed when the text changes, not in + /// `build`: resize, theme and focus rebuilds must not re-split a text of + /// thousands of lines on every frame (#1532). + late List _lines = widget.text.split('\n'); + + @override + void didUpdateWidget(covariant VirtualSelectableTextView oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.text != widget.text) { + _lines = widget.text.split('\n'); + } + } + @override material.Widget build(material.BuildContext context) { - final lines = text.split('\n'); - if (lines.length <= threshold) { + final lines = _lines; + if (lines.length <= widget.threshold) { return material.SingleChildScrollView( - padding: padding, + padding: widget.padding, child: material.SelectableText( - text, - style: style, + widget.text, + style: widget.style, ), ); } return material.ListView.builder( - padding: padding, + padding: widget.padding, itemCount: lines.length, itemBuilder: (context, index) { return material.SelectableText( lines[index], - style: style, + style: widget.style, ); }, ); diff --git a/test/core/widgets/virtual_selectable_text_view_test.dart b/test/core/widgets/virtual_selectable_text_view_test.dart index 3cf5439f..5cdfb20a 100644 --- a/test/core/widgets/virtual_selectable_text_view_test.dart +++ b/test/core/widgets/virtual_selectable_text_view_test.dart @@ -40,5 +40,65 @@ void main() { expect(find.text('Virtual Line 0'), findsOneWidget); expect(find.text('Virtual Line 1'), findsOneWidget); }); + + testWidgets('a new text re-splits the lines (#1532)', (tester) async { + final large = List.generate(50, (i) => 'Virtual Line $i').join('\n'); + var text = 'a\nb'; + late void Function(void Function()) rebuild; + + await tester.pumpWidget( + material.MaterialApp( + home: material.Scaffold( + body: material.StatefulBuilder( + builder: (context, setState) { + rebuild = setState; + return VirtualSelectableTextView(text: text, threshold: 10); + }, + ), + ), + ), + ); + expect(find.byType(material.ListView), findsNothing); + + rebuild(() => text = large); + await tester.pump(); + expect(find.byType(material.ListView), findsOneWidget); + expect(find.text('Virtual Line 0'), findsOneWidget); + + rebuild(() => text = 'only\none'); + await tester.pump(); + expect(find.byType(material.ListView), findsNothing); + expect(find.text('only\none'), findsOneWidget); + }); + + testWidgets('a rebuild with the same text keeps the same lines (#1532)', + (tester) async { + final text = List.generate(50, (i) => 'Virtual Line $i').join('\n'); + var style = const material.TextStyle(fontSize: 12); + late void Function(void Function()) rebuild; + + await tester.pumpWidget( + material.MaterialApp( + home: material.Scaffold( + body: material.StatefulBuilder( + builder: (context, setState) { + rebuild = setState; + return VirtualSelectableTextView( + text: text, + style: style, + threshold: 10, + ); + }, + ), + ), + ), + ); + expect(find.text('Virtual Line 0'), findsOneWidget); + + rebuild(() => style = const material.TextStyle(fontSize: 14)); + await tester.pump(); + expect(find.byType(material.ListView), findsOneWidget); + expect(find.text('Virtual Line 0'), findsOneWidget); + }); }); }