fix(mobile+desktop): cross-device read state sync + diagnostic logging#843
Merged
Conversation
Both platforms silently swallow all errors across 20+ catch sites in the read state manager, format decoder, and storage layers. Cross-device sync failures (decrypt, LWW merge, subscription, publish, storage corruption) produce zero log output, making debugging impossible. Add [ReadStateManager]-tagged logging at every silent drop point on both mobile (debugPrint) and desktop (console.debug) — initialization identity, subscription lifecycle, incoming event decrypt/decode, per- context LWW merge decisions, publish lifecycle, and storage hydration.
Guard Dart clientId.substring with min() to prevent RangeError on short IDs from relay-delivered events. Restore console.warn for publish failures (was accidentally downgraded to debug). Move "publish starting" log before fetchOwnBlobBeforePublish so hung fetches are visible. Remove redundant outer logs where inner format-layer logs already capture the specific failure.
70a3e57 to
b987c1a
Compare
Cross-device mark-as-unread published via NIP-RS correctly but the receiving device's UI never showed the unread dot. Root cause: the unread computation requires both a synced read marker AND a session-local "latest message timestamp" to agree, but only the read marker syncs. On the receiving device, the catch-up REQ already ran with the old (higher) marker and found nothing, so latestByChannelRef/lastMessageAt was missing — the channel was silently skipped. Fix: when handleIncomingEvent detects a rollback (incoming ts < current), store the context ID and propagate it to the forced-unread path — the same mechanism local mark-as-unread already uses. Also trims the verbose per-context LWW loop logs that fire 85+ times per incoming event.
Two root causes of flaky cross-device read state sync: 1. React.StrictMode mount→unmount→remount creates two ReadStateManager instances with independent live subscriptions. The first manager's destroy() nulls unsubscribeLive before the async startLiveSubscription() resolves, leaving an orphaned subscription. Fix: add destroyed flag, check after every await in initialize() and startLiveSubscription(). 2. _publish() sets contextSourceCreatedAt for ALL 85 contexts to the publish timestamp, causing any incoming event with an older createdAt to fail the LWW check for every context — not just the ones that changed. Fix: only bump contextSourceCreatedAt for contexts whose value actually differs from lastPublishedContexts. Also guards reinitializeRemote() with !_initialized on mobile to prevent duplicate subscriptions when it races with initialize().
…2E test When a synced rollback arrived (cross-device mark-as-unread), the UI correctly showed the unread dot via forcedUnreadRef/syncedForcedChannelIds. But a subsequent synced advance (mark-as-read) never cleared it — the dot stayed visible until the user opened the channel locally. Track synced advances alongside rollbacks in ReadStateManager. When LWW detects ts > current, add to pendingSyncedAdvances and remove from pendingSyncedRollbacks (mutual exclusion — last-wins within a React batch). The UI drain effect removes advanced channels from forcedUnreadRef. E2E infrastructure: mock NIP-44 encrypt/decrypt as passthrough in the test bridge, add __SPROUT_E2E_EMIT_MOCK_READ_STATE__ helper to inject kind:30078 events, and ACK kind:30078 EVENT publishes (no #h tag). New badge.spec.ts test exercises the full lifecycle: seed → rollback (dot appears) → advance (dot disappears).
wesbillman
approved these changes
Jun 4, 2026
michaelneale
added a commit
that referenced
this pull request
Jun 5, 2026
* origin/main: chore(release): release version 0.3.11 (#865) fix(mobile+desktop): cross-device read state sync + diagnostic logging (#843) feat(mobile): star channels (Slack-style favorites) (#863) feat: desktop-screenshot skill to stop agents uploading relay media to PRs (#862) feat(desktop): star channels (Slack-style favorites) (#860) fix(desktop): handle symlinked persona pack directories (#859) feat: channel muting for desktop and mobile (#838) feat(acp): default SPROUT_ACP_MEMORY to on (#854) fix(desktop): eliminate image-hover layout jump in messages (#813) chore(release): release version 0.3.10 (#849) fix(desktop): harden relay mesh connect p-tag (#834) fix(desktop): scroll activity panel to bottom on open (#848) Polish desktop profile menu interactions (#836) fix(desktop): outline thread hover targets (#845) fix(desktop): keep message actions hover-only (#844) fix(desktop): let inbox composer fill available width (#841) # Conflicts: # desktop/src/app/AppShell.tsx # desktop/src/features/workspaces/useWorkspaceInit.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Cross-device read state sync (mark-as-read/unread via NIP-RS kind:30078) was broken — actions on one device didn't reflect on the other. The sync layer (encrypt, publish, relay, decrypt, LWW merge) was working correctly; the bugs were all in the UI consumption layer.
Four bugs fixed across 5 commits:
Synced rollbacks not propagated to UI —
handleIncomingEventdetected rollbacks (ts < current) but the UI layer (forcedUnreadRef/syncedForcedChannelIds) was never populated from them. AddedpendingSyncedRollbacks→drainSyncedRollbacks()→ UI drain mechanism on both platforms.React.StrictMode orphaned manager — dev-mode double-mounting created two
ReadStateManagerinstances with independent live subscriptions. Addeddestroyedflag with abort guards after everyawaitininitialize()andstartLiveSubscription().Overbroad
contextSourceCreatedAtduring publish —_publish()bumpedcontextSourceCreatedAtfor ALL 85 contexts, blocking incoming events for contexts that didn't change. Scoped to only update contexts whose value actually differs fromlastPublishedContexts.Synced advances not clearing forced-unread state — after a synced rollback populated
forcedUnreadRef, a subsequent synced advance (mark-as-read) never cleared it. AddedpendingSyncedAdvanceswith mutual exclusion (last-wins when React batches events). UI drain effect now removes advanced channels fromforcedUnreadRef/syncedForcedChannelIds.E2E test infrastructure:
nip44_encrypt_to_self/nip44_decrypt_from_selfas passthrough in the E2E bridge soReadStateManagerinitializes in tests__SPROUT_E2E_EMIT_MOCK_READ_STATE__helper to inject kind:30078 events through the mock WebSocket#htag)badge.spec.tstest: synced rollback shows dot → synced advance clears itAlso adds permanent
[ReadStateManager]diagnostic logging to lifecycle, publish, and error paths on both platforms (trimmed from the verbose per-context LWW logs that fired 85+ times per event).