Skip to content

fix(mobile+desktop): cross-device read state sync + diagnostic logging#843

Merged
wpfleger96 merged 5 commits into
mainfrom
wpfleger/read-state-logging
Jun 4, 2026
Merged

fix(mobile+desktop): cross-device read state sync + diagnostic logging#843
wpfleger96 merged 5 commits into
mainfrom
wpfleger/read-state-logging

Conversation

@wpfleger96
Copy link
Copy Markdown
Collaborator

@wpfleger96 wpfleger96 commented Jun 4, 2026

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:

  1. Synced rollbacks not propagated to UIhandleIncomingEvent detected rollbacks (ts < current) but the UI layer (forcedUnreadRef / syncedForcedChannelIds) was never populated from them. Added pendingSyncedRollbacksdrainSyncedRollbacks() → UI drain mechanism on both platforms.

  2. React.StrictMode orphaned manager — dev-mode double-mounting created two ReadStateManager instances with independent live subscriptions. Added destroyed flag with abort guards after every await in initialize() and startLiveSubscription().

  3. Overbroad contextSourceCreatedAt during publish_publish() bumped contextSourceCreatedAt for ALL 85 contexts, blocking incoming events for contexts that didn't change. Scoped to only update contexts whose value actually differs from lastPublishedContexts.

  4. Synced advances not clearing forced-unread state — after a synced rollback populated forcedUnreadRef, a subsequent synced advance (mark-as-read) never cleared it. Added pendingSyncedAdvances with mutual exclusion (last-wins when React batches events). UI drain effect now removes advanced channels from forcedUnreadRef / syncedForcedChannelIds.

E2E test infrastructure:

  • Mocked nip44_encrypt_to_self / nip44_decrypt_from_self as passthrough in the E2E bridge so ReadStateManager initializes in tests
  • Added __SPROUT_E2E_EMIT_MOCK_READ_STATE__ helper to inject kind:30078 events through the mock WebSocket
  • ACK kind:30078 EVENT publishes in the mock relay (no #h tag)
  • New badge.spec.ts test: synced rollback shows dot → synced advance clears it

Also 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).

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.
@wpfleger96 wpfleger96 force-pushed the wpfleger/read-state-logging branch from 70a3e57 to b987c1a Compare June 4, 2026 19:57
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.
@wpfleger96 wpfleger96 changed the title fix(notifications): add diagnostic logging to read state sync path fix(mobile+desktop): cross-device mark-as-unread sync + read state logging Jun 4, 2026
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().
@wpfleger96 wpfleger96 changed the title fix(mobile+desktop): cross-device mark-as-unread sync + read state logging fix(mobile+desktop): cross-device read state sync + diagnostic logging Jun 4, 2026
…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).
@wpfleger96 wpfleger96 marked this pull request as ready for review June 4, 2026 22:50
@wpfleger96 wpfleger96 requested a review from a team as a code owner June 4, 2026 22:50
@wpfleger96 wpfleger96 merged commit 269b35e into main Jun 4, 2026
15 checks passed
@wpfleger96 wpfleger96 deleted the wpfleger/read-state-logging branch June 4, 2026 22:55
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants