Skip to content

feat(desktop): persist sidebar observed-unread across webview reload - #3976

Open
wpfleger96 wants to merge 4 commits into
mainfrom
duncan/persist-observed-unread
Open

feat(desktop): persist sidebar observed-unread across webview reload#3976
wpfleger96 wants to merge 4 commits into
mainfrom
duncan/persist-observed-unread

Conversation

@wpfleger96

@wpfleger96 wpfleger96 commented Jul 31, 2026

Copy link
Copy Markdown
Member

Problem

Command+R (webview reload) wipes the two in-memory refs driving sidebar channel unread badges: observedUnreadEventsByChannelRef and latestByChannelRef. The boot catch-up REQ can only fetch events newer than each channel's NIP-RS frontier, so thread replies that arrived before the frontier was passively advanced (the common case) are never re-discovered.

Inbox is unaffected because it rebuilds candidates from a relay feed query and checks fine-grained thread:/msg: markers. The sidebar badge path lacks an equivalent recovery mechanism.

Solution

Persist the sidebar's per-event candidate set to localStorage as a disposable, versioned projection cache (buzz-observed-unread.v1:<relay>:<pubkey>) and hydrate it on boot before the catch-up REQ runs.

New files

observedUnreadStorage.ts — storage module for the cache:

  • Keyed buzz-observed-unread.v1:<normalizedRelayUrl>:<normalizedPubkey> (relay-scoped to prevent cross-community leakage, matching threadActivityStorage)
  • Stores validated per-event ObservedUnreadEvent rows; latestByChannel is derived at hydration — no divergent dual aggregate
  • Age pruning (7d = READ_STATE_HORIZON_SECONDS), per-channel cap (1000), global cap (5000) across all channels in a scope bucket
  • Payload updatedAt for LRU ordering; registered in PURE_CACHE_KEY_PREFIXES for 2 MiB eviction budget
  • Field-level validation on decode; write failure is non-fatal (session-only degradation)
  • Snapshot-owning timers: scheduleObservedUnreadWrite deep-clones the events map at schedule time — a late A-scope timer can never read B's mutable refs or write under B's key

useObservedUnreadPersistence.ts — hook that owns all persistence lifecycle:

  • Scope fence: normalized pubkey + normalized relay identity; isScopeLoaded() callback guards both projection (rawUnread) and every mutation (recordUnreadEvent, removeChannel, clearAll) before touching refs or storage
  • Synchronous pagehide flush closes the Cmd+R timing gap (useReloadShortcut.ts reloads within 500ms of teardown, before the 1-second debounce fires)
  • Identity-reset effect: flushes old scope, resets refs, hydrates from storage, stamps loaded scope — all atomic; cleanup flushes on unmount
  • clearAll and removeChannel cancel pending timer before clearing storage, preventing a pending snapshot from resurrecting cleared state
  • Marker-prune effect on readStateVersion: evaluates each retained event with observedUnreadEventReadAt() (the same evaluator used by the projection memo) and removes covered events, rederiving per-channel latest — never clears a whole channel for a single thread/msg marker
  • Returns a stable useMemo-wrapped API object keyed on actual deps so unrelated re-renders do not restart the catch-up REQ
  • isScopeLoaded is a useCallback (not a memoized boolean) — always reads the ref at call time, never stale

Modified files

useUnreadChannels.ts — hook integration:

  • Calls useObservedUnreadPersistence with all persistence wired through the returned API
  • rawUnread: isScopeLoaded() guard suppresses A-scope refs from projecting under B
  • recordUnreadEvent: isScopeLoaded() fence before touching refs; schedules a debounced write on each successful record
  • markChannelRead clearObserved path: calls removeChannel so the cleared state survives reload
  • markAllChannelsRead: clears both observed refs wholesale (= new Map()), then calls clearAll — prevents a pending snapshot from carrying non-projected events back to storage

localStorageQuota.ts — registers buzz-observed-unread.v1: in PURE_CACHE_KEY_PREFIXES

Design constraints

The cache is a disposable projection: versioned key, read-through only, safe to delete wholesale. It does not touch ReadStateManager, marker semantics, or forcedUnreadStore. Zero overlap with the NIP-RS manual mark-read/unread protocol work in progress in another channel; migration path when that lands is "stop reading the key."

Test coverage

observedUnreadStorage.test.mjs covers storage primitives:

  • Key normalization, relay-scoped isolation, round-trip correctness
  • Age-prune and per-channel cap on read and write; global cap across channels
  • deriveLatestByChannel correctness
  • pruneObservedUnreadByMarkers with channel, thread, and msg markers — one opened thread leaves another unread thread persisted and lit; local/synced advances produce identical stored result
  • flushObservedUnreadWrite and scheduleObservedUnreadWrite scope safety (late A-scope timer while in B scope does nothing)
  • record → pending-debounce → pagehide-flush → hydrate proves the headline repro is not timing-dependent
  • Malformed structures/fields, relay/pubkey isolation, normalized-equivalent URLs, quota failure degradation

useObservedUnreadPersistence.test.mjs exercises the real hook via createRoot + act:

  • pagehide flush: event recorded within debounce window survives reload (headline regression)
  • Unmount with pending write flushes before teardown
  • API object identity is stable across re-renders with unchanged props (catch-up REQ not restarted)
  • A→B scope switch: late A-scope scheduled write does not corrupt B's bucket
  • clearAll cancels pending debounce so no resurrection after reload
  • removeChannel cancels pending debounce so cleared channel is not resurrected
  • Marker prune: thread marker removes only covered events, leaves other threads intact, calls onPruned
  • isScopeLoaded returns false before identity-reset effect commits, true after

@wpfleger96
wpfleger96 requested a review from a team as a code owner July 31, 2026 18:32
Command+R wipes the two in-memory refs (observedUnreadEventsByChannelRef,
latestByChannelRef) that drive sidebar channel badges. The boot catch-up
REQ can only fetch events newer than each channel's NIP-RS frontier, so
thread replies below the frontier — the common case — were never
re-discovered after reload.

New module observedUnreadStorage.ts persists the per-event candidate map
to localStorage under buzz-observed-unread.v1:<relay>:<pubkey>. Key design
choices mandated by the plan:

- Single persisted representation (per-event rows); latestByChannel is
  derived at hydration, eliminating divergence between the two.
- Synchronous pagehide flush closes the Cmd+R timing gap: Cmd+R reloads
  within 500ms of teardown, before the 1-second debounce fires.
- Normalized pubkey+relay scope fence with snapshot-owning timers: a
  pending scope-A timer cannot write into scope-B's bucket or read B's
  mutable refs.
- Event-granular marker pruning on readStateVersion changes, using the
  same observedUnreadEventReadAt() evaluator as the projection memo.
- Field-level validation, age/per-channel/global caps, and registration
  in PURE_CACHE_KEY_PREFIXES for LRU eviction — write failure degrades to
  session-only behavior.

All persistence logic is extracted into useObservedUnreadPersistence so
useUnreadChannels stays within its 1022-line ratchet. The hook accepts an
onPruned callback that fires bumpLatestVersion when a marker-prune pass
removes covered events.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
@wpfleger96
wpfleger96 force-pushed the duncan/persist-observed-unread branch from e72be19 to 13d5de1 Compare July 31, 2026 19:23
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 and others added 3 commits July 31, 2026 16:21
…stence

Pass 2 review blocked on three IMPORTANT findings — all resolved here.

Finding (a) — one scope authority, enforced everywhere:
- handleChannelMessage now fences latestByChannelRef on isScopeLoaded()
  before mutating, guarding the path that was unfenced at 420-423.
- Catch-up .then() drops threadActivityScopeRef as its guard and uses
  observedPersistence.isScopeLoaded() instead — one authority, not two.
- removeChannel/clearAll reject when scopeLoadedRef.current !== currentScope;
  non-empty props alone are no longer sufficient.

Finding (b) — removeChannel never cancel-without-replacement:
- removeChannel deletes the channel from both in-memory refs and then calls
  scheduleObservedUnreadWrite with the current full map. The previous path
  (cancel + edit persisted bucket) would lose unsaved sibling-channel events
  on the next reload. The updated removeChannel test seeds both channel-1 and
  channel-2, removes channel-1 before the debounce fires, flushes via
  pagehide, and asserts channel-2 survives.

Finding (c) — real scope-transition tests:
- isScopeLoaded test now drives a real prop transition (re-render with new
  pubkey) instead of manually back-dating scopeLoadedRef.current.
- Added stale-removeChannel scope-fence test: captures removeChannel under
  scope A, switches to scope B via re-render, calls stale callback, asserts
  B's bucket is untouched.
- Fixed two pre-existing biome useOptionalChain findings in A→B late-timer
  test (storedAT?.has / storedBT2?.has).

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…undary tests

removeChannel and clearAll in useObservedUnreadPersistence now own all
mutation of the shared observed refs — the parent (useUnreadChannels)
no longer touches latestByChannelRef or observedUnreadEventsByChannelRef
directly on the clear/mark-all-read paths.

Before this change, markChannelRead deleted from both refs before calling
the fenced removeChannel(), and markAllChannelsRead reset both refs to
empty Maps before calling the fenced clearAll(). A stale scope-A callback
could therefore corrupt scope B's in-memory badges before the scope fence
rejected the call.

Both owner operations now validate the captured scope first, then mutate
refs, then write to storage — stale scope invocations are complete no-ops.

Adds useUnreadChannels.test.mjs: five boundary tests that mount the full
production hook with QueryClientProvider, covering:
- markChannelRead happy path (clears channel from refs + storage)
- markChannelRead topLevelOnly=true (leaves refs intact)
- markChannelRead stale scope-A callback (B refs and storage untouched)
- markAllChannelsRead happy path (clears entire bucket)
- markAllChannelsRead stale scope-A callback (B refs and storage untouched)

Also rewrites the A→B late-timer test in useObservedUnreadPersistence
to accurately describe what it proves (synchronous flush invariant), and
fixes a vacuous removeChannelAndPersistCurrent API stability assertion.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…giene

Both stale boundary tests now detect the pre-fix parent mutations:

markChannelRead stale test: A and B now share the same channel ID
("channel-shared") so the stale callback targets a channel actually
present in B's hydrated refs. The test calls flushStorage() after the
stale invocation and asserts B's bucket still contains the channel; the
pre-fix parent deleted channel-shared from B's refs first, causing the
flush to write an empty map and failing the assertion.

markAllChannelsRead stale test: flushStorage() is now called after the
stale invocation before the storage assertion. The pre-fix parent reset
both observed refs to new Maps, causing the subsequent flush to overwrite
B's bucket with an empty map; the post-fix fence leaves the refs intact
and the flush preserves the seeded event.

Litmus verified: both stale tests fail on 2fa9a05 (pre-fix parent) and
pass on bfbaf5e (fenced owner operations).

Also removes an unsupported claim in the A→B late-timer test comment
that pointed to observedUnreadStorage.test.mjs as proof of the
scheduler's immutable snapshot (that file never invokes
scheduleObservedUnreadWrite). Replaces with an accurate description of
what the timer closure holds and why the snapshot invariant is moot in
the lifecycle path.

Narrows two "no parent mutation" comments in useUnreadChannels.ts to
"destructive remove/clear mutations" to avoid implying the fenced record
writes in handleChannelMessage and catch-up are prohibited.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
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.

1 participant