feat(desktop): persist sidebar observed-unread across webview reload - #3976
Open
wpfleger96 wants to merge 4 commits into
Open
feat(desktop): persist sidebar observed-unread across webview reload#3976wpfleger96 wants to merge 4 commits into
wpfleger96 wants to merge 4 commits into
Conversation
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
force-pushed
the
duncan/persist-observed-unread
branch
from
July 31, 2026 19:23
e72be19 to
13d5de1
Compare
…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>
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.
Problem
Command+R(webview reload) wipes the two in-memory refs driving sidebar channel unread badges:observedUnreadEventsByChannelRefandlatestByChannelRef. 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:buzz-observed-unread.v1:<normalizedRelayUrl>:<normalizedPubkey>(relay-scoped to prevent cross-community leakage, matchingthreadActivityStorage)ObservedUnreadEventrows;latestByChannelis derived at hydration — no divergent dual aggregateREAD_STATE_HORIZON_SECONDS), per-channel cap (1000), global cap (5000) across all channels in a scope bucketupdatedAtfor LRU ordering; registered inPURE_CACHE_KEY_PREFIXESfor 2 MiB eviction budgetscheduleObservedUnreadWritedeep-clones the events map at schedule time — a late A-scope timer can never read B's mutable refs or write under B's keyuseObservedUnreadPersistence.ts— hook that owns all persistence lifecycle:normalized pubkey + normalized relayidentity;isScopeLoaded()callback guards both projection (rawUnread) and every mutation (recordUnreadEvent,removeChannel,clearAll) before touching refs or storagepagehideflush closes the Cmd+R timing gap (useReloadShortcut.tsreloads within 500ms of teardown, before the 1-second debounce fires)clearAllandremoveChannelcancel pending timer before clearing storage, preventing a pending snapshot from resurrecting cleared statereadStateVersion: evaluates each retained event withobservedUnreadEventReadAt()(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 markeruseMemo-wrapped API object keyed on actual deps so unrelated re-renders do not restart the catch-up REQisScopeLoadedis auseCallback(not a memoized boolean) — always reads the ref at call time, never staleModified files
useUnreadChannels.ts— hook integration:useObservedUnreadPersistencewith all persistence wired through the returned APIrawUnread:isScopeLoaded()guard suppresses A-scope refs from projecting under BrecordUnreadEvent:isScopeLoaded()fence before touching refs; schedules a debounced write on each successful recordmarkChannelReadclearObserved path: callsremoveChannelso the cleared state survives reloadmarkAllChannelsRead: clears both observed refs wholesale (= new Map()), then callsclearAll— prevents a pending snapshot from carrying non-projected events back to storagelocalStorageQuota.ts— registersbuzz-observed-unread.v1:inPURE_CACHE_KEY_PREFIXESDesign constraints
The cache is a disposable projection: versioned key, read-through only, safe to delete wholesale. It does not touch
ReadStateManager, marker semantics, orforcedUnreadStore. 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.mjscovers storage primitives:deriveLatestByChannelcorrectnesspruneObservedUnreadByMarkerswith channel, thread, and msg markers — one opened thread leaves another unread thread persisted and lit; local/synced advances produce identical stored resultflushObservedUnreadWriteandscheduleObservedUnreadWritescope safety (late A-scope timer while in B scope does nothing)useObservedUnreadPersistence.test.mjsexercises the real hook viacreateRoot+act:clearAllcancels pending debounce so no resurrection after reloadremoveChannelcancels pending debounce so cleared channel is not resurrectedonPrunedisScopeLoadedreturns false before identity-reset effect commits, true after