Commit 2478a29
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
2 files changed
+112
-45
lines changed| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1 | 1 | | |
2 | 2 | | |
3 | 3 | | |
4 | | - | |
| 4 | + | |
5 | 5 | | |
6 | 6 | | |
7 | 7 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
311 | 311 | | |
312 | 312 | | |
313 | 313 | | |
314 | | - | |
| 314 | + | |
| 315 | + | |
| 316 | + | |
| 317 | + | |
| 318 | + | |
| 319 | + | |
| 320 | + | |
315 | 321 | | |
316 | 322 | | |
317 | 323 | | |
318 | 324 | | |
319 | | - | |
320 | | - | |
321 | 325 | | |
322 | | - | |
323 | | - | |
324 | | - | |
325 | | - | |
326 | | - | |
| 326 | + | |
| 327 | + | |
| 328 | + | |
| 329 | + | |
| 330 | + | |
| 331 | + | |
| 332 | + | |
| 333 | + | |
| 334 | + | |
| 335 | + | |
| 336 | + | |
| 337 | + | |
| 338 | + | |
| 339 | + | |
| 340 | + | |
| 341 | + | |
| 342 | + | |
| 343 | + | |
| 344 | + | |
| 345 | + | |
| 346 | + | |
| 347 | + | |
| 348 | + | |
| 349 | + | |
| 350 | + | |
327 | 351 | | |
328 | | - | |
| 352 | + | |
329 | 353 | | |
330 | 354 | | |
331 | 355 | | |
332 | 356 | | |
333 | 357 | | |
334 | 358 | | |
335 | | - | |
336 | | - | |
337 | | - | |
338 | | - | |
| 359 | + | |
| 360 | + | |
| 361 | + | |
| 362 | + | |
| 363 | + | |
| 364 | + | |
| 365 | + | |
339 | 366 | | |
340 | 367 | | |
341 | | - | |
| 368 | + | |
342 | 369 | | |
343 | 370 | | |
344 | 371 | | |
| |||
357 | 384 | | |
358 | 385 | | |
359 | 386 | | |
360 | | - | |
361 | | - | |
362 | | - | |
363 | | - | |
364 | | - | |
365 | | - | |
366 | | - | |
367 | | - | |
368 | | - | |
369 | | - | |
370 | | - | |
371 | | - | |
372 | | - | |
373 | | - | |
| 387 | + | |
| 388 | + | |
| 389 | + | |
| 390 | + | |
| 391 | + | |
| 392 | + | |
| 393 | + | |
| 394 | + | |
| 395 | + | |
| 396 | + | |
| 397 | + | |
| 398 | + | |
| 399 | + | |
| 400 | + | |
| 401 | + | |
| 402 | + | |
| 403 | + | |
| 404 | + | |
| 405 | + | |
| 406 | + | |
| 407 | + | |
| 408 | + | |
| 409 | + | |
| 410 | + | |
| 411 | + | |
| 412 | + | |
| 413 | + | |
| 414 | + | |
| 415 | + | |
| 416 | + | |
| 417 | + | |
374 | 418 | | |
375 | 419 | | |
376 | 420 | | |
| |||
881 | 925 | | |
882 | 926 | | |
883 | 927 | | |
884 | | - | |
| 928 | + | |
| 929 | + | |
| 930 | + | |
| 931 | + | |
| 932 | + | |
| 933 | + | |
| 934 | + | |
885 | 935 | | |
886 | | - | |
887 | | - | |
888 | | - | |
889 | | - | |
| 936 | + | |
| 937 | + | |
| 938 | + | |
| 939 | + | |
| 940 | + | |
| 941 | + | |
| 942 | + | |
| 943 | + | |
| 944 | + | |
| 945 | + | |
| 946 | + | |
| 947 | + | |
| 948 | + | |
| 949 | + | |
| 950 | + | |
890 | 951 | | |
891 | 952 | | |
892 | | - | |
893 | | - | |
894 | | - | |
895 | | - | |
896 | | - | |
897 | | - | |
898 | | - | |
899 | | - | |
900 | | - | |
901 | | - | |
902 | | - | |
| 953 | + | |
| 954 | + | |
| 955 | + | |
| 956 | + | |
| 957 | + | |
| 958 | + | |
| 959 | + | |
| 960 | + | |
| 961 | + | |
| 962 | + | |
| 963 | + | |
| 964 | + | |
| 965 | + | |
| 966 | + | |
| 967 | + | |
| 968 | + | |
| 969 | + | |
903 | 970 | | |
904 | 971 | | |
905 | 972 | | |
| |||
0 commit comments