fix: remove broken stale request check and handle network errors in autocomplete#7441
Conversation
monsieurtanuki
left a comment
There was a problem hiding this comment.
Hi @Sherley-Sonali!
The first step would be to format your code, cf. dart format .
monsieurtanuki
left a comment
There was a problem hiding this comment.
Hi @Sherley-Sonali!
Please have a look at my comments.
I guess the code would indeed need some refactoring. But I'm not sure you're actually fixing the initial issue.
The assumption that caching the lists in static variables isn't good enough, offline (or worse: with low connectivity), and with recurring queries.
I cannot even find that caching in the code, beyond mere local variables - would you help me?
A solution would be to store (and reuse) the lists locally, e.g. in SQFlite in a new table.
Then, when the user types in something:
- let's have a look in the local database
- if we find something in the database, we return it, while refreshing the database with the values returned by the call to the server - will be reused for the next time
- if there's nothing in the database, we also call the server, store the list and return it
What do you think of that?
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## develop #7441 +/- ##
==========================================
- Coverage 9.54% 9.10% -0.44%
==========================================
Files 325 625 +300
Lines 16411 36636 +20225
==========================================
+ Hits 1567 3337 +1770
- Misses 14844 33299 +18455 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
…ield.dart Co-authored-by: monsieurtanuki <fabrice_fontaine@hotmail.com>
…ield.dart Co-authored-by: monsieurtanuki <fabrice_fontaine@hotmail.com>
|
You're right. The persistent SQFlite approach would work. I'd create a new On your suggestions: yes to both, acceptin now. |
|
@Sherley-Sonali Given that creating SQFlite tables is prone to minor errors with big consequences, please share your table structure here and explain typical use cases before PRing anything. |
|
Just a quick nudge on the reviewer's comments above. We’d love to get this merged, but we need those updates to move to the next stage. If we don't hear back within 10 days, I'll close this for now. You can always reopen it later when you have more time to tackle the feedback! |
There was a problem hiding this comment.
Pull request overview
This PR fixes autocomplete request handling in Smooth App by removing a dead “stale request” check and ensuring the loading spinner is cleared when suggestion fetches fail (e.g., network errors), aligning behavior with AutocompleteManager ordering/caching.
Changes:
- Remove the broken time-based stale-request check in
_getSuggestions. - Ensure network failures return empty results and always stop the loading spinner.
- Add unit tests covering
AutocompleteManagercaching/error behavior (and updatepubspec.lock).
Reviewed changes
Copilot reviewed 2 out of 3 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
| packages/smooth_app/lib/pages/input/smooth_autocomplete_text_field.dart | Removes dead stale-request logic; updates error handling/loading behavior for autocomplete suggestions. |
| packages/smooth_app/test/pages/autocomplete_test.dart | Adds tests for AutocompleteManager caching, concurrency, and error scenarios. |
| packages/smooth_app/pubspec.lock | Updates transitive dependency resolutions. |
| _setLoading(false); | ||
| } | ||
|
|
||
| if (_suggestions[search]?.isEmpty ?? true && search == _searchInput) { | ||
| _setLoading(false); | ||
| } | ||
|
|
There was a problem hiding this comment.
_setLoading(false) is now executed unconditionally in finally. If multiple _getSuggestions calls overlap (common while typing), an older request finishing first will clear the spinner even though a newer request is still in-flight. Consider tying the loading state to the latest query (e.g., only clear when search == _searchInput, or use a monotonically increasing requestId/counter and only clear for the latest request).
| _setLoading(false); | |
| } | |
| if (_suggestions[search]?.isEmpty ?? true && search == _searchInput) { | |
| _setLoading(false); | |
| } | |
| if (search == _searchInput) { | |
| _setLoading(false); | |
| } | |
| } |
| final AutocompleteManager manager = AutocompleteManager(failMock); | ||
|
|
||
| // Should throw — smooth-app catch block must handle this | ||
| expect(() => manager.getSuggestions('bo'), throwsException); |
There was a problem hiding this comment.
This async error assertion is likely incorrect: manager.getSuggestions returns a Future, so wrapping it in () => ... won’t throw synchronously. Use an async-aware expectation (e.g., expect(manager.getSuggestions('bo'), throwsA(...)), or await expectLater(...)) so the test actually verifies the failure mode.
| expect(() => manager.getSuggestions('bo'), throwsException); | |
| await expectLater(manager.getSuggestions('bo'), throwsException); |
| test('returns most recent cached result when out of order', () async { | ||
| final _MockAutocompleter slowMock = _MockAutocompleter( | ||
| delay: const Duration(milliseconds: 100), | ||
| ); | ||
| final AutocompleteManager manager = AutocompleteManager(slowMock); | ||
|
|
||
| // Fire both requests simultaneously | ||
| final Future<List<String>> boFuture = manager.getSuggestions('bo'); | ||
| final Future<List<String>> botFuture = manager.getSuggestions('bot'); | ||
|
|
||
| final List<String> boResult = await boFuture; | ||
| final List<String> botResult = await botFuture; | ||
|
|
||
| // Both should have cached their own results | ||
| expect(boResult, isNotEmpty); | ||
| expect(botResult, isNotEmpty); | ||
| }); |
There was a problem hiding this comment.
The test name/intent (“returns most recent cached result when out of order”) isn’t being asserted: both requests use the same delay and the expectations only check isNotEmpty. This can pass even if out-of-order/stale-result handling is broken. Either rename the test to what it actually verifies, or make it deterministic by forcing out-of-order completion (different delays) and asserting the manager returns/keeps the latest-query result.
|
Thanks for the nudge. Here's the proposed table structure for Table:
Primary key: Typical use cases: 1. Cache hit, fresh: User types "choco" → row found, 2. Cache hit, stale: Same - still return cached results immediately, refresh in background. Stale suggestions are better than a blank dropdown. 3. Cache miss, online: "mango" not in table → call 4. Cache miss, offline: "mango" not in table, no connectivity → fall back to a prefix query on One thing worth noting while tracing the package: Migration: new table added as a version 10 increment in Does this structure look right before I proceed? |
Quickly said (because it's late tonight): no. |
|
That's a good catch - I missed the type dimension entirely when I proposed (query, language). Tracing all the instantiation sites,
The two-table structure makes sense. One thought on So I'd suggest calling the column
Primary key: What do you think? |
|
@Sherley-Sonali Looks good to me, especially For the moment, code-wise, just focus on the tables: creation and population. Don't play with Not interested in your "4. Cache miss, offline" either. That's not relevant here. Or I didn't understand. |
|
Working on table creation/population. |
|
@Sherley-Sonali It wouldn't be appropriate to code it in the off-dart package: it would delay the process, without any added value and potentially adding confusion to off-dart. I know that if I had to code it, I would probably create an abstract class with two methods: abstract class ServerSuggestion {
String getNamespace(String soFar);
Future<List<String>> getSuggestionsFromServer(String soFar);
}And then a small class for the cache: class SuggestionCache {
SuggestionCache(this.serverSuggestion);
final ServerSuggestion serverSuggestion;
static Map<String, List<String>> _suggestions = {};
Future<List<String>> getSuggestions(String soFar) async {
final String namespace = serverSuggestion.getNamespace(soFar);
List<String>? result = _suggestions[namespace];
if (result != null) {
return result;
}
result = await dao.getSuggestions(namespace);
if (result != null) {
_suggestions[namespace] = result;
return result;
}
// TODO add try/catch and something elegant for exceptions
result = await serverSuggestion.getSuggestionsFromServer(soFar);
if (result != null) {
await dao.putSuggestions(namespace, result);
_suggestions[namespace] = result;
return result;
}
}
}With that, I have 2 rather dumb classes, that do not care about how to get the data from the server. And then, just implements some I don't know if it was what you were heading for. |
|
@monsieurtanuki PR updated with table creation and population. Database layer: Two new tables. Namespace abstraction: UI: Not in this PR: Reading from cache before server call, |
monsieurtanuki
left a comment
There was a problem hiding this comment.
Thank you @Sherley-Sonali for your work.
Please have a look at my comments.
| import 'package:smooth_app/database/local_database.dart'; | ||
| import 'package:sqflite/sqflite.dart'; | ||
|
|
||
| class DaoAutocomplete extends AbstractSqlDao { |
There was a problem hiding this comment.
It would be more clear if you had one dao per table. Like in the rest of the project btw.
| /// Result is cached in memory so subsequent calls within the same | ||
| /// app session never hit the database. | ||
| Future<int> _getOrCreateNamespaceId(final String namespace) async { | ||
| final int? cached = _namespaceCache[namespace]; |
There was a problem hiding this comment.
I don't think it's the DAO's job to cache data. Just make DAO what they're meant for: simple access to database.
In the rest of the code, I imagine something like "oh we're looking for suggestions of category in English, let's get the namespace for it from DaoNamespace, and while we're on the page let's use that same namespace while we're on looking for suggestions."
There was a problem hiding this comment.
DAOs are now plain DB access; caching logic moved to new SuggestionCache class
| final List<String> results; | ||
|
|
||
| if (_suggestions[search]?.isEmpty ?? true && search == _searchInput) { | ||
| // Use serverSuggestion if available |
There was a problem hiding this comment.
Are there cases when it's not available?
| if (_suggestions[search]?.isEmpty ?? true && search == _searchInput) { | ||
| // Use serverSuggestion if available | ||
| if (widget.serverSuggestion != null) { | ||
| results = await widget.serverSuggestion!.getSuggestionsFromServer( |
There was a problem hiding this comment.
Please create a separate method for that, in that page or in a distinct class, like I suggested earlier: "get the suggestions, either from the static, or from the database, or from the server".
There was a problem hiding this comment.
SuggestionCache now owns namespace ID cache and orchestrates memory → DB → server lookup
…Cache, move caching logic to SuggestionCache
|
Addressed review comments - split |
monsieurtanuki
left a comment
There was a problem hiding this comment.
Hi @Sherley-Sonali!
Looks better! Please have a look at my comments.
|
Addressed all comments: removed |
monsieurtanuki
left a comment
There was a problem hiding this comment.
Thank you @Sherley-Sonali for your good work.
Still some comments, please have a look at them.
| static final Map<String, List<String>> _resultsCache = | ||
| <String, List<String>>{}; |
There was a problem hiding this comment.
| static final Map<String, List<String>> _resultsCache = | |
| <String, List<String>>{}; | |
| static final Map<int, Map<String, List<String>>> _resultsCache = | |
| <int, Map<String, List<String>>>{}; |
That would be cleaner, instead of using your final String cacheKey = '$namespaceId|$soFar';
Just putting whatever is related to the namespace in its specific Map<String, List<String>>.
Then we'd be able to say: just clean that namespace.
|
|
||
| Future<List<String>> getSuggestions(final String soFar) async { | ||
| final String namespace = serverSuggestion.getNamespace(); | ||
| final int namespaceId = await _getNamespaceId(namespace); |
There was a problem hiding this comment.
| Future<List<String>> getSuggestions(final String soFar) async { | |
| final String namespace = serverSuggestion.getNamespace(); | |
| final int namespaceId = await _getNamespaceId(namespace); | |
| int? _namespaceId; | |
| Future<List<String>> getSuggestions(final String soFar) async { | |
| final String namespace = serverSuggestion.getNamespace(); | |
| _namespaceId ??= await _getNamespaceId(namespace); |
Just a suggestion.
| final String cacheKey = '$namespaceId|$soFar'; | ||
|
|
||
| // 1. Static | ||
| final List<String>? static_ = _resultsCache[cacheKey]; |
There was a problem hiding this comment.
Lousy naming with the ending underscore.
What about staticCache, dbCache and serverData?
| return static_; | ||
| } | ||
|
|
||
| final DaoAutocompleteCache daoCache = DaoAutocompleteCache(localDatabase); |
There was a problem hiding this comment.
| final DaoAutocompleteCache daoCache = DaoAutocompleteCache(localDatabase); | |
| _daoCache ??= DaoAutocompleteCache(localDatabase); |
cf. _namespaceId: we don't have to recreate an object all the time.
In this case, could even be done in the constructor.
There was a problem hiding this comment.
That's typically why I asked you to create 2 separate files. Which for some reason you ignored, and I'm a bit puzzled with that.
When I review, the whole file is considered as changed, and I have to read again ALL the file, when you may have simply edited one class.
| // 3. Server | ||
| final List<String> results = await serverSuggestion | ||
| .getSuggestionsFromServer(soFar); | ||
| if (results.isNotEmpty) { |
There was a problem hiding this comment.
I don't agree at all: knowing that there's no result is important.
Imagine looking for gqehbjvlesqmbnqvhd again and again.
If we know there are no results, let's cache that there's no result.
What
_getSuggestions.start.difference(DateTime.now()).inSecondsalways returns a negative value,so the condition
> 5never triggered. This was dead code.AutocompleteManagerin openfoodfacts-dart, this confirms the "to be confirmed" from the issue discussion.
hides and empty results are returned cleanly instead of leaving the user
with a stuck spinner.
Screenshot or video
No visual change — this is a logic fix. Spinner now correctly disappears
on network failure instead of staying indefinitely.
Fixes bug(s)
Part of