fix: prevent Enter key from re-running a settled search (#473) #487
@@ -55,6 +55,10 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
|||||||
// generation has advanced (prevents out-of-order IMAP responses from
|
// generation has advanced (prevents out-of-order IMAP responses from
|
||||||
// overwriting fresh results with results for an older query).
|
// overwriting fresh results with results for an older query).
|
||||||
int _searchGeneration = 0;
|
int _searchGeneration = 0;
|
||||||
|
// The query whose results are currently settled in _searchResults.
|
||||||
|
// Used to skip redundant re-runs when the user presses Enter on an
|
||||||
|
// already-settled search (issue #473).
|
||||||
|
String? _lastSettledQuery;
|
||||||
bool get _selecting =>
|
bool get _selecting =>
|
||||||
_selectedThreadIds.isNotEmpty || _selectedSearchIds.isNotEmpty;
|
_selectedThreadIds.isNotEmpty || _selectedSearchIds.isNotEmpty;
|
||||||
|
|
||||||
@@ -66,6 +70,7 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_searchResults = null;
|
_searchResults = null;
|
||||||
_searchLoading = false;
|
_searchLoading = false;
|
||||||
|
_lastSettledQuery = null;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -122,8 +127,17 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _runSearch(String query) async {
|
Future<void> _runSearch(String query) async {
|
||||||
if (query.trim().isEmpty) {
|
final q = query.trim();
|
||||||
setState(() => _searchResults = null);
|
if (q.isEmpty) {
|
||||||
|
setState(() {
|
||||||
|
_searchResults = null;
|
||||||
|
_lastSettledQuery = null;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Skip if results are already settled for this exact query — prevents the
|
||||||
|
// Enter key from re-triggering an IMAP search that already completed.
|
||||||
|
if (_searchResults != null && !_searchLoading && q == _lastSettledQuery) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final generation = ++_searchGeneration;
|
final generation = ++_searchGeneration;
|
||||||
@@ -131,9 +145,12 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
|||||||
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, q);
|
||||||
if (mounted && generation == _searchGeneration) {
|
if (mounted && generation == _searchGeneration) {
|
||||||
setState(() => _searchResults = results);
|
setState(() {
|
||||||
|
_searchResults = results;
|
||||||
|
_lastSettledQuery = q;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted && generation == _searchGeneration) {
|
if (mounted && generation == _searchGeneration) {
|
||||||
|
|||||||
@@ -554,6 +554,69 @@ void main() {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'pressing Enter on already-settled search does not re-run search (issue #473)',
|
||||||
|
(tester) async {
|
||||||
|
final email1 = testEmail(id: 'acc-1:1', subject: 'Alpha Match');
|
||||||
|
final email2 = testEmail(id: 'acc-1:2', subject: 'Beta Match');
|
||||||
|
|
||||||
|
var searchCallCount = 0;
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
buildApp(
|
||||||
|
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
|
||||||
|
overrides: [
|
||||||
|
accountRepositoryProvider.overrideWithValue(
|
||||||
|
FakeAccountRepository([kTestAccount]),
|
||||||
|
),
|
||||||
|
mailboxRepositoryProvider.overrideWithValue(
|
||||||
|
FakeMailboxRepository(),
|
||||||
|
),
|
||||||
|
emailRepositoryProvider.overrideWithValue(
|
||||||
|
FakeEmailRepository(
|
||||||
|
onSearch: (_) async {
|
||||||
|
searchCallCount++;
|
||||||
|
return [email1, email2];
|
||||||
|
},
|
||||||
|
emailBody: const EmailBody(emailId: '', attachments: []),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Run the initial search.
|
||||||
|
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);
|
||||||
|
|
||||||
|
final countAfterFirstSearch = searchCallCount;
|
||||||
|
|
||||||
|
// Re-focus the search bar (simulates user tapping back into the field
|
||||||
|
// with the keyboard still visible) and press Enter again on the same,
|
||||||
|
// already-settled query.
|
||||||
|
await tester.tap(find.byType(TextField));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.testTextInput.receiveAction(TextInputAction.search);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// The search must NOT re-run; call count must not increase.
|
||||||
|
expect(
|
||||||
|
searchCallCount,
|
||||||
|
countAfterFirstSearch,
|
||||||
|
reason:
|
||||||
|
'Enter on settled results must not re-run the search (issue #473)',
|
||||||
|
);
|
||||||
|
// Results must still be visible — no loading spinner.
|
||||||
|
expect(find.byType(CircularProgressIndicator), findsNothing);
|
||||||
|
expect(find.text('Alpha Match'), findsOneWidget);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
testWidgets('deleting all search results pops back to previous screen', (
|
testWidgets('deleting all search results pops back to previous screen', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
|
|||||||
Reference in New Issue
Block a user