diff --git a/packages/material_ui/lib/src/search_anchor.dart b/packages/material_ui/lib/src/search_anchor.dart index c08eccafa7e..bc0578517d7 100644 --- a/packages/material_ui/lib/src/search_anchor.dart +++ b/packages/material_ui/lib/src/search_anchor.dart @@ -972,6 +972,8 @@ class _ViewContentState extends State<_ViewContent> { Iterable result = []; String? searchValue; Timer? _timer; + // Identifies the latest call so that older async results cannot replace newer ones. + int _suggestionsCallId = 0; @override void initState() { @@ -1008,14 +1010,9 @@ class _ViewContentState extends State<_ViewContent> { _timer?.cancel(); _timer = Timer(Duration.zero, () async { searchValue = _controller.text; - final Iterable suggestions = await widget.suggestionsBuilder(context, _controller); + await _buildSuggestions(); _timer?.cancel(); _timer = null; - if (mounted) { - setState(() { - result = suggestions; - }); - } }); } } @@ -1056,15 +1053,21 @@ class _ViewContentState extends State<_ViewContent> { Future updateSuggestions() async { if (searchValue != _controller.text) { searchValue = _controller.text; - final Iterable suggestions = await widget.suggestionsBuilder(context, _controller); - if (mounted) { - setState(() { - result = suggestions; - }); - } + await _buildSuggestions(); } } + Future _buildSuggestions() async { + final int callId = ++_suggestionsCallId; + final Iterable suggestions = await widget.suggestionsBuilder(context, _controller); + if (!mounted || callId != _suggestionsCallId) { + return; + } + setState(() { + result = suggestions; + }); + } + @override Widget build(BuildContext context) { final Widget defaultLeading = BackButton( diff --git a/packages/material_ui/pending_changelogs/change_2026_08_16_search_anchor.yaml b/packages/material_ui/pending_changelogs/change_2026_08_16_search_anchor.yaml new file mode 100644 index 00000000000..cec507da792 --- /dev/null +++ b/packages/material_ui/pending_changelogs/change_2026_08_16_search_anchor.yaml @@ -0,0 +1,3 @@ +changelog: | + - Prevents stale asynchronous suggestions from replacing newer `SearchAnchor` results. +version: patch diff --git a/packages/material_ui/test/search_anchor_test.dart b/packages/material_ui/test/search_anchor_test.dart index 675a4e93950..568be6c7d91 100644 --- a/packages/material_ui/test/search_anchor_test.dart +++ b/packages/material_ui/test/search_anchor_test.dart @@ -2261,6 +2261,46 @@ void main() { expect(controller.value.text, suggestion); }); + testWidgets('SearchAnchor ignores out-of-order async suggestions', (WidgetTester tester) async { + final requests = >>{}; + + await tester.pumpWidget( + MaterialApp( + home: SearchAnchor( + builder: (BuildContext context, SearchController controller) { + return const Icon(Icons.search); + }, + suggestionsBuilder: (BuildContext context, SearchController controller) { + final String query = controller.text; + if (query.isEmpty) { + return []; + } + final request = Completer>(); + requests[query] = request; + return request.future; + }, + ), + ), + ); + + await tester.tap(find.byIcon(Icons.search)); + await tester.pumpAndSettle(); + + await tester.enterText(findTextField(), 'a'); + await tester.pump(); + await tester.enterText(findTextField(), 'ab'); + await tester.pump(); + + requests['ab']!.complete([const Text('ab-result')]); + await tester.pumpAndSettle(); + expect(find.text('ab-result'), findsOneWidget); + + requests['a']!.complete([const Text('a-result')]); + await tester.pumpAndSettle(); + expect(find.text('ab-result'), findsOneWidget); + expect(find.text('a-result'), findsNothing); + }); + testWidgets('SearchAnchor.bar has a default search bar as the anchor', ( WidgetTester tester, ) async {