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:
- 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.
- 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:
load() must distinguish success-empty from failure (change its return).
- No activity since baseline.
- 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.
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 afterdiagnosis), one pull-to-refresh:
.refreshablebinds the spinner toloadDashboardreturning, and its tailawaits the branches sequentially, so the slowest (inbox) sets the wall-clock.
Root cause
fetchUnreadThreadSnapshot(for:)inHomeViewModelpaginates every threadpage 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 theMailingListActivitytrap), so arecently-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
InboxReadStateStoreandMailingListActivityLoadershows two independentholes:
InboxReadStateStore.markUnread(a live feature —Home, RootView, LookupView, ProjectMailingListView) sets
lastViewedAt = distantPast, soisUnreadreturns true forever regardless ofactivity. 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.
MailingListActivityLoader.loadswallowserrors 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:
load()must distinguish success-empty from failure (change its return)."<rid>#…"threadID atdistantPastin 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) andsystemStatus(1.0s) to the forced set —both near-instant. Inbox was already forced on pull before it.