bug-email-tab-shows-few-threads.md7.9 KBView on GitHub # Bug: the Email tab keeps filtering by a search that is no longer on screen
Reported for <email> on staging, 2026-09-08. Two independent defects, both in
`apps/mail`, both found in the same investigation.
## Summary
Isabelle's `/mail` Email tab showed "a page full of emails, but only 2 from the past week", while
the same screen's All-channels badge showed her whole inbox. The Email list was not missing mail —
it was still filtered by a search for `thiago` she had run three hours earlier, with no search box
on screen to say so. `SearchInput` holds the only writer that clears the search value, and mail.tsx
mounts it only while the search is "active"; the value lives in a module-singleton Zustand store
that outlives the input. The unified feed never reads the search at all, which is why switching to
"All channels" appeared to fix it — the two lists were answering different questions.
A second, unrelated defect surfaced in the same logs: her "Starred" split returns zero rows on
every read and is pinned on "You've reached inbox zero here :)" forever.
## Symptom & impact
- **Observed**: Email tab shows a full page of older mail with almost nothing from the past week.
"All channels" shows the real inbox.
- **Expected**: the channel badge picks channels; it does not change which filter is applied.
- **Who/scope**: <email> (user `ZQLxbHFaT4tqVAbVHWgh1BtPKTBLloTE`, connection
`ecffb0c4…`) on staging; the mechanism is not account-specific — any user who searches and then
navigates is exposed.
- **Severity**: silent wrong result — her mail is filtered away with no error and no visible filter.
---
## Bug 1 — the search filter outlives its input
### Root cause
[search-input.tsx](../components/ui/search-input.tsx) owned the `?search=` → search-slice mirror.
Its `else` branch — `setSearchState({ value: '', highlight: '' })` — was the ONLY writer that ever
cleared the value `useThreads` filters by
([use-threads.ts](../modules/threads/threadList/hooks/use-threads.ts): `isSearching` →
`effectiveQuery = withUnreadOnly(userSearch, unreadOnly)`). mail.tsx renders that input only while
`isSearchActive = isSearchOpen || !!searchQuery` ([mail.tsx:212](../modules/threads/mail.tsx#L212)).
`isSearchOpen` is component state that resets to `false` on remount, and `?search=` is dropped by
any navigation that rewrites the URL. When both go, the input unmounts **in the same commit** as
the param change, so its effect never runs for the new value — and `useCedarStore` is a module
singleton that survives the route. The list stays narrowed forever.
### Code walkthrough
1. `search-input.tsx` `handleSearch` → `setSearchQuery('thiago')` writes `?search=thiago`; the
sync effect compiles it into the store:
```json
{ "value": "from:thiago OR to:thiago OR cc:thiago OR subject:thiago OR thiago",
"highlight": "thiago" }
```
2. She navigates away and back. `?search=` is gone, `isSearchOpen` is `false`, so
[mail.tsx:353](../modules/threads/mail.tsx#L353) renders no `SearchInput` — nothing clears the
store.
3. [use-threads.ts](../modules/threads/threadList/hooks/use-threads.ts) still reads it:
`isSearching = !explicitInbox && userSearch.length > 0` → `true`, so `compiledQuery` is dropped
and the Gmail search is sent instead. Real staging payload:
```json
{ "surface": "mail-list",
"compiledQuery": "from:thiago OR to:thiago OR cc:thiago OR subject:thiago OR thiago",
"fellBackToGmailFirstList": true, "gmailThreadCount": 25 }
```
4. [use-inbox-items.ts](../modules/inbox/hooks/use-inbox-items.ts) never reads `useSearchValue()`,
so the All-channels feed asks `label:INBOX` and looks healthy — the divergence the user saw.
### Evidence: three hours of a search nobody was looking at
`/aws/ecs/aws-staging-api/api-service`, `message = "mail.listThreads"`, her userId, 2026-09-08 UTC:
```
16:01:19 mail-list Inbox label:INBOX cur 0 ← she opens the Email tab
16:01:21 mail-list Inbox label:INBOX cur 1
16:01:26 mail-list – SEARCH(thiago) cur 0 ← she searches
16:06:19 … 16:50:18 SEARCH(thiago) ×46, every ~5 min, nothing else
16:49:45–16:50:05 unified-feed label:INBOX ×6 ← she flips to All channels: full inbox
18:04:21 … 18:25:25 SEARCH(thiago) ×6, still alone
18:26:27 onward Inbox and SEARCH(thiago) alternate, both on their own 5-min timers
```
52 reads of the same search over 2h40m. `useThreads`' 5-minute `resetListThreadsToFirstPage`
interval is what re-ran it; a search a human is actually using does not refetch itself for three
hours. The Gmail fallback returned 25 threads each time, of which only ~2 fall in the past week
(her mirror holds 301 threads matching `thiago`, 5 of them from the last 7 days) — exactly
"a page full of emails, but only 2 from the past week".
The server, meanwhile, was blameless: **306 reads of `label:INBOX` on the `mail-list` surface over
7 days, `min(dbThreadCount) = max(dbThreadCount) = 25`.** It never served a short inbox page.
### Fix
[use-search-query-sync.ts](../modules/threads/hooks/use-search-query-sync.ts) — the mirror moves
out of the input and into a hook mail.tsx calls unconditionally, next to the list it filters. The
URL owns the filter; the input only edits the URL.
---
## Bug 2 — an empty page with a cursor reads as an empty inbox
### Root cause
`listThreadsFromDb` returns a SHORT (possibly zero-row) page **plus a usable cursor** when the
over-fetch cap stops the candidate scan —
[`resolveNextPageToken` case 3](../../server/src/services/mail/list/list-threads-from-db.ts#L188),
whose own docstring says *"callers must not read 'short page' as 'end of list'"*. `MailList` read
it as exactly that: with `items.length === 0` it renders the inbox-zero state instead of the
`VList`, so `vListRef.current` is null, so the viewport-fill effect bails at its
`if (!vListRef.current) return;` guard before it can ask for the next page — and there is no list
to scroll, so nothing else asks either.
### Evidence
16 reads of her Starred split, every one identical:
```json
{ "surface": "mail-list", "inboxName": "Starred",
"compiledQuery": "-in:CHAT in:inbox is:starred",
"dbThreadCount": 0, "candidatesScanned": 2000, "truncatedByOverFetch": true }
```
She has **2** starred threads, in an inbox of **9,138** — they sit past the 2,000-candidate cap
(`DEFAULT_OVER_FETCH_CAP`), so page 1 is legitimately empty and page 2 would have found them.
### Fix
[mail-list.tsx](../modules/threads/threadList/components/mail-list.tsx) — an empty list with
`hasNextPage` pages ahead of the `vListRef` guard, and renders the spinner rather than announcing
inbox zero while more pages are still to come. The auto-load budget (8) still bounds it.
---
## Evidence index
- **Environment**: staging (`cedar-staging` / `/aws/ecs/aws-staging-api/api-service`). Prod and
staging share one Supabase database.
- **trace_ids**: `50b47eb197dff06ed73cc020bea689fc`, `c8eef299e2cc2741f02c17b4e69cafe8`
(mail-list, 25 rows); `de43bacb43d2a60deaddd8eef84316a2` (unified-feed, 50 rows);
`d23c76bb997cd1595c6bd22e44836d03` (Starred, 0 rows, truncated).
- The structured `mail.listThreads` log is `console.log(createStructuredLog(…))`, so it lands in
**CloudWatch**, not Axiom — Axiom carries only this route's OTel spans, whose attributes are
reachable as `tostring(['attributes.custom']["userId"])`.
## Tests
- [searchFilterLifetime.test.tsx](../tests/modules/threads/searchFilterLifetime.test.tsx) — the
filter is cleared when `?search=` is absent, with no search box on screen.
- [emptyPageKeepsPaging.test.tsx](../tests/modules/threads/emptyPageKeepsPaging.test.tsx) — an
empty page with a cursor pages on and does not announce inbox zero.
## Still open
`useInboxItems` ignores the search value entirely, so a search typed while the All-channels badge
is selected does nothing. That is the other half of "the two lists answer different questions" and
is a deliberate feature gap, not part of this fix.