From 856139f90882399813d4552af937c8fd21da76b3 Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Sun, 6 Sep 2026 02:34:04 +0700 Subject: [PATCH] fix(search): let latest live queries supersede cancelled requests --- lib/screens/home_tab.dart | 42 +++++---------- lib/screens/home_tab_search_results.dart | 2 +- lib/services/platform_bridge.dart | 6 ++- test/live_search_cancellation_test.dart | 65 ++++++++++++++++++++++++ 4 files changed, 83 insertions(+), 32 deletions(-) create mode 100644 test/live_search_cancellation_test.dart diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index d4255538..2b8b3c35 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -70,8 +70,7 @@ class _HomeTabState extends ConsumerState late final ProviderSubscription _homeFeedExtSub; Timer? _liveSearchDebounce; - bool _isLiveSearchInProgress = false; - String? _pendingLiveSearchQuery; + int _searchGeneration = 0; static const int _minLiveSearchChars = 3; static const Duration _liveSearchDelay = Duration(milliseconds: 800); @@ -396,40 +395,21 @@ class _HomeTabState extends ConsumerState } Future _executeLiveSearch(String query) async { - if (_isLiveSearchInProgress) { - _pendingLiveSearchQuery = query; - return; - } - - _isLiveSearchInProgress = true; - _pendingLiveSearchQuery = null; - - try { - await _performSearch(query); - } finally { - _isLiveSearchInProgress = false; - - final pending = _pendingLiveSearchQuery; - _pendingLiveSearchQuery = null; - - if (pending != null && - pending != query && - mounted && - _urlController.text.trim() == pending) { - await Future.delayed(const Duration(milliseconds: 100)); - if (mounted && _urlController.text.trim() == pending) { - _executeLiveSearch(pending); - } - } - } + if (!mounted || _urlController.text.trim() != query) return; + // The provider cancels the preceding native request and rejects stale + // results. Let the latest debounced input reach that cancellation path. + await _performSearch(query); } Future _performSearch(String query, {String? filterOverride}) async { + final generation = ++_searchGeneration; var extState = ref.read(extensionProvider); if (!extState.isInitialized && extState.error == null) { await ref.read(extensionProvider.notifier).waitForInitialization(); + if (!mounted || generation != _searchGeneration) return; extState = ref.read(extensionProvider); } + if (!mounted || generation != _searchGeneration) return; final settings = ref.read(settingsProvider); final searchProvider = HomeSearchProviderPolicy.resolveProvider( @@ -501,7 +481,9 @@ class _HomeTabState extends ConsumerState .read(trackProvider.notifier) .search(query, filterOverride: selectedFilter); } - ref.read(settingsProvider.notifier).setHasSearchedBefore(); + if (mounted && generation == _searchGeneration) { + ref.read(settingsProvider.notifier).setHasSearchedBefore(); + } } Future _pasteFromClipboard() async { @@ -524,7 +506,7 @@ class _HomeTabState extends ConsumerState _isResettingSearchSurface = true; try { _liveSearchDebounce?.cancel(); - _pendingLiveSearchQuery = null; + _searchGeneration++; _lastSearchQuery = null; _activeSearchInput = null; FocusManager.instance.primaryFocus?.unfocus(); diff --git a/lib/screens/home_tab_search_results.dart b/lib/screens/home_tab_search_results.dart index 9dcfc2e3..ea317489 100644 --- a/lib/screens/home_tab_search_results.dart +++ b/lib/screens/home_tab_search_results.dart @@ -824,7 +824,7 @@ extension _HomeTabSearchResultsUI on _HomeTabState { void _onSearchSubmitted() { _liveSearchDebounce?.cancel(); - _pendingLiveSearchQuery = null; + _searchGeneration++; final text = _urlController.text.trim(); if (text.isEmpty) return; diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index 8f92513d..b4e9d097 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -611,6 +611,9 @@ class PlatformBridge { }) { for (final entry in _customSearchInFlight.entries.toList()) { if (entry.key == exceptKey || entry.value.scopeKey != scopeKey) continue; + // A cancelled request must not be reused if the user types its query + // again before the native cancellation finishes. + _customSearchInFlight.remove(entry.key); _cancelExtensionRequestUnawaited(entry.value.requestId); } } @@ -1824,7 +1827,8 @@ class PlatformBridge { 'request_id': requestId, }); final decoded = _decodeMapListResult(result, 'customSearchWithExtension'); - if (generation == _lookupCacheGeneration) { + if (generation == _lookupCacheGeneration && + _customSearchInFlight[cacheKey]?.requestId == requestId) { _putMemoryCachedMapList( _customSearchCache, cacheKey, diff --git a/test/live_search_cancellation_test.dart b/test/live_search_cancellation_test.dart new file mode 100644 index 00000000..ad3e6c1a --- /dev/null +++ b/test/live_search_cancellation_test.dart @@ -0,0 +1,65 @@ +import 'dart:async'; +import 'dart:convert'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotiflac_android/providers/track_provider.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + test( + 'latest search wins and A-B-A never reuses a cancelled request', + () async { + const channel = MethodChannel('com.zarz.spotiflac/backend'); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + final requests = <(String, String, Completer)>[]; + final cancellations = []; + messenger.setMockMethodCallHandler(channel, (call) async { + final arguments = call.arguments as Map; + if (call.method == 'cancelExtensionRequest') { + cancellations.add(arguments['request_id'] as String); + return null; + } + if (call.method == 'customSearchWithExtension') { + final result = Completer(); + requests.add(( + arguments['query'] as String, + arguments['request_id'] as String, + result, + )); + return result.future; + } + return null; + }); + addTearDown(() => messenger.setMockMethodCallHandler(channel, null)); + final container = ProviderContainer(); + addTearDown(container.dispose); + final notifier = container.read(trackProvider.notifier); + final first = notifier.customSearch('regression-search-provider', 'A'); + await Future.delayed(Duration.zero); + final second = notifier.customSearch('regression-search-provider', 'B'); + await Future.delayed(Duration.zero); + final third = notifier.customSearch('regression-search-provider', 'A'); + await Future.delayed(Duration.zero); + expect(requests.map((r) => r.$1), ['A', 'B', 'A']); + expect(cancellations, containsAll([requests[0].$2, requests[1].$2])); + String response(String id) => jsonEncode([ + { + 'id': id, + 'name': id, + 'artist_name': 'Artist', + 'album_name': 'Album', + 'duration': 1000, + }, + ]); + requests[2].$3.complete(response('latest')); + await third; + requests[0].$3.complete(response('old-A')); + requests[1].$3.complete(response('old-B')); + await Future.wait([first, second]); + expect(container.read(trackProvider).tracks.single.id, 'latest'); + expect(container.read(trackProvider).isLoading, isFalse); + }, + ); +}