Skip to content

fix(app): hold the apps search in its searching state until the newest query lands - #11297

Open
mikejsmith1985 wants to merge 2 commits into
BasedHardware:mainfrom
mikejsmith1985:fix/app-search-empty-flash
Open

fix(app): hold the apps search in its searching state until the newest query lands#11297
mikejsmith1985 wants to merge 2 commits into
BasedHardware:mainfrom
mikejsmith1985:fix/app-search-empty-flash

Conversation

@mikejsmith1985

@mikejsmith1985 mikejsmith1985 commented Aug 9, 2026

Copy link
Copy Markdown

Problem

Typing in the Apps tab intermittently shows "No apps found" for a query that does match.
Searching adhd returns nothing; the same search a moment later returns ADHD Assistant.
Nothing about the account or the app changed in between.

performServerSearch used isSearching for two jobs at once:

  • the flag the apps screen renders its loading state from, and
  • the re-entrancy guard that stops two requests running at the same time.

When a query was superseded mid-flight, the finally block cleared isSearching and
notified listeners before starting the queued follow-up. For that frame the screen saw
"not searching" alongside the previous query's results — which
_buildFilteredAppsSlivers renders as "No apps found" — for a search that was still
outstanding.

The superseded request was already discarding its results correctly. What it must also stop
doing is ending the searching state, which by then belongs to its replacement.

Change

Splits the two jobs. _isDrainingSearchQueue guards re-entrancy, and one run drains the
queue in a loop rather than ending and recursing per query. isSearching now stays true for
the whole chain, so the screen cannot fall into the empty state between two queries.

The filter-building block moves into small named helpers so the request path stays readable.
The filters it produces are unchanged — same keys, same types, same null-vs-true semantics
for the mutually exclusive my_apps / installed_apps flags.

This also removes an unawaited recursive call to performServerSearch() from the finally
block.

Test seam

Adds searchAppsOverride, following the enableAppOverride / disableAppOverride pattern
already in this provider. Without it the queueing behaviour is only reachable through a live
backend, which would put the regression test outside CI. The test drives two overlapping
queries through completers and records what the screen would render on every notification.

Verification

  • flutter test test/providers/app_provider_search_flash_test.dart — 3/3 pass. The first
    assertion fails on the parent commit with (isSearching: false, resultCount: 0) recorded
    while a newer query was still in flight. That is the flash.
  • flutter test test/providers/app_provider_toggle_test.dart — 3/3 pass, unchanged.
  • flutter test — 1119 pass, 2 fail: plans_sheet_l10n and
    voice_recorder_mic_contention. Both fail identically on d60ab7a1c with this work
    stashed. An earlier run also showed voice_recorder_test failing; it passes in isolation
    twice and did not recur on a second full run, so it is order-dependent and unrelated to
    this change.
  • dart analyze — no new issues; two pre-existing avoid_print infos remain.
  • dart format --line-length 120 — applied.

I could not run the app. No macOS and no Android SDK on this machine, so this is proven
by test rather than on a device. I would rather say so than imply otherwise.

Product invariants

scripts/pr-preflight --suggest reports none affected.

Failure class

Failure-Class: none

This is declared none reluctantly, and the reason is probably worth your attention.

FC-superseded-connection-mutates-live-session describes this defect exactly. Its violated
contract — a callback registered on an operation that has since been replaced must never
mutate the state its replacement now owns
— is precisely what went wrong; the retired
operation is an in-flight search rather than a socket, and the state it wrongly wrote is
isSearching rather than session connectedness. Its canonical prevention matches the fix
too: return early when the operation is no longer current, and prove it by delivering the
retired one's completion after the replacement is live. That is what the new test does.

I declared it, and failure-class-protocol passed. But failure-class-guard-artifact-ratchet
then failed:

FC-superseded-connection-mutates-live-session: 3 declarations in the window
(threshold 3) with no 'canonical_prevention_artifact'.

So this would be the third recurrence in 90 days of a class that still has no reusable guard
surface — which is exactly the signal that ratchet exists to raise. Clearing it means either
adding a canonical_prevention_artifact to the definition or grandfathering it in the
allowlist, and both are registry edits that AGENTS.md says an instance fix must never make.

I have declared none so this PR does not smuggle a registry change through a bug fix. If
you would rather it were classified, the honest sequence is a registry-only PR naming a guard
artifact for that class, then re-declaring here — happy to do either, but that is your call,
not mine.


Written with Claude Code. Every file:line above was verified against d60ab7a1c.

Review in cubic

…t query lands

Typing in the Apps tab intermittently shows "No apps found" for a query that
does in fact match. Searching "adhd" returns nothing; the same search a moment
later returns ADHD Assistant.

`performServerSearch` used `isSearching` for two jobs at once: the flag the apps
screen renders its loading state from, and the re-entrancy guard that stops two
requests running together. When a query was superseded mid-flight, the finally
block cleared `isSearching` and notified listeners *before* starting the queued
follow-up. For that frame the screen saw "not searching" with the previous
query's results — which it renders as "No apps found" — for a search that was
still outstanding.

The superseded request was already discarding its results correctly. What it
must also stop doing is ending the searching state, which by then belongs to its
replacement.

Splits the two jobs: `_isDrainingSearchQueue` guards re-entrancy, and one run now
drains the queue in a loop instead of ending and recursing per query. `isSearching`
stays true for the whole chain, so the screen cannot fall into the empty state
between two queries. The filter-building block moves into small named helpers so
the request path stays readable; the filters it produces are unchanged.

Adds `searchAppsOverride` as a test seam, following the existing
`enableAppOverride` / `disableAppOverride` pattern in this provider, so the
queueing can be exercised without a backend and the regression test runs in CI.

Verification:
- flutter test test/providers/app_provider_search_flash_test.dart — 3/3 pass. The
  first assertion fails on the parent commit with
  `(isSearching: false, resultCount: 0)` recorded while a newer query was still
  in flight; that is the flash this fixes
- flutter test test/providers/app_provider_toggle_test.dart — 3/3 pass, unchanged
- flutter test — 1119 pass, 2 fail: plans_sheet_l10n and
  voice_recorder_mic_contention, both of which fail identically on d60ab7a with
  this work stashed. An earlier run also showed voice_recorder_test failing; it
  passes in isolation twice and did not recur on a second full run, so it is
  order-dependent and unrelated
- dart analyze — no new issues (two pre-existing avoid_print infos remain)
- dart format --line-length 120 — applied

I could not run the app: no macOS and no Android SDK here, so this is proven by
test rather than on a device.

FC-superseded-connection-mutates-live-session describes this exactly — a retired
operation writing state its replacement owns — but declaring it would be the
third declaration in the 90-day window, tripping the guard-artifact ratchet on a
class with no canonical_prevention_artifact. Clearing that needs a registry edit,
which an instance fix must not make. Declared none and raised it in the PR body
instead.

Failure-Class: none

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 2 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread app/lib/providers/app_provider.dart Outdated
Review catch from cubic-dev-ai on BasedHardware#11297, and it is right. Both this branch and
the code it replaces identified a queued search by its query text. The text is
not what makes a search unique: narrowing by Category, Rating, Capability or
Apps while the same word is still loading changes the results completely and
changes the text not at all.

So `applyFilters()` during an in-flight request set no new pending text, the
drain loop saw nothing new to run, and the unfiltered results were published as
though they answered the filtered search the user had just asked for. The
replacement never ran. This predates the branch — the original
`_pendingSearchQuery != queryBeingSearched` check had the same blind spot — but
it is in the code this PR is already rewriting, so it is fixed here rather than
left behind.

Replaces the text comparison with a monotonically increasing revision, bumped on
every statement of search intent. `_runOneSearch` now also reads the query and
filters immediately before issuing its request, so what a request asked for
cannot drift from what it is judged against.

Clearing the box takes a revision too, for the same reason: without one, a
request already in flight still counted as current and landed its results on top
of the cleared list. `_runOneSearch` returns early when the box is empty and no
filter narrows it, so superseding a search that way costs no request.

Verification:
- flutter test test/providers/ — 167 pass. The two new tests fail on the parent
  commit: the filter-change case makes only 1 request instead of 2, and the
  clear case lands 'Late Result' on an emptied list
- flutter test — 1121 pass, 2 fail: plans_sheet_l10n and
  voice_recorder_mic_contention, both identical on d60ab7a with this stashed
- dart analyze — no new issues; two pre-existing avoid_print infos remain
- dart format --line-length 120 — no changes

Still not run on a device: no macOS and no Android SDK here.

Failure-Class: none

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@mikejsmith1985

Copy link
Copy Markdown
Author

@cubic-dev-ai this is a real bug and it is now fixed in c23b235.

You were right that queue identity was keyed only on query text. Worth adding that it is not new to this branch — the code being replaced used the same _pendingSearchQuery != queryBeingSearched check, so applyFilters() during an in-flight request was already being dropped. My refactor carried the blind spot forward rather than introducing it. Since it is in the path this PR is rewriting anyway, it is fixed here in its own commit rather than left for later.

What was wrong: narrowing by Category, Rating, Capability or Apps while the same word is still loading changes the results completely and changes the query text not at all. The drain loop saw no new text, concluded there was nothing further to run, and published the unfiltered results as the answer to the filtered search.

The fix, as you suggested — a monotonically increasing revision bumped on every statement of search intent, rather than a text comparison. _runOneSearch also now reads the query and filters immediately before issuing its request, so what a request asked for cannot drift from what it is later judged against.

One case your comment did not name, which the same root cause produces: clearing the search box. That sets no new pending text either, so a request already in flight still counted as current and landed its results on top of the emptied list. It takes a revision now too, and _runOneSearch returns early when the box is empty with no filter narrowing it, so superseding a search that way costs no request.

Regression tests (app_provider_search_flash_test.dart), both failing on the parent commit:

  • filter change mid-flight — 1 request made instead of 2, and the unfiltered results published
  • clear mid-flight — Late Result lands on an emptied list

flutter test test/providers/ — 167 pass. Full suite 1121 pass, 2 fail (plans_sheet_l10n, voice_recorder_mic_contention), both identical on d60ab7a1c with this work stashed. pr_preflight --lane local — 11/11.

Still not verified on a device: no macOS and no Android SDK on this machine, so this is proven by test rather than by use.

@cubic-dev-ai

cubic-dev-ai Bot commented Aug 9, 2026

Copy link
Copy Markdown

@cubic-dev-ai this is a real bug and it is now fixed in c23b235.

You were right that queue identity was keyed only on query text. Worth adding that it is not new to this branch — the code being replaced used the same _pendingSearchQuery != queryBeingSearched check, so applyFilters() during an in-flight request was already being dropped. My refactor carried the blind spot forward rather than introducing it. Since it is in the path this PR is rewriting anyway, it is fixed here in its own commit rather than left for later.

What was wrong: narrowing by Category, Rating, Capability or Apps while the same word is still loading changes the results completely and changes the query text not at all. The drain loop saw no new text, concluded there was nothing further to run, and published the unfiltered results as the answer to the filtered search.
...

@mikejsmith1985 Looks right to me. I went through the final diff and the revision approach holds together the way you describe.

The key move — ++_latestSearchRevision happening on every statement of intent before the _isDrainingSearchQueue re-entrancy check — is what makes the drain loop correct. A follow-up call during a run still bumps the revision and then early-returns, so the running loop sees revisionBeingSearched != _latestSearchRevision and picks up the newer intent. That's what drives the requestCount == 2 assertion in the filter-change test.

Your two extensions check out against the code:

  • Filter change mid-flight. Because _runOneSearch re-reads searchQuery and the filter helpers right before issuing the request, the second request genuinely carries category: 'productivity' — the test's categoriesRequested.last assertion matches what the drain loop actually runs. Good catch that the old _pendingSearchQuery != queryBeingSearched had the same blind spot; the revision fixes both the branch and its predecessor.

  • Clearing the box. The empty-path bump in searchApps plus the early-return guard in _runOneSearch (empty && !_hasServerSideFilters) is the right pairing — clears supersede an in-flight request without spending a request. The clear-mid-flight test's requestCount == 1 expectation is the honest proof of that.

One thing I can't verify from this slice: whether filterApps()/searchApps notify on the cleared path, since the diff doesn't show a notifyListeners() there. Not a blocker — the screen settles out via the drain loop's finally — but worth a glance if the empty state ever fails to repaint immediately.

Nothing here contradicts your read. The regression tests fail on the parent for the reasons you stated, and the seam is a faithful extension of the existing override pattern. No device run is noted and the failure-class rationale is sound.

Tip: get faster answers by chatting with cubic’s review copilot in the review UI

@mikejsmith1985

Copy link
Copy Markdown
Author

Thanks for the re-review. On the one thing you couldn't verify from the slice — it does notify, and immediately.

searchApps has always called notifyListeners() on the cleared path; it just isn't in the diff because it is an unchanged line. app/lib/providers/app_provider.dart:230-243:

if (query.trim().isEmpty && !_hasServerSideFilters()) {
  _latestSearchRevision++;   // <- the only line this PR adds here
  searchResults = [];
  isSearching = false;
  filterApps();
  notifyListeners();         // <- pre-existing
  return;
}

So the empty state repaints synchronously, before the drain loop's finally is reached. The finally is a second notification for the same settled state rather than the first one.

For completeness on the other half of your question: filterApps() (:374) does not notify on its own — it is a pure recompute and every caller notifies afterwards. That is why the pairing above is filterApps(); notifyListeners(); rather than one or the other, and the convention is unchanged by this PR.

@Git-on-my-level Git-on-my-level added positive-signal Good PR — positive signal, not a formal approval flutter flutter work labels Aug 9, 2026
@Git-on-my-level

Copy link
Copy Markdown
Collaborator

Thanks for this — the diagnosis is sharp and the fix targets the actual root cause.

What I verified:

  • app/lib/providers/app_provider.dart — The dual-purpose isSearching (UI flag + re-entrancy guard) is correctly split into isSearching (UI only) and _isDrainingSearchQueue (re-entrancy). The drain loop holds isSearching true across the whole chain, so the apps screen can no longer render "No apps found" between a superseded request and its replacement. The finally block always resets _isDrainingSearchQueue, so there's no stuck-searching risk on exception paths.
  • The revision-based identity (_latestSearchRevision, commit 2) is a real improvement over the text comparison it replaces: narrowing by Category/Rating/Capability/Apps while the same query text is loading changes results completely but changes the text not at all. The original _pendingSearchQuery != queryBeingSearched check had that same blind spot, so fixing it here rather than deferring is the right call. Clearing the empty box now takes a revision bump too, and _runOneSearch early-returns for empty+no-filters, so a superseding clear costs no request.
  • The AppsSearchRequest typedef matches retrieveAppsSearch (app/lib/backend/http/api/apps.dart) exactly, so searchAppsOverride ?? retrieveAppsSearch is type-correct and the @visibleForTesting seam mirrors the existing enableAppOverride pattern.
  • The extracted filter helpers preserve the original null-vs-true semantics (e.g. _isAppsFilter returns true or null, never false), so the mutually-exclusive my_apps / installed_apps flags are unchanged.
  • The unawaited recursive performServerSearch() in the old finally block is gone — good; the drain loop makes it unnecessary.

app/test/providers/app_provider_search_flash_test.dart — 5 cases covering the flash, newest-query-wins-when-late, filter-change-queues-replacement, clear-discards-in-flight, and single-search-settles. The filter-change case is the one that would have silently broken under the text-only identity, so it's the right thing to pin down.

One thing I can't verify from here: there's no CI check data on the PR yet, and the queueing is only reachable through the test seam. A maintainer with the app running should confirm the behavior on-device (particularly the applyFilters path during an in-flight search). The logic is sound on static review.

Positive signal — not formally approving because there's no CI signal to confirm the build yet. Appreciate the honesty in the description about the two pre-existing test failures and not being able to run on a device.


by AI on behalf of David — if you need David’s attention urgently, please @Git-on-my-level and escalate with need human response.

@undivisible undivisible added human Human-authored pull request app mobile labels Aug 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

app flutter flutter work human Human-authored pull request mobile positive-signal Good PR — positive signal, not a formal approval

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants