Closes the "fails once, stays broken forever" behavior (issue #1245): when a screen failed to load (dead zone, server restart), nothing ever retried — and the cache-first screens masked the failure as an empty account.
What changed
Screen-level recovery (the missing wire)
New connectivity/Recovery.kt: NetworkStatusController.recoveries() — per-collector flow of down→Healthy transitions (StateFlow replay dropped, so an already-healthy subscribe never double-loads). Screen-level half of the idiom SyncController / MutationReplayer / DiagnosticsUploader already use.
Every screen ViewModel now re-runs its load on recovery: Home, Playlists, Liked, History, Hidden, Requests, Search, Discover, Album/Artist/Playlist detail, Admin Requests/Users/Quarantine/Invites. Cache-first VMs refresh unconditionally (silent Room write); direct-load VMs only from Error (no spinner flash over good content).
Failure no longer masquerades as emptiness
Home / Playlists / Liked track refresh failure in a refreshError StateFlow folded into their state combine; Library reads new SyncController.lastSyncError. Empty cache + failed load → "Couldn't load…" with Retry instead of "Welcome to Minstrel" / "No playlists yet". Failed refresh over a populated cache stays silent (stale content beats a full-screen error; the connection banner tells the story).
Stuck corners unstuck
Requests: 12s poll also retries from UiState.Error (was structurally unable to escape it — predicate required a Success state). Also covers non-transport failures (e.g. a 500) that health monitoring never sees.
Search: retry() bypasses the distinctUntilChanged query pipeline so re-running the same query after a transient failure works; error state now has a Retry button.
ArtistDetail: secondary sections (similar artists / top tracks) re-fetch on recovery instead of staying silently absent.
Retry affordance normalized (rule 24)
ErrorRetry gains optional title + LazyColumn wrapper so pull-to-refresh works on error states (same rationale as EmptyState); adopted on every error branch. PlaylistDetail's private one-shot ErrorBlock deleted in its favor.
Verification
CI green on head 7628330f (ktlint, detekt, 99 unit tests, assemble).
LibraryViewModelTest updated: lastSyncError stubbed with a real MutableStateFlow (a relaxed-mock flow never emits, which kept the state combine from firing).
Closes the "fails once, stays broken forever" behavior (issue #1245): when a screen failed to load (dead zone, server restart), nothing ever retried — and the cache-first screens masked the failure as an empty account.
## What changed
**Screen-level recovery (the missing wire)**
- New `connectivity/Recovery.kt`: `NetworkStatusController.recoveries()` — per-collector flow of down→Healthy transitions (StateFlow replay dropped, so an already-healthy subscribe never double-loads). Screen-level half of the idiom SyncController / MutationReplayer / DiagnosticsUploader already use.
- Every screen ViewModel now re-runs its load on recovery: Home, Playlists, Liked, History, Hidden, Requests, Search, Discover, Album/Artist/Playlist detail, Admin Requests/Users/Quarantine/Invites. Cache-first VMs refresh unconditionally (silent Room write); direct-load VMs only from Error (no spinner flash over good content).
**Failure no longer masquerades as emptiness**
- Home / Playlists / Liked track refresh failure in a `refreshError` StateFlow folded into their state combine; Library reads new `SyncController.lastSyncError`. Empty cache + failed load → "Couldn't load…" with Retry instead of "Welcome to Minstrel" / "No playlists yet". Failed refresh over a populated cache stays silent (stale content beats a full-screen error; the connection banner tells the story).
**Stuck corners unstuck**
- Requests: 12s poll also retries from `UiState.Error` (was structurally unable to escape it — predicate required a Success state). Also covers non-transport failures (e.g. a 500) that health monitoring never sees.
- Search: `retry()` bypasses the `distinctUntilChanged` query pipeline so re-running the same query after a transient failure works; error state now has a Retry button.
- ArtistDetail: secondary sections (similar artists / top tracks) re-fetch on recovery instead of staying silently absent.
**Retry affordance normalized (rule 24)**
- `ErrorRetry` gains optional title + LazyColumn wrapper so pull-to-refresh works on error states (same rationale as EmptyState); adopted on every error branch. PlaylistDetail's private one-shot `ErrorBlock` deleted in its favor.
## Verification
- CI green on head `7628330f` (ktlint, detekt, 99 unit tests, assemble).
- `LibraryViewModelTest` updated: `lastSyncError` stubbed with a real `MutableStateFlow` (a relaxed-mock flow never emits, which kept the state combine from firing).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01TsF3cNoKrqCYsU78cXC8U6
Failed loads used to stay failed forever: no screen ViewModel listened
to server-health recovery, and the cache-first screens swallowed refresh
errors so a cold load against a down server looked like an empty account.
- connectivity/Recovery.kt: recoveries() — per-collector flow of
down→Healthy transitions; the screen-level half of the idiom
SyncController/MutationReplayer/DiagnosticsUploader already use.
- Every screen VM now re-runs its load on recovery (cache-first screens
unconditionally; direct-load screens when sitting in Error).
- Cache-first error surfacing: Home / Playlists / Liked track refresh
failure; Library reads SyncController.lastSyncError (new) — empty
cache + failed refresh now renders Error-with-Retry, not welcome copy.
- Requests: 12s poll also retries from Error (was structurally unable
to escape it — the in-flight predicate required a Success state).
- Search: retry() bypasses the distinctUntilChanged query pipeline so a
same-text resubmit after a transient failure actually re-runs.
- ArtistDetail: secondary sections (similar artists / top tracks)
re-fetch on recovery instead of staying silently absent.
- ErrorRetry: LazyColumn wrapper (pull-to-refresh works on error states,
same rationale as EmptyState) + optional title; adopted on every error
branch; PlaylistDetail's one-shot ErrorBlock removed in its favor.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TsF3cNoKrqCYsU78cXC8U6
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Closes the "fails once, stays broken forever" behavior (issue #1245): when a screen failed to load (dead zone, server restart), nothing ever retried — and the cache-first screens masked the failure as an empty account.
What changed
Screen-level recovery (the missing wire)
connectivity/Recovery.kt:NetworkStatusController.recoveries()— per-collector flow of down→Healthy transitions (StateFlow replay dropped, so an already-healthy subscribe never double-loads). Screen-level half of the idiom SyncController / MutationReplayer / DiagnosticsUploader already use.Failure no longer masquerades as emptiness
refreshErrorStateFlow folded into their state combine; Library reads newSyncController.lastSyncError. Empty cache + failed load → "Couldn't load…" with Retry instead of "Welcome to Minstrel" / "No playlists yet". Failed refresh over a populated cache stays silent (stale content beats a full-screen error; the connection banner tells the story).Stuck corners unstuck
UiState.Error(was structurally unable to escape it — predicate required a Success state). Also covers non-transport failures (e.g. a 500) that health monitoring never sees.retry()bypasses thedistinctUntilChangedquery pipeline so re-running the same query after a transient failure works; error state now has a Retry button.Retry affordance normalized (rule 24)
ErrorRetrygains optional title + LazyColumn wrapper so pull-to-refresh works on error states (same rationale as EmptyState); adopted on every error branch. PlaylistDetail's private one-shotErrorBlockdeleted in its favor.Verification
7628330f(ktlint, detekt, 99 unit tests, assemble).LibraryViewModelTestupdated:lastSyncErrorstubbed with a realMutableStateFlow(a relaxed-mock flow never emits, which kept the state combine from firing).🤖 Generated with Claude Code
https://claude.ai/code/session_01TsF3cNoKrqCYsU78cXC8U6