Skip to content

Home pull-to-refresh: inbox unread count walks every thread of every list (~25s) #9

Description

@ccleberg

Summary

Pull-to-refresh on the Home tab blocks for ~25s because the inbox unread count
paginates every thread of every subscribed list. The recommended fix is to take
the inbox count off the pull-to-refresh critical path so the spinner returns in
~2s and the badge updates in the background.

Symptom

Pull-to-refresh on the Home tab spins for ~25s. Not a hang — real work.

Measurement

A per-branch timing probe in HomeViewModel.loadDashboard (removed after
diagnosis), one pull-to-refresh:

refresh branch systemStatus:    1.05s
refresh branch projects:        1.61s
refresh branch jobs:            1.64s
refresh branch assignedTickets: 1.68s
refresh branch inboxUnread:    25–28s   ← the entire cost

.refreshable binds the spinner to loadDashboard returning, and its tail
awaits the branches sequentially, so the slowest (inbox) sets the wall-clock.

Root cause

fetchUnreadThreadSnapshot(for:) in HomeViewModel paginates every thread
page of every subscribed list to the end
— it only breaks when the cursor runs
out. It walks the whole list on purpose: sr.ht orders threads by creation time
(thread.updated = root insert time, per the MailingListActivity trap), so a
recently-active but old thread sits deep in the list and a naive early-break
would miss it.

Why "skip quiet lists" is unsafe (verified)

The idea was: the per-list activity feed is already loaded; if a list has no
activity since the read baseline, skip its pagination. Verification against
InboxReadStateStore and MailingListActivityLoader shows two independent
holes:

  1. Manual mark-unread. InboxReadStateStore.markUnread (a live feature —
    Home, RootView, LookupView, ProjectMailingListView) sets
    lastViewedAt = distantPast, so isUnread returns true forever regardless of
    activity. Such a thread can be old and quiet; skipping the list drops it from
    the count. The activity feed cannot see it — there has been no new mail.
  2. Failure vs quiet ambiguity. MailingListActivityLoader.load swallows
    errors and returns an empty activity map, indistinguishable from a genuinely
    quiet list. Skipping on empty would undercount on any transient fetch failure.

A safe skip would require three guards, per list:

  1. load() must distinguish success-empty from failure (change its return).
  2. No activity since baseline.
  3. No stored unread marker for the list — no "<rid>#…" threadID at
    distantPast in the read-state dict.

Recommended fix

Unblock the spinner — take inbox off the pull-to-refresh critical path so
pull returns in ~2s and the badge updates in the background. Zero correctness
risk; the work still runs, just not blocking. Also touches the
needs-attention / widget snapshot persistence that reads the count.

Note: not caused by the v3.8.1 forceRefresh fix

That fix added projects (1.6s) and systemStatus (1.0s) to the forced set —
both near-instant. Inbox was already forced on pull before it.

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions