12 KiB
WORK-009 — Offline-First Home Screen
Feature: (bug fix — restores INVARIANTS.md: local-first + no UI gating on network) Status: In Progress
Context
The home screen (Latest Releases, App Stacks) stays on skeletons for 1+ minute when offline, even though all data is already in SQLite.
First pass — fixed a real bug, but not the one the user saw
Initial diagnosis focused on LatestReleasesNotifier in lib/widgets/latest_releases_container.dart:
- The first-page listener only reacted to
StorageData<Release>.RequestNotifierdelivers local-first data asStorageLoading(localModels)during theawaitingRemotephase (seerequest_notifier.dart_emit:StorageLoadingduringinitializing/awaitingRemote,StorageDataonly after EOSE orresponseTimeout). Local Releases sat unconsumed. - The listener then
awaited_resolveRelated(...)which ran three imperativestorage.query(..., LocalAndRemoteSource(stream: false))calls. Imperativestorage.querywithLocalAndRemoteSourceblocks onRemoteQueryOpbefore returning any local data (seepurplebase_storage.dartline ~254). Each call waited the absolute EOSE timeout.
Both real. Both fixed (see tasks below). Neither was the dominant cause of the user-visible hang.
Second pass — actual root cause
The containers are hard-gated in search_screen.dart:
AppStackContainer(
showSkeleton: !(initState.hasValue || initState.hasError),
),
LatestReleasesContainer(
showSkeleton: !(initState.hasValue || initState.hasError),
...
),
where initState = ref.watch(appInitializationProvider). That provider awaits the full init chain — including _attemptAutoSignIn → onSignInSuccess, which did:
await storage.query(
RequestFilter<ContactList>(authors: {pubkey}).toRequest(),
source: const RemoteSource(relays: 'social', stream: false),
subscriptionPrefix: 'app-contact-list',
);
Offline, RemoteSource(stream: false) blocks on EOSE per relay: up to 5 s connect × per-relay retries × 3 social relays, plus the eoseTimeoutSingleRelay / eoseTimeout windows (30 s / 15 s) from purplebase's PoolConfiguration, plus reconnect backoff. That matches the observed "1+ minute" hang exactly.
While showSkeleton is true, the notifier providers aren't even watched — so the first-pass fix had no user-visible effect because the notifier never ran.
Decision — no model/purplebase primitives needed
LocalAndRemoteSource + the and: callback on query<T>(...) already implement local-first with background relationship resolution (NestedQueryManager._executeNestedQuery fires relationships via _queryBuffer.bufferQuery(...).then(...), never awaited on the emission path). The fix is to stop reinventing this at the widget layer and use the primitive as intended.
See spec/knowledge/ (to be promoted after merge): guidance that multi-hop loads MUST be expressed via and: on an outer reactive query, not via imperative storage.query chains.
Tasks
- 1. Rewrite
LatestReleasesNotifier._subscribeto:- Add
and:on outerquery<Release>(...)that pullsrelease.app(+ nestedapp.author) andrelease.softwareAssets. - React to every state emission (local data carried by both
StorageLoading(models)andStorageData(models)). OnlyStorageErrorshort-circuits. - Compute
appsByIdentifierviastorage.querySync— noawaiton any network path. - Delete
_resolveRelatedentirely.
- Add
- 2. Rewrite
loadMore:- Keep imperative
storage.queryfor older Releases (user-initiated, acceptable to await). - Drop the imperative
_resolveRelated. Fire a non-blocking relationship fetch for the older page (unawaited) — the first-page listener's general-update path will refreshappsByIdentifieras Apps land. - Resolve whatever is already local via
storage.querySyncbefore returning.
- Keep imperative
- 3. Audit other notifiers for the same
if (next is StorageData<T>)footgun.PagedSubscriptionNotifier.updateFirstPage— already OK (copiesfirstPage: nextin the else branch, widgets use.combined/.models).app_stacks_screen.dart,profile_screen.dart,app_detail_screen.dart— reviewed, useStorageLoading && models.isEmptyidiom correctly.updates_service.dartimperativestorage.queryis onLocalSource, so not a network-blocking path.
- 4. Self-review against INVARIANTS.md — clean (UI safety, async discipline, local-first guarantees all upheld).
- 5.
fvm flutter analyzeclean. - 6.
fvm flutter test— existing suite still passes. - 7. Split initialization to decouple local-first UI from network warm-ups.
- Added
storageReadyProvider(lib/main.dart): returns as soon as SQLite + worker isolate are ready. Zero network dependencies. appInitializationProvidernowawait ref.read(storageReadyProvider.future)first, then continues with device capabilities, package sync, deep links, auto-sign-in.search_screen.dartnow gates the skeleton onstorageReadyProviderinstead ofappInitializationProvider. Other consumers ofappInitializationProvider(updates polling inupdates_service.dart; error overlay inmain.dart) keep the original gate — they legitimately want the full init done.
- Added
- 8. De-block
onSignInSuccess— the contact-list fetch is nowunawaited+catchError. It was a cache warm-up, never a gate. Consumers ofContactListread it reactively via storage queries and re-render when it lands.- Signature changed from
Future<void>→void. Call sites updated (main.dart,widgets/sign_in_button.dart).
- Signature changed from
- 9. Updates screen: decouple display from polling (
lib/services/updates_service.dart).- Added
UpdatePollerState.hasHydrated. The categorizer now gates onhasHydratedinstead oflastCheckTime == null. _initswitched fromappInitializationProvidertostorageReadyProvider. On storage-ready, the poller runsrefreshFromLocal()first (local-only, offline-safe) which flipshasHydrated: true. UI unblocks immediately._startPollingnow fires the remotecheckNow()asunawaited— polling is purely background; it no longer gates the render. Offline, the "Checking for updates..." indicator spins at the top, but the list of apps renders from local data.refreshFromLocal()now always setshasHydrated: true, even when installed set is empty or a local read fails, so the UI never gets stuck on skeleton.
- Added
- 10. Infinite scroll offline for Latest Releases and App Stacks.
LatestReleasesNotifier.loadMoreandStacksNotifier.fetchOlderPagenow read local viastorage.querySyncfirst. If local has results, those are used immediately and a backgroundLocalAndRemoteSource(stream: false)fetch warms the cache for the next page. If cold-cache, the remote fetch is tried with a 5 s timeout and falls back to empty — so offline scroll fails fast instead of hanging indefinitely.
Test Coverage
| Scenario | Expected | Status |
|---|---|---|
| Offline, local Releases present | First page renders within one frame; no await on network | [ ] manual |
| Offline, no local Releases | Skeleton stays until relationship fetch resolves/fails; no hard error | [ ] manual |
| Online, cold cache | Same render timing as before (no regression) | [ ] manual |
loadMore while offline |
Older page attempt fails fast; first page remains rendered | [ ] manual |
Automated regression test: deferred. A meaningful test requires simulating a blocking relay that never sends EOSE. DummyStorageNotifier does not model relay-blocking — its LocalAndRemoteSource returns immediately, which makes the old code appear to work offline in tests. Proper coverage belongs in purplebase's integration tests (against its stub relay) or in a Patrol integration test that toggles airplane mode. Tracked here so it isn't silently skipped.
Code review evidence that the invariant now holds:
- No
awaiton anystorage.query(..., RemoteSource)inside the state-update path of the first-page listener (_applyFirstPageis synchronous). - All relationship loading goes through
and:→NestedQueryManager._executeNestedQuerywhich uses_queryBuffer.bufferQuery(...).then(...)— non-blocking. loadMore's imperative remotestorage.queryremains, but is user-initiated and wrapped in try/catch; failure resetsisLoadingMorewithout affectingfirstPage.
Decisions
2026-04-30 — Keep Release-first (don't restore asset-first)
Context: WORK-007 (FEAT-004) specified asset-first queries for the home screen; subsequent commits ("Refactored latest releases container, basic again" → 6bb9c7f) reverted LatestReleasesNotifier to Release-first. Restoring asset-first is a larger change and out of scope for this fix.
Decision: Keep current Release-first query shape; fix only the blocking/guard bugs.
Rationale: Smallest change that restores the invariant. FEAT-004 can be revisited separately.
2026-04-30 — Don't change models/purplebase
Context: We considered whether LocalAndRemoteSource needs new semantics (e.g., imperative storage.query returning local-first). There are two separate improvements worth considering, but neither is required for this fix:
- Rename
StorageLoading(models)to something likeStorageFresh/StoragePartialso consumers can't accidentally gate onis StorageData. - Make imperative
storage.query(..., LocalAndRemoteSource)return local immediately and fire remote in the background.
Decision: Defer. File as follow-ups.
Rationale: The reactive path (ref.watch(query<T>(and:))) already implements local-first correctly. The app-layer fix restores the invariant without touching model semantics.
Spec Issues
None
Progress Notes
2026-04-30 (1): Diagnosed, then rewrote LatestReleasesNotifier to use and: + local-synchronous app resolution. Dropped _resolveRelated. Missed the dominant root cause — the skeleton gate was on the full init chain, so the notifier was never constructed until the contact-list remote query timed out.
2026-04-30 (2): Second pass. Split appInitializationProvider into storageReadyProvider (pre-network, used by UI skeletons) and appInitializationProvider (full chain). Made onSignInSuccess non-blocking. Home screen now renders local data as soon as SQLite is open, regardless of network state.
2026-04-30 (3): Third pass, same class of bug in two more places:
- Updates screen was gated on
pollerState.lastCheckTime == null, which only flips after a successful remote poll. Offline, that never happens for 1+ minute (three social relays + AppCatalog timeouts). Replaced withhasHydratedflag flipped by a local-onlyrefreshFromLocal()called onstorageReadyProvider. Remote polling is nowunawaited— purely a background cache refresh. - Infinite scroll on Latest Releases / App Stacks used imperative
storage.query(..., LocalAndRemoteSource(stream: false))which blocks onRemoteQueryOpbefore returning local data. Offline this hung the spinner forever. Changed to local-first viaquerySyncwith a background hydrate, or — for cold cache — a remote fetch with a 5 s timeout and empty fallback.
Lesson
Three passes, same pattern: an awaited remote call was sitting in the render path. In each case the "gate" had a sensible justification at the time (don't show stale data, don't render before storage is open, don't paginate without a relay answer), but each gate violated the offline-first invariant by making the UI wait on the network.
Generalizable rules:
- If a widget uses
showSkeleton: !(someProvider.hasValue || someProvider.hasError), audit every awaited call in that provider. Any remotestorage.query— evenLocalAndRemoteSource(stream: false)— is a hang offline. - Imperative
storage.query(..., LocalAndRemoteSource(stream: false))is not local-first. It awaits remote first, then reads local. Usestorage.querySyncup front when rendering, and fire theLocalAndRemoteSourceversion as a background hydrate. - "First successful remote poll" is not a safe precondition for rendering. If the UI needs to categorize or paginate, derive from local state and let the remote enhance asynchronously.
On Merge
Delete this work packet. Promote the "multi-hop loads MUST use and:" guidance to spec/knowledge/DEC-XXX-relationship-queries.md if no existing knowledge entry covers it.