Files
buzz/mobile/test/features/search
Logan Johnson 2e4aacc9e0 fix(mobile): find open channels in search and stop the stuck spinner (#8764)
## Summary

Mobile search could not find an open channel you had not joined. In the
Channels tab it could also show an empty list with a spinner that never
stopped. This change fixes both and makes mobile rank channel results
the way desktop (buzz-app) does.

**Open channels you have not joined.** Mobile ranked channels from the
loaded channel list, which holds open channels only after Browse
channels has loaded the directory. Desktop search also looks up public
channels (`usePublicChannelSearch`). Mobile now loads the open-channel
directory when a search starts
(`ChannelsNotifier.ensureDirectoryLoaded(maxAge:)`). It reads the
directory again when it is older than 30 s, as buzz-app's
`PUBLIC_CHANNEL_PAGE_TTL` does. Results are ranked again whenever the
channel list or the directory status changes. Unjoined channels already
render with the "Open" badge.

**Directory failure.** If the directory load fails, search shows "Open
channel search couldn't finish." with a Retry channels button, as
buzz-app does, instead of "No results". Screen readers announce the
notice (live region). The next query also retries.

**Stuck spinner.** The message, people and channel lookups shared one
`isLoading` flag. When no messages and no channels matched and the
people lookup finished last, it set `isLoading = messages.isEmpty &&
channels.isEmpty`. That turned the spinner back on, and nothing turned
it off again. Each relay lookup now has its own pending flag. The
spinner shows while any lookup is still running, or while a directory
load is running: one this search started, until its future completes, or
one another caller started in this community. The shared directory
status alone is not enough, because a real refresh marks it loading only
after memberships arrive, and loaded before the new list is installed.
Lookups are fenced by a query generation, so a late reply to an earlier
query, even one with the same text, cannot settle the current search.

**Displaced directory loads.** A membership refresh that starts while
the directory loads retires that load. It used to mark the directory as
failed, so search showed "Open channel search couldn't finish." when
nothing had failed. It now marks the load idle. A refresh that starts
earlier, during the membership round trips, retires the load before it
is marked loading and leaves no status at all. In both cases search
loads the directory again once. If that load is retired too, search
offers Retry instead of looping. A real relay error still shows Retry at
once and is not retried automatically. When a directory refresh starts,
it clears an earlier failure for the same community to idle, so a
failure left from before (for example before a reconnect) cannot be read
as the new attempt's result and skip its retry. The notice follows the
live directory status, so it clears when another caller loads the
directory.

**Directory races.** A community switch drops the cached directory, but
the directory status still said the earlier community was loaded, so
switching away and back within 30 s skipped the reload.
`ensureDirectoryLoaded` now also requires the cached directory to come
from the current community. A search during the first channel load used
to start a directory refresh that retired the pending load; if that
load's reply came last, its stale failure replaced the list.
`retryDirectory` now waits for a pending first load, as `fetchFresh`
does. Both came from the Codex security review of this branch.

**Only the current community's list.** While a community switch reloads
the channel list, Riverpod keeps the previous community's list as the
loading value. Search now ranks only installed data (`asData`), so the
old community's channels cannot match. (The signing identity derives
from the community config, so the existing config watch already resets
search on an identity change.)

The idle marking and the `asData` read come from Wes's
https://github.com/block/buzz/pull/8869, which fixed the same bug in
parallel.

**Same ranking as desktop.** `name_match.dart` ports buzz-app's
`matchName` (`src/features/search/match.ts`). Its ranks, best first:
- exact
- prefix
- word start
- substring
- word runs (`tlp` finds `team-launch-planning`; `buzz community` finds
`buzz-community`)
- letters in order

`SearchNotifier` orders results like buzz-app's `SearchResults.tsx`:
- Joined and open channels share one ranking, and joined channels win
ties. Open channels with the same rank are ordered by name, then id,
like buzz-app's `searchPublic`.
- An archived channel ranks half a step behind a live one and stays
findable only by its members.
- A bare `#` lists joined channels only. Like desktop, it does not load
the open-channel directory, so it shows no directory spinner and no
failure notice.
- The list shows the top 8.

A filter tab with none of its own results now shows "No results" instead
of a blank list.

Plain substring matching on descriptions is removed; buzz-app does not
match descriptions.

**Differences from desktop:**
- Desktop also orders by the viewer's past search choices. Mobile has no
such history yet.
- Desktop shows Solo chats and DMs in their own groups. Mobile keeps
Solo channels in its Channels list.
- `from:`, `in:`, `since:` and `until:` operators and desktop's people
ranking are not ported. Mobile still sends the raw query to the relay.
These can be a separate PR.
- Lowercasing differs for a few letters: JS lowercases `İ` to `i` plus
U+0307 and Dart does not, so `İstanbul` / `istanbul` ranks exact on
mobile and word runs on desktop.
- Desktop ranks channels at each keystroke. Mobile still waits for the
300 ms debounce, so channel rows clear while typing.
- Open channels that tie in rank are ordered with Dart's case-sensitive
`compareTo`; buzz-app uses `localeCompare`. Only names with capital
letters can order differently.
- A directory refresh runs the full channel-list reload
(`retryDirectory`), not only the directory read. With the 30 s TTL this
happens at most once per 30 s of searching.

### Related issue

Reported in Buzz #buzz-bugs: a search for `4things` on mobile found
nothing. No GitHub issue. No duplicate open PRs found.

### Testing

- `search_channels_journey_test.dart` drives the real `SearchNotifier`
through `SearchPage` with held relay lookups. It covers:
- an unjoined open channel that appears when the directory arrives, with
the "Open" badge, ranked above a joined channel with a weaker match
  - the spinner stopping when the people lookup finishes last
  - a letters-in-order query
  - a query with no matches that shows "No results"
- a clear and retype of the same text, where late replies do not stop
the spinner
  - the Channels tab showing "No results" when only People matched
- a bare `#` whose directory finishes last, then failed directory loads
that show the notice instead of "No results", then Retry channels
finding the open channel
- a community switch during a directory load, where the retired load
does not settle the new search
- A third widget test drives the real `ensureDirectoryLoaded` →
`retryDirectory` path, with the membership query held at the relay
boundary. The spinner stays on while that query is held. A failure shows
the notice, and another caller's directory load also keeps the spinner
on.
- The journey fails on main's `search_provider.dart`. It also fails in
each of these mutations:
  - the old loading rule restored
  - the generation fence replaced by the old query-text check
  - membership sorted before match rank
- `name_match_test.dart` checks 20 name/query pairs. Their expected
ranks come from running buzz-app's `matchRank` on the same inputs.
- `search_channel_rank_test.dart` is a table test of
`rankSearchChannels`: joined-wins ties, open ties by name, archived
ordering and membership, private and DM filtering, `#` handling, and the
8-row cutoff.
- `channels_provider_test.dart` checks that `ensureDirectoryLoaded`
reads again only once the directory is older than `maxAge`.
- 17 mutations of the review fixes each fail a test: the archived
filter, the archived penalty, the 8-row limit, joined ties, open ties,
`#` handling (×2), the failure flag, the failure flag while loading,
search-owned pending, the load fence, the rebuild reset, the scoped
status spinner, the search TTL, the TTL check, filter-aware "No
results", and Retry.
- Displaced loads (`efc17a5d4`, `22e52d687`): the journey retires a load
before it is marked loading, then displaces the retry after (one retry,
then Retry channels with no "No results"). It also checks that the
notice clears when another caller loads the directory, and that it is
not shown during the first debounce. A displaced Retry recovers. A
community switch whose held list reload must match neither the old
community's `general` channel nor its name on a message hit. The
production-path test holds the real refresh's membership query, runs
`refresh()`, and asserts the one retry. `channels_provider_test.dart`
expects a displaced load to end idle. Each of 8 mutations fails a test:
no retry, retry only on idle, retry on a relay error, notice only on
error, no attempted flag, `.value` in ranking, `.value` in message
names, and `markError` on displacement.
- Review fixes (`29c74e8c5`, `fe4276538`): both displaced-load journeys
now start from an earlier same-scope failure (asserted) instead of an
artificial idle reset. The notice's live region is asserted. A bare `#`
after a failure, and while another caller loads the directory, makes no
directory request, shows no spinner and no notice. Each of 5 mutations
fails a test: no error reset in `beginRefresh`, no live region, a
directory load for `#`, the notice for `#`, and the spinner for `#`.
- Directory races (`c73b028ac`): `channels_provider_test.dart` switches
A→B→A and expects a new directory read, and starts a directory load
while the first load's member metadata is held, releases it last, and
expects the list with the open channel and no error. Both tests fail on
`fe4276538`, and each fails again when its fix is removed.
- Review hardening (`85042b1a4`): `retryDirectory` clears a same-scope
error before its first await. A test reads `idle` synchronously after
the call; it failed before the fix.
- `just mobile-check`, `just mobile-test` and `just file-size-check`
pass at `85042b1a4` on `origin/main` `b80217de3` (4052 tests, 32
skipped).
- Carl's re-review of `c73b028ac` (via Wes's account): no blockers. Its
optional hardening note is done in `85042b1a4`. The 8-row cap is still
Wes's product call.
- Wes's review (Carl) of `22e52d687`: the blocker (a stale same-scope
error skipped the retry) is fixed in `29c74e8c5`. The optional `#` and
live-region items are done. The 8-row cap is left for the PR author's
product call. The in-app run below is from `3b5521c51`.
- Agent review of `efc17a5d4`: Cerveau found no must-fix or should-fix.
Auto found one should-fix (a load retired before `markLoading` got no
retry and no notice), fixed in `22e52d687` with its optional
notice-liveness and A→B→A items. Cerveau's optional coverage items are
also done there.
- Not covered by a test yet (follow-up): the empty state on the Messages
and People tabs when only channels matched.
- `just mobile-check` and `just mobile-test` pass at `3b5521c51` on
`origin/main` `0c82435ae` (4008 tests, 28 skipped).
- Agent review: Auto and Cerveau reviewed `b8a268e2c` independently.
Every must-fix and should-fix is fixed in `28a0990e5`. Cerveau confirmed
both of its must-fix items at that head. It then found one regression in
my fix, a spinner gap during the real refresh, and that is fixed in
`3b5521c51`. Cerveau re-reviewed `28a0990e5..3b5521c51`, found nothing
new, and confirmed that all three of its blockers are closed. Its own
full mobile suite run at `3b5521c51`: 4008 passed, 28 skipped. Auto
re-checked `b8a268e2c..3b5521c51` and confirmed that all four of its
should-fix items are closed and nothing new is must-fix. Auto's optional
findings on `İ`, per-keystroke ranking and reload cost are listed under
differences above.

#### In the app (Android emulator, debug build of `3b5521c51`)

No Xcode was available, so this ran on the Android emulator against a
local relay. The viewer has joined `general` and `team-4things`. Another
user owns the open channels `4things` and `retro2108`.


https://github.com/user-attachments/assets/f54700c6-c34c-4ded-b7c7-c7e6e07aa43b

1. Channels tab, `4things`: the spinner shows while the open-channel
list loads, then `4things` appears with the Open badge, above the joined
`team-4things`, and the spinner stops.
2. `#`: the joined channels are listed, and the spinner stops.
3. Channels tab, `morty` (a person, no channel name): "No results for
'morty'". The All tab shows Morty under People.
4. The phone is cut off from the relay, and `retro2108` is created
meanwhile. Searching for `retro2108` more than 30 s after the last load
shows "Open channel search couldn't finish." with Retry channels. After
the relay is reachable again, Retry channels finds `retro2108` with the
Open badge.

The local `ws://` relay needed two demo-only edits to the add-community
checks, which are not part of this PR.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Wes <wesbillman@users.noreply.github.com>
2026-11-01 08:28:58 -07:00
..