fix: discard stale search results when a newer query supersedes them (#468)

This commit was merged in pull request #468.
This commit is contained in:
Bot of Thomas Güttler
2026-06-06 10:32:37 +02:00
parent e28996cf86
commit 7985caa9b4
4 changed files with 170 additions and 5 deletions
+12 -2
View File
@@ -50,6 +50,11 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
// Pagination: number of threads currently requested from the DB. // Pagination: number of threads currently requested from the DB.
static const _pageSize = 50; static const _pageSize = 50;
int _limit = _pageSize; int _limit = _pageSize;
// Incremented on every search start; stale completions are ignored when the
// generation has advanced (prevents out-of-order IMAP responses from
// overwriting fresh results with results for an older query).
int _searchGeneration = 0;
bool get _selecting => bool get _selecting =>
_selectedThreadIds.isNotEmpty || _selectedSearchIds.isNotEmpty; _selectedThreadIds.isNotEmpty || _selectedSearchIds.isNotEmpty;
@@ -121,14 +126,19 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
setState(() => _searchResults = null); setState(() => _searchResults = null);
return; return;
} }
final generation = ++_searchGeneration;
setState(() => _searchLoading = true); setState(() => _searchLoading = true);
try { try {
final results = await ref final results = await ref
.read(emailRepositoryProvider) .read(emailRepositoryProvider)
.searchEmails(widget.accountId, widget.mailboxPath, query.trim()); .searchEmails(widget.accountId, widget.mailboxPath, query.trim());
if (mounted) setState(() => _searchResults = results); if (mounted && generation == _searchGeneration) {
setState(() => _searchResults = results);
}
} finally { } finally {
if (mounted) setState(() => _searchLoading = false); if (mounted && generation == _searchGeneration) {
setState(() => _searchLoading = false);
}
} }
} }
+16
View File
@@ -371,6 +371,14 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
flutter_launcher_icons:
dependency: "direct dev"
description:
name: flutter_launcher_icons
sha256: "10f13781741a2e3972126fae08393d3c4e01fa4cd7473326b94b72cf594195e7"
url: "https://pub.dev"
source: hosted
version: "0.14.4"
flutter_lints: flutter_lints:
dependency: "direct dev" dependency: "direct dev"
description: description:
@@ -562,6 +570,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.1.2" version: "4.1.2"
image:
dependency: transitive
description:
name: image
sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce
url: "https://pub.dev"
source: hosted
version: "4.8.0"
integration_test: integration_test:
dependency: "direct dev" dependency: "direct dev"
description: flutter description: flutter
+124
View File
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
@@ -430,6 +432,128 @@ void main() {
expect(find.text('Result email'), findsWidgets); expect(find.text('Result email'), findsWidgets);
}); });
testWidgets(
'tapping first of multiple search results opens the first email',
(tester) async {
final email1 = testEmail(id: 'acc-1:1', subject: 'Alpha Match');
final email2 = testEmail(id: 'acc-1:2', subject: 'Beta Match');
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
overrides: [
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([kTestAccount]),
),
mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository(),
),
emailRepositoryProvider.overrideWithValue(
FakeEmailRepository(
searchResults: [email1, email2],
emailBody: const EmailBody(emailId: '', attachments: []),
),
),
],
),
);
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), 'Match');
await tester.testTextInput.receiveAction(TextInputAction.search);
await tester.pumpAndSettle();
expect(find.text('Alpha Match'), findsOneWidget);
expect(find.text('Beta Match'), findsOneWidget);
// Tap the first result.
await tester.tap(find.text('Alpha Match'));
await tester.pumpAndSettle();
expect(find.byType(EmailDetailScreen), findsOneWidget);
// The detail AppBar title shows the first email's subject.
expect(
find.descendant(
of: find.byType(AppBar),
matching: find.text('Alpha Match'),
),
findsOneWidget,
);
// The second email's subject must not appear in the detail view.
expect(
find.descendant(
of: find.byType(EmailDetailScreen),
matching: find.text('Beta Match'),
),
findsNothing,
);
},
);
testWidgets(
'stale search results from a slower concurrent search are discarded',
(tester) async {
// Reproduces: user types quickly, triggering multiple concurrent IMAP
// searches. An older, slower search must not overwrite the results for
// the user's current query (issue #467).
final staleEmail = testEmail(id: 'acc-1:1', subject: 'Stale Result');
final freshEmail = testEmail(id: 'acc-1:2', subject: 'Fresh Result');
// The first search call is held open by a Completer; all subsequent
// calls resolve immediately with freshEmail.
final staleCompleter = Completer<List<Email>>();
var firstCall = true;
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
overrides: [
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([kTestAccount]),
),
mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository(),
),
emailRepositoryProvider.overrideWithValue(
FakeEmailRepository(
onSearch: (_) {
if (firstCall) {
firstCall = false;
return staleCompleter.future;
}
return Future.value([freshEmail]);
},
emailBody: const EmailBody(emailId: '', attachments: []),
),
),
],
),
);
await tester.pumpAndSettle();
// Trigger the first (slow) search.
await tester.enterText(find.byType(TextField), 'slow');
await tester.testTextInput.receiveAction(TextInputAction.search);
// Do not pumpAndSettle yet — the slow search is still in flight.
// Trigger the second (fast) search by changing the query.
await tester.enterText(find.byType(TextField), 'fast');
await tester.testTextInput.receiveAction(TextInputAction.search);
await tester.pumpAndSettle(); // fast searches settle immediately
// The fresh results must be shown.
expect(find.text('Fresh Result'), findsOneWidget);
expect(find.text('Stale Result'), findsNothing);
// Now let the stale search complete.
staleCompleter.complete([staleEmail]);
await tester.pumpAndSettle();
// The stale results must NOT replace the fresh ones.
expect(find.text('Fresh Result'), findsOneWidget);
expect(find.text('Stale Result'), findsNothing);
},
);
testWidgets('deleting all search results pops back to previous screen', ( testWidgets('deleting all search results pops back to previous screen', (
tester, tester,
) async { ) async {
+18 -3
View File
@@ -216,12 +216,17 @@ class FakeEmailRepository implements EmailRepository {
final List<Email> _searchResults; final List<Email> _searchResults;
/// Optional override: when set, [searchEmails] calls this instead of
/// returning [_searchResults]. Useful for testing race-condition fixes.
final Future<List<Email>> Function(String query)? onSearch;
FakeEmailRepository({ FakeEmailRepository({
List<Email>? emails, List<Email>? emails,
Email? emailDetail, Email? emailDetail,
EmailBody? emailBody, EmailBody? emailBody,
List<Email>? searchResults, List<Email>? searchResults,
String rawRfc822 = '', String rawRfc822 = '',
this.onSearch,
}) : _emails = emails ?? [], }) : _emails = emails ?? [],
_emailDetail = emailDetail, _emailDetail = emailDetail,
_searchResults = searchResults ?? [], _searchResults = searchResults ?? [],
@@ -274,7 +279,15 @@ class FakeEmailRepository implements EmailRepository {
Stream.value(_emails.where((e) => e.threadId == threadId).toList()); Stream.value(_emails.where((e) => e.threadId == threadId).toList());
@override @override
Future<Email?> getEmail(String emailId) async => _emailDetail; Future<Email?> getEmail(String emailId) async {
for (final e in _searchResults) {
if (e.id == emailId) return e;
}
for (final e in _emails) {
if (e.id == emailId) return e;
}
return _emailDetail;
}
@override @override
Future<EmailBody> getEmailBody(String emailId) async => _emailBody; Future<EmailBody> getEmailBody(String emailId) async => _emailBody;
@@ -340,8 +353,10 @@ class FakeEmailRepository implements EmailRepository {
String accountId, String accountId,
String mailboxPath, String mailboxPath,
String query, String query,
) async => ) async {
_searchResults; if (onSearch != null) return onSearch!(query);
return _searchResults;
}
@override @override
Future<List<Email>> searchEmailsGlobal( Future<List<Email>> searchEmailsGlobal(