fix(repost): dedupe echo of just-published kind-6 against optimistic count#325
fix(repost): dedupe echo of just-published kind-6 against optimistic count#325
Conversation
…count
Reposts double-counted by 1 after the user clicked the repost button.
NoteRepost.svelte incremented the count optimistically on click, then
the relay echoed the kind-6 back through the shared engagement
subscription and processRepost() unconditionally ran `count++` a
second time. Reactions and zaps already had content-level optimistic
matching (optimisticReactions / optimisticZaps) to skip the echo;
reposts had neither that nor a markEventAsProcessed call.
Fix: mirror the reaction pattern.
- engagementCache.ts: add an optimisticReposts Map keyed by
`${targetEventId}:${userPubkey}` (NIP-18 is one-per-user-per-target
so no emoji-style distinction is needed). Export
trackOptimisticRepost / clearOptimisticRepost. processRepost() now
takes an optional targetEventId and, when event.pubkey ===
userPublickey, checks+deletes the matching optimistic entry before
incrementing. Both call sites (fetchEngagement single sub at line
~479, batchFetchEngagement at line ~1001) pass the eventId through.
cleanupEngagement and clearAllEngagementCaches clean up the new Map.
- NoteRepost.svelte: call trackOptimisticRepost before the optimistic
store update, markEventAsProcessed between sign() and publish() to
close the race where the echo arrives before publish() resolves,
and clearOptimisticRepost on publish failure alongside the count
rollback. Also removes a pre-existing unused `get` import.
Baseline preserved: eslint clean on the two edited files; pnpm check
still 4 errors in the three pre-existing files (kitchens.ts,
nourishDiscovery.ts, FoodstrFeedOptimized.svelte untouched).
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ❌ Deployment failed View logs |
frontend | e04d358 | Apr 21 2026, 10:47 PM |
There was a problem hiding this comment.
Pull request overview
Fixes repost count being incremented twice by deduping the relay echo of a just-published kind-6 against the optimistic UI increment.
Changes:
- Add optimistic repost tracking to
engagementCacheand skip counting when the echoed repost matches an optimistic entry. - Update
NoteRepost.svelteto register optimistic reposts pre-awaitand pre-mark processed ids to reduce echo races. - Bump package version.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
src/lib/engagementCache.ts |
Introduces optimisticReposts and integrates it into repost processing + cache cleanup. |
src/components/NoteRepost.svelte |
Registers optimistic reposts before async work and pre-marks processed ids to avoid double counts. |
package.json |
Version bump to reflect the fix release. |
Comments suppressed due to low confidence (1)
src/components/NoteRepost.svelte:101
markEventAsProcessed(event.id, repostEvent.id)is set beforepublish(), but the catch block rolls back the optimistic UI state without removing that processed-id entry. Ifpublish()throws after partially succeeding (e.g., some relays accepted the event), the subscription echo for this repost id will be dropped byprocessed.has(event.id)and the UI will stay rolled back even though the repost exists. Consider either (a) only marking as processed after a confirmed successful publish, relying onoptimisticRepostsfor pre-publish dedup, or (b) adding an engagementCache API to unmark/remove the processed id on rollback (and using it here).
// Secondary protection — mark by event id so the subscription's
// `processed.has(event.id)` short-circuit fires even if the optimistic
// key check misses. Done between sign() (which populates .id) and
// publish() to close the race where the echo arrives before publish()
// resolves.
if (repostEvent.id) {
markEventAsProcessed(event.id, repostEvent.id);
}
await repostEvent.publish();
console.log('Successfully reposted');
} catch (error) {
console.error('Error reposting:', error);
// Revert optimistic update + clear the optimistic-repost tracking so
// a retry can re-register cleanly.
clearOptimisticRepost(event.id, $userPublickey);
store.update((s) => ({
...s,
reposts: { count: s.reposts.count - 1, userReposted: false }
}));
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // key check misses. Done between sign() (which populates .id) and | ||
| // publish() to close the race where the echo arrives before publish() | ||
| // resolves. | ||
| if (repostEvent.id) { |
There was a problem hiding this comment.
markEventAsProcessed is a no-op if processedEventIds hasn’t been initialized for this event.id yet (it only adds when the Set exists). Since fetchEngagement(...) is started in onMount and not awaited, a very fast click could call this before the Set is created, weakening the intended dedup. Consider ensuring the Set exists (e.g., have markEventAsProcessed init-if-absent) or otherwise guarantee engagement initialization before marking.
| if (repostEvent.id) { | |
| if (repostEvent.id) { | |
| // Ensure engagement state has been initialized for this note before | |
| // marking, otherwise markEventAsProcessed(...) can be a no-op if a | |
| // very fast click beats the onMount-triggered fetch. | |
| await fetchEngagement(event.id); |
… timing Two Copilot review concerns on PR #325, both tied to markEventAsProcessed timing: 1. (inline) markEventAsProcessed was a no-op if processedEventIds hadn't been initialized yet — a fast click between NoteRepost onMount and the non-awaited fetchEngagement would silently drop the mark and weaken the secondary dedup. 2. (suppressed low-confidence) Pre-publish mark + catch-block rollback leaves a dangling processed-id if publish throws after partial success. Echoes from relays that did accept the kind-6 would be dropped by processed.has(), so the UI (rolled back to 0) would stay out of sync with relay reality. Fix: - engagementCache.ts: markEventAsProcessed now init-if-absent. A later fetchEngagement sees the Set exists → takes re-entry branch → counts preserved → subscription starts normally. This also benefits ReactionTrigger, which has the same latent race. - NoteRepost.svelte: move markEventAsProcessed to AFTER publish() resolves, matching the ReactionTrigger pattern. The pre-await trackOptimisticRepost is the primary defense against the pre-publish-resolve echo race (content-match by targetEventId + userPubkey); the post-publish mark is only the secondary safety net for later re-deliveries. On partial-publish-fail no mark is left, so the echo from accepting relays reaches processRepost with optimisticReposts already cleared and the count increments to reflect relay reality. Baseline: eslint clean; pnpm check still 4 pre-existing errors in untouched files.
The bug
Clicking the repost button bumps the displayed repost count by 2 instead of 1. Net effect mirrors the reaction/comment inflation we fixed in #321 and #323, but was untouched by either of them because the repost path never had optimistic-echo dedup to begin with.
Root cause
NoteRepost.sveltedoes an optimisticcount + 1on click, then publishes the kind-6. The relay echoes the kind-6 back through the shared engagement subscription, andprocessRepost()inengagementCache.tsunconditionally runscount++a second time.optimisticReactionscontent-matching +markEventAsProcessedid-matching.optimisticZapsamount-matching.processed.has(event.id)short-circuit works correctly).Fix
Mirror the reaction pattern. NIP-18 is one-repost-per-user-per-target, so the optimistic key is
(targetEventId, userPubkey)— no emoji-style distinction needed.src/lib/engagementCache.tsoptimisticReposts: Map<string, { timestamp: number }>keyed by\${targetEventId}:\${userPubkey}.trackOptimisticRepost(targetEventId, userPubkey)andclearOptimisticRepost(targetEventId, userPubkey).processRepost(data, event, userPublickey, targetEventId?)— newtargetEventIdparam. Whenevent.pubkey === userPublickey, check-and-delete the matching optimistic entry and early-return before thecount++.batchFetchEngagementat line ~1001) pass the eventId through.cleanupEngagement(eventId)andclearAllEngagementCaches()clean up the new Map.src/components/NoteRepost.sveltetrackOptimisticRepost(event.id, \$userPublickey)before the optimistic store update and before anyawait.markEventAsProcessed(event.id, repostEvent.id)betweensign()(which populates.id) andpublish()— closes the race where the echo arrives beforepublish()resolves.clearOptimisticRepost(event.id, \$userPublickey)on publish failure alongside the count rollback.getimport (trivial cleanup while in the file).Verification
Out of scope
markEventAsProcessedalone — could work without theoptimisticRepostsMap if we trusted the pre-publish mark to always win the race. The two-layer approach matches reactions and is more defensive; a single-layer alternative would be simpler but brittle to echoes landing beforepublish()resolves.🤖 Generated with Claude Code