Skip to content

Commit 2478a29

Browse files
spe1020spe1020claude
authored
fix(feed-comment-count): dedupe across concurrent engagementCache subscriptions (#323)
* chore(instrument): TEMP feed-comment double-count fingerprint logs NOT A FIX. Temporary diagnostic logs to fingerprint the feed-post comment-count doubling that Phase 1 misread as correctly deduped. Revert this commit once the fingerprint is captured and the real fix lands. Tags used: [EC-COMMENT] batch sub kind-1 branch (batchFetchEngagement) [EC-COMMENT-SINGLE] single-event sub kind-1 branch (fetchEngagement) [NTC-RENDER] NoteTotalComments render tick (one per mounted instance) Each EC log carries event.id.slice(0,8) + root.slice(0,8). NTC log carries the same event slice + the rendered count. Fingerprint decoder (expected after posting a single comment on a kind-1 feed note that had zero prior comments): F5-A Two identical `[EC-COMMENT] counted` OR two identical `[EC-COMMENT-SINGLE] counted` lines for the same event id → processed Set is being reset mid-stream, or the subscription is being recreated and the new sub inherits a cleared Set. Fix: scope audit. F5-B One `[EC-COMMENT] counted` + one `[EC-COMMENT-SINGLE] counted` for the same event id → both batch subscription and single-event subscription are alive for the same root and are incrementing two counter paths. They do share processedEventIds.get(eventId), so this would mean one of them is resetting that Set before the other runs. Fix: coordinate reset between the two entry points (batchFetchEngagement + fetchEngagement). F5-C One `[EC-COMMENT*] counted` + multiple `[NTC-RENDER]` lines for the same event id → two NoteTotalComments instances mounted for the same root. Each reads independently, but both see the true count. If both render side-by-side the user sees them summed. Fix: DOM audit — likely a feed card + a detail view both visible. F5-D No `[EC-COMMENT*]` lines fire at all but count still climbs → a third write-path outside engagementCache.ts. Unlikely per the exhaustive grep at 2f.1 — all .comments.count writes are in engagementCache.ts, engagementPreloader.ts (assignment from counts API, not ++), or display-only (shareNoteImage.ts, FoodstrFeedOptimized engagementData snapshots). If this fires, grep harder. F5-E Two `[EC-COMMENT*] counted` lines with event ids the user didn't just post → filter matching something unexpected (reaction, reply-to- reply, etc.). Fix: tighten filter. Instrumentation sites — all lines are marked with `TEMP-INSTRUMENT fingerprint-5` to make revert trivial: engagementCache.ts (single-event sub, around line 420) - deduped log in top-level `processed.has` branch, kind-1 only - counted log inside kind-1 case, before count++ engagementCache.ts (batch sub, around line 920) - mirror of the above NoteTotalComments.svelte - reactive `$: console.log(...)` at render time Seth: reproduce by posting ONE comment on a kind-1 feed post with zero prior comments, reload the page, paste the console lines tagged [EC-COMMENT, [EC-COMMENT-SINGLE, and [NTC-RENDER from the last ~15 seconds. The fingerprint will dictate the Phase 2g fix shape. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(engagementCache): dedupe across concurrent engagement subscriptions Completes the reaction/comment count dedup work started in #321. Feed comment counts on kind-1 posts were inflating (a newly posted comment showed up as 6 in the console while Primal / other clients showed 1) because `processedEventIds` was being wiped on every entry into `fetchEngagement` and `batchFetchEngagement`, breaking dedup across concurrent subscription lifecycles. Fingerprint (F5-B, from the instrumentation on fix/feed-comment-count-dedup) [EC-COMMENT-SINGLE] counted 5605e182 for root 00006922 new-count: 2 [EC-COMMENT-SINGLE] counted 5605e182 for root 00006922 new-count: 3 [EC-COMMENT-SINGLE] counted 5605e182 for root 00006922 new-count: 4 [EC-COMMENT-SINGLE] counted 5605e182 for root 00006922 new-count: 5 [EC-COMMENT] counted 5605e182 for root 00006922 new-count: 6 [EC-COMMENT] deduped 5605e182 for root 00006922 One comment event counted six times — five times through the single- event subscription, once through the batch subscription, before the batch sub finally deduped. Root cause A feed card mounts several engagement-consuming components for the same root event: NoteTotalComments, NoteTotalLikes (via ReactionTrigger), NoteTotalZaps, NoteRepost, ReactionPills. Each calls `fetchEngagement(ndk, eventId, userPublickey)` on mount. Each call was executing: processedEventIds.set(eventId, new Set()); processedReactionPairs.set(eventId, new Set()); …replacing any existing Set mid-stream. And the sub-refresh branch (when `existingPersistent && !hasAmountData`) was also doing: processedEventIds.delete(eventId); processedReactionPairs.delete(eventId); But the subscription handler captures `const processed = processedEventIds.get(eventId)!` at creation time — so each stopped- but-still-delivering sub held a closure reference to its old (now orphaned) Set. New fetchEngagement entrants installed fresh Sets. When relays redelivered the same event to multiple in-flight handlers, each handler's independent Set reported `has(id) === false`, and the handlers independently incremented the shared store's `comments.count`. Fix Stop resetting the dedup Sets on entry. Initialize-if-absent instead. The Sets now persist for the entire lifetime of an event being visible on screen; `cleanupEngagement(eventId)` (fires when the event leaves the viewport) is the only place they're cleared. Applied at four sites in engagementCache.ts: 1. fetchEngagement sub-refresh branch — removed the explicit `.delete()` calls for both processedEventIds and processedReactionPairs. Stopping the subscription is kept; the Set is not touched. 2. fetchEngagement init block — `processedEventIds.set(...)` and `processedReactionPairs.set(...)` replaced with `if (!map.has(eventId)) map.set(eventId, new Set())`. 3-4. batchFetchEngagement init block — same init-if-absent pattern for both Maps, per toFetch id. All handlers already call `processed.has(event.id)` before incrementing, so the only change needed was preserving the Set's identity across calls; no handler logic changes. Why count resets are left intact The synchronous count reset (`reactions.count: 0`, `comments.count: 0`, etc.) still runs. On first-time init the Set is empty, so the subscription correctly counts events from zero. On a re-entry where the Set already has prior ids, events the relay redelivers are deduped (not counted), and the authoritative count is refreshed by the NIP-45 `getEngagementCounts` API fast path that runs earlier in `fetchEngagement`. Transient oscillation (0 → API count → 0 → API count) may be visible for 100-300ms during a concurrent-mount burst, but the final steady-state count is correct. Verified - pnpm exec eslint: only pre-existing `pendingOptimisticZappers` prefer-const on line 355 (untouched). No new errors. - pnpm check: 4 errors baseline preserved. - Instrumentation on fix/feed-comment-count-dedup captured the pre-fix F5-B fingerprint. Instrumentation revert follows in the next commit so logs don't ship. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Revert "chore(instrument): TEMP feed-comment double-count fingerprint logs" This reverts commit eb8042f. * fix(engagementCache): gate count resets on fresh-Set init Addresses the Copilot review on PR #323: with the dedup Sets now persisting across fetchEngagement / batchFetchEngagement re-entries (the bb89d79 fix), the pre-existing unconditional `counts = 0` reset became a new failure mode. On re-entry to either function with an already-populated Set, the flow was: 1. Sync reset reactions/comments/reposts/zaps counts to 0. 2. subscriptionCountingInProgress.add(eventId), which causes the NIP-45 `getEngagementCounts()` fast-path to skip its store.update (line 258-261: "Skip if subscription is actively counting"). 3. Subscription starts against the preserved Set. 4. Relay redelivers historical events — every one returns `processed.has(event.id) === true` → dedup → handler early-exits without incrementing. 5. Nothing ever increments the zeroed counts. Stuck at 0. Fix: gate the count reset on `isFirstInit` (i.e. we created the Set this call). On re-entry, keep the counts already produced against this Set in place and just flag `loading: true` so the UI reflects the refresh. Applied at both sites: - fetchEngagement: `isFirstInit = !processedEventIds.has(eventId)` captured before the .set() call. Only the first-init branch runs the full reset+optimistic-zap-restore store.update; re-entry does a minimal `loading: true` update while topping up optimistic zap state (so a pending zap isn't erased mid-flight). - batchFetchEngagement: same pattern per `toFetch` id. The count state's invariant is now: the dedup Set and the counts are co-owned — they come into existence together and go out of existence together (via cleanupEngagement). A subscription only mutates the counts by calling `processed.add(event.id)` and incrementing, never by wiping. Verified - pnpm exec eslint src/lib/engagementCache.ts: clean (the prior pendingOptimisticZappers prefer-const warning disappeared when the variable was rewritten as const during this refactor). - pnpm check: 4 errors baseline preserved. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: spe1020 <sethsager@Seths-MacBook-Air.local> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 5ddde3d commit 2478a29

File tree

2 files changed

+112
-45
lines changed

2 files changed

+112
-45
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "zap.cooking",
33
"license": "MIT",
4-
"version": "4.2.266",
4+
"version": "4.2.267",
55
"private": true,
66
"scripts": {
77
"dev": "vite dev",

src/lib/engagementCache.ts

Lines changed: 111 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -311,34 +311,61 @@ export async function fetchEngagement(
311311
return;
312312
}
313313

314-
// If we have a subscription but no amount data, close it and create a fresh one
314+
// If we have a subscription but no amount data, close it and create a fresh one.
315+
// Note: we intentionally do NOT wipe processedEventIds / processedReactionPairs
316+
// here — those dedup Sets must persist across sub close+reopen so any
317+
// events the prior sub already counted aren't re-counted by the new sub
318+
// when the relay redelivers them (F5-B feed-comment double-count bug).
319+
// The Sets are only evicted by `cleanupEngagement(eventId)` when the
320+
// event goes off-screen.
315321
if (existingPersistent && !hasAmountData) {
316322
console.debug('[Engagement] Subscription exists but no amount data, refreshing for', eventId);
317323
existingPersistent.sub.stop();
318324
persistentSubscriptions.delete(eventId);
319-
processedEventIds.delete(eventId); // Allow re-processing events
320-
processedReactionPairs.delete(eventId);
321325
}
322-
323-
// Initialize processed event tracking - MUST be fresh to avoid double counting
324-
// Clear any stale processed IDs and start fresh
325-
processedEventIds.set(eventId, new Set());
326-
processedReactionPairs.set(eventId, new Set());
326+
327+
// Initialize processed event tracking — init-if-absent, never wipe.
328+
// Multiple components (NoteTotalComments, NoteTotalLikes/ReactionTrigger,
329+
// NoteTotalZaps, NoteRepost, ReactionPills) all call fetchEngagement for
330+
// the same eventId on mount. Wiping the Set on every call caused each
331+
// component's resulting subscription to treat already-counted events as
332+
// new — producing the F5-B fingerprint of one event yielding 5+ counts.
333+
// Sharing a single Set across all callers is the correct pattern for
334+
// cross-sub dedup.
335+
//
336+
// The Set lifetime is bound to the count state that was accumulated
337+
// against it: if we created the Set this call, counts are still at
338+
// zero (or cached / API-fast-path values) and the subscription should
339+
// count up from there. If the Set already existed from a prior call,
340+
// the count state it produced is already in `store` — wiping counts
341+
// while preserving the Set would cause the sub to dedup historical
342+
// events (they're in the Set) without ever re-counting them, leaving
343+
// counts stuck at 0.
344+
const isFirstInit = !processedEventIds.has(eventId);
345+
if (isFirstInit) {
346+
processedEventIds.set(eventId, new Set());
347+
}
348+
if (!processedReactionPairs.has(eventId)) {
349+
processedReactionPairs.set(eventId, new Set());
350+
}
327351
const processed = processedEventIds.get(eventId)!;
328-
352+
329353
// Stop any old-style subscriptions
330354
const existingSubs = activeSubscriptions.get(eventId);
331355
if (existingSubs) {
332356
existingSubs.forEach(sub => sub.stop());
333357
activeSubscriptions.delete(eventId);
334358
}
335-
336-
// IMPORTANT: Reset counts to 0 before subscription to prevent double counting
337-
// The cache shows instant data, but subscription will provide accurate final counts
338-
// Preserve any pending optimistic zap updates so they aren't wiped during re-fetch
359+
360+
// Count-reset is only safe when starting fresh. On re-entry (Set
361+
// already populated), leave the accumulated counts in place — the
362+
// subscription will dedup historical events and only increment for
363+
// genuinely new arrivals, which is the correct behavior.
364+
// Pending optimistic zap updates are re-applied on both paths so
365+
// they aren't wiped during re-fetch.
339366
let pendingOptimisticAmount = 0;
340367
let pendingOptimisticCount = 0;
341-
let pendingOptimisticZappers: Array<{ pubkey: string; amount: number; timestamp: number }> = [];
368+
const pendingOptimisticZappers: Array<{ pubkey: string; amount: number; timestamp: number }> = [];
342369
for (const [key, zap] of optimisticZaps.entries()) {
343370
if (key.startsWith(`${eventId}:`)) {
344371
// totalAmount is stored in millisats; topZappers.amount is sats
@@ -357,20 +384,37 @@ export async function fetchEngagement(
357384
}
358385
}
359386
}
360-
store.update(s => ({
361-
...s,
362-
reactions: { count: 0, userReacted: false, groups: [], userReactions: new Set() },
363-
comments: { count: 0 },
364-
reposts: { count: 0, userReposted: false },
365-
zaps: {
366-
...s.zaps,
367-
count: pendingOptimisticCount,
368-
totalAmount: pendingOptimisticAmount,
369-
topZappers: pendingOptimisticZappers,
370-
userZapped: s.zaps.userZapped || pendingOptimisticCount > 0
371-
},
372-
loading: true
373-
}));
387+
if (isFirstInit) {
388+
store.update(s => ({
389+
...s,
390+
reactions: { count: 0, userReacted: false, groups: [], userReactions: new Set() },
391+
comments: { count: 0 },
392+
reposts: { count: 0, userReposted: false },
393+
zaps: {
394+
...s.zaps,
395+
count: pendingOptimisticCount,
396+
totalAmount: pendingOptimisticAmount,
397+
topZappers: pendingOptimisticZappers,
398+
userZapped: s.zaps.userZapped || pendingOptimisticCount > 0
399+
},
400+
loading: true
401+
}));
402+
} else {
403+
// Re-entry: preserve counts already produced against this Set;
404+
// just mark loading so UI knows a refresh is in flight, and top
405+
// up the optimistic-zap state.
406+
store.update(s => ({
407+
...s,
408+
zaps: {
409+
...s.zaps,
410+
count: Math.max(s.zaps.count, pendingOptimisticCount),
411+
totalAmount: Math.max(s.zaps.totalAmount, pendingOptimisticAmount),
412+
topZappers: pendingOptimisticZappers.length > 0 ? pendingOptimisticZappers : s.zaps.topZappers,
413+
userZapped: s.zaps.userZapped || pendingOptimisticCount > 0
414+
},
415+
loading: true
416+
}));
417+
}
374418

375419
// Mark that subscription counting is in progress (prevents NIP-45 race condition)
376420
subscriptionCountingInProgress.add(eventId);
@@ -881,25 +925,48 @@ export async function batchFetchEngagement(
881925
}
882926

883927
// FULL PATH: NDK subscription for accurate counts + user state
884-
// Reset processed IDs and counts to prevent double counting
928+
// Init-if-absent on the dedup Sets — never wipe them here. The batch
929+
// subscription shares `processedEventIds` with any per-event subscription
930+
// that fetchEngagement may have already opened for the same eventId.
931+
// Wiping the Set at batch-entry caused the in-flight single subs and
932+
// the new batch sub to each count the same event against a fresh Set
933+
// (F5-B fingerprint). Eviction happens only in cleanupEngagement when
934+
// the event leaves the viewport.
885935
toFetch.forEach(id => {
886-
// Clear processed IDs to start fresh
887-
processedEventIds.set(id, new Set());
888-
processedReactionPairs.set(id, new Set());
889-
936+
// Only reset counts to 0 when we're creating the dedup Set for the
937+
// first time for this id. If the Set is already populated (e.g. a
938+
// per-event `fetchEngagement` ran for this id moments ago), the
939+
// accumulated counts in `store` were produced against that Set —
940+
// wiping counts while preserving the Set would cause this batch sub
941+
// to dedup every historical event without ever re-counting them,
942+
// leaving counts stuck at 0.
943+
const isFirstInit = !processedEventIds.has(id);
944+
if (isFirstInit) {
945+
processedEventIds.set(id, new Set());
946+
}
947+
if (!processedReactionPairs.has(id)) {
948+
processedReactionPairs.set(id, new Set());
949+
}
950+
890951
// Mark that subscription counting is in progress
891952
subscriptionCountingInProgress.add(id);
892-
893-
// Reset counts to 0 before fetching to prevent accumulation
894-
const store = getEngagementStore(id);
895-
store.update(s => ({
896-
...s,
897-
reactions: { count: 0, userReacted: false, groups: [], userReactions: new Set() },
898-
comments: { count: 0 },
899-
reposts: { count: 0, userReposted: false },
900-
zaps: { ...s.zaps, count: 0, totalAmount: 0, topZappers: [] },
901-
loading: true
902-
}));
953+
954+
if (isFirstInit) {
955+
const store = getEngagementStore(id);
956+
store.update(s => ({
957+
...s,
958+
reactions: { count: 0, userReacted: false, groups: [], userReactions: new Set() },
959+
comments: { count: 0 },
960+
reposts: { count: 0, userReposted: false },
961+
zaps: { ...s.zaps, count: 0, totalAmount: 0, topZappers: [] },
962+
loading: true
963+
}));
964+
} else {
965+
// Re-entry: preserve counts; just flag loading so UI reflects
966+
// the refresh.
967+
const store = getEngagementStore(id);
968+
store.update(s => ({ ...s, loading: true }));
969+
}
903970
});
904971

905972
const filter = {

0 commit comments

Comments
 (0)