Skip to content

Commit 077c511

Browse files
committed
fix(nostr): prevent duplicate events, feedback loops, and relay filter issues
Why: Nostr sync was causing duplicate bookmarks and potential infinite loops What: - Add event deduplication with processedEventIds tracking - Use transaction origin to prevent feedback loop when applying synced changes - Switch to #t (indexed) filter instead of #app (not indexed by most relays) - Fix d-tag parsing for bookmark IDs containing colons - Add concurrent initialization guard with initializationPromise - Convert between Y.Map and plain objects for consistent Yjs storage - Defer initialization to not block initial render
1 parent ca2e013 commit 077c511

2 files changed

Lines changed: 183 additions & 61 deletions

File tree

src/hooks/useNostrSync.js

Lines changed: 165 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
*/
2020

2121
import { useState, useEffect, useCallback, useRef } from 'react'
22+
import * as Y from 'yjs'
2223
import { NostrSyncService, CONNECTION_STATES } from '../services/nostr-sync'
2324
import { retrieveLEK } from '../services/key-storage'
2425
import { getYdocInstance } from './useYjs'
@@ -34,6 +35,44 @@ import {
3435
let nostrSyncService = null
3536
let nostrSyncListeners = []
3637
let performanceManager = null
38+
let initializationPromise = null // Guard against concurrent initialization
39+
let processedEventIds = new Set() // Track processed events to prevent duplicates
40+
let deletedBookmarkIds = new Set() // Track deleted bookmarks to skip duplicate delete events
41+
42+
/**
43+
* Convert a plain bookmark object to a Y.Map for Yjs storage
44+
* @param {Object} bookmarkData - Plain bookmark object from Nostr
45+
* @returns {Y.Map} - Y.Map instance
46+
*/
47+
function bookmarkDataToYMap(bookmarkData) {
48+
const ymap = new Y.Map()
49+
50+
// Set all bookmark properties
51+
if (bookmarkData.url) ymap.set('url', bookmarkData.url)
52+
if (bookmarkData.title) ymap.set('title', bookmarkData.title)
53+
if (bookmarkData.description) ymap.set('description', bookmarkData.description)
54+
if (bookmarkData.favicon) ymap.set('favicon', bookmarkData.favicon)
55+
if (bookmarkData.preview) ymap.set('preview', bookmarkData.preview)
56+
if (bookmarkData.readLater !== undefined) ymap.set('readLater', bookmarkData.readLater)
57+
if (bookmarkData.createdAt) ymap.set('createdAt', bookmarkData.createdAt)
58+
if (bookmarkData.updatedAt) ymap.set('updatedAt', bookmarkData.updatedAt)
59+
60+
// Handle tags array - convert to Y.Array
61+
if (bookmarkData.tags && Array.isArray(bookmarkData.tags) && bookmarkData.tags.length > 0) {
62+
const tagsArray = new Y.Array()
63+
// Filter out any undefined/null values before pushing
64+
const validTags = bookmarkData.tags.filter(t => t != null)
65+
if (validTags.length > 0) {
66+
tagsArray.push(...validTags)
67+
}
68+
ymap.set('tags', tagsArray)
69+
} else {
70+
// Set empty tags array
71+
ymap.set('tags', new Y.Array())
72+
}
73+
74+
return ymap
75+
}
3776

3877
/**
3978
* Notify all listeners of service changes
@@ -84,23 +123,44 @@ export function getPerformanceManager() {
84123
* @returns {Promise<NostrSyncService>}
85124
*/
86125
export async function initializeNostrSync(lek, options = {}) {
126+
// Already initialized - return existing service
87127
if (nostrSyncService?.isInitialized) {
88128
console.log('[useNostrSync] Service already initialized')
89129
return nostrSyncService
90130
}
91131

132+
// Initialization in progress - wait for it to complete
133+
if (initializationPromise) {
134+
console.log('[useNostrSync] Initialization already in progress, waiting...')
135+
return initializationPromise
136+
}
137+
92138
console.log('[useNostrSync] Initializing Nostr sync service')
93139

94-
nostrSyncService = new NostrSyncService({
95-
debug: options.debug || false,
96-
autoReconnect: options.autoReconnect !== false,
97-
...options,
98-
})
140+
// Create initialization promise to prevent concurrent initialization
141+
initializationPromise = (async () => {
142+
try {
143+
nostrSyncService = new NostrSyncService({
144+
debug: true, // TEMP: enable debug for troubleshooting
145+
autoReconnect: options.autoReconnect !== false,
146+
...options,
147+
})
148+
149+
await nostrSyncService.initialize(lek)
150+
notifyNostrSyncListeners()
151+
152+
// Debug: expose service on window for inspection
153+
if (typeof window !== 'undefined') {
154+
window.__nostrSyncService = nostrSyncService
155+
}
99156

100-
await nostrSyncService.initialize(lek)
101-
notifyNostrSyncListeners()
157+
return nostrSyncService
158+
} finally {
159+
initializationPromise = null
160+
}
161+
})()
102162

103-
return nostrSyncService
163+
return initializationPromise
104164
}
105165

106166
/**
@@ -290,58 +350,80 @@ export function useNostrSync(options = {}) {
290350
// Set up bookmark sync subscription with performance optimizations
291351
bookmarkSubscriptionRef.current = await nostrSyncService.subscribeToBookmarks(
292352
async (bookmarkId, bookmarkData, event) => {
293-
// Handle incoming bookmark updates
294-
const receiveStart = Date.now()
295-
296-
// Track received bookmarks for batched processing
297-
receivedBookmarksRef.current.set(bookmarkId, {
298-
data: bookmarkData,
299-
event,
300-
receivedAt: receiveStart,
301-
})
302-
303-
// Record in diagnostics
304-
try {
305-
getNostrDiagnostics().recordReceive(event?.id, 'bookmark', bookmarkId)
306-
} catch (e) {
307-
// Diagnostics recording should not break sync
353+
// Early validation - skip invalid/empty bookmarks immediately
354+
if (!bookmarkData || !bookmarkData.url || !bookmarkData.title) {
355+
return
308356
}
309357

310-
// Apply to Yjs document
311-
const ydoc = getYdocInstance()
312-
if (ydoc) {
313-
const bookmarksMap = ydoc.getMap('bookmarks')
314-
// Only apply if different from local (simple conflict avoidance)
315-
const existing = bookmarksMap.get(bookmarkId)
316-
if (!existing || existing.updatedAt < bookmarkData.updatedAt) {
317-
bookmarksMap.set(bookmarkId, bookmarkData)
358+
// Deduplicate: skip if we've already processed this event
359+
if (event?.id && processedEventIds.has(event.id)) {
360+
return
361+
}
362+
if (event?.id) {
363+
processedEventIds.add(event.id)
364+
// Limit set size to prevent memory leak
365+
if (processedEventIds.size > 1000) {
366+
const idsToRemove = [...processedEventIds].slice(0, 500)
367+
idsToRemove.forEach(id => processedEventIds.delete(id))
318368
}
319369
}
320370

321-
// Record network latency for performance optimization
322-
if (performanceManager) {
323-
performanceManager.recordNetworkLatency(Date.now() - receiveStart)
324-
}
371+
// Defer processing to not block the main thread
372+
setTimeout(() => {
373+
// Apply to Yjs document
374+
const ydoc = getYdocInstance()
375+
if (ydoc) {
376+
const bookmarksMap = ydoc.getMap('bookmarks')
377+
// Only apply if different from local (simple conflict avoidance)
378+
const existing = bookmarksMap.get(bookmarkId)
379+
// Handle both Y.Map (local) and plain object (legacy) formats
380+
const existingUpdatedAt = existing?.get ? existing.get('updatedAt') : existing?.updatedAt
381+
if (!existing || !existingUpdatedAt || existingUpdatedAt < bookmarkData.updatedAt) {
382+
// Convert plain object to Y.Map for consistent Yjs storage
383+
const bookmarkYMap = bookmarkDataToYMap(bookmarkData)
384+
// Use transaction with 'nostr-sync' origin so observer knows not to re-publish
385+
ydoc.transact(() => {
386+
bookmarksMap.set(bookmarkId, bookmarkYMap)
387+
}, 'nostr-sync')
388+
}
389+
}
390+
setLastSyncTime(Date.now())
391+
}, 0)
325392

326393
setLastSyncTime(Date.now())
327394
},
328395
async (bookmarkId, event) => {
329-
// Handle bookmark deletions
330-
console.log('[useNostrSync] Received bookmark deletion:', bookmarkId)
331-
setLastSyncTime(Date.now())
332-
333-
// Record in diagnostics
334-
try {
335-
getNostrDiagnostics().recordReceive(event?.id, 'delete', bookmarkId)
336-
} catch (e) {
337-
// Diagnostics recording should not break sync
396+
// Deduplicate: skip if we've already processed this event
397+
if (event?.id && processedEventIds.has(event.id)) {
398+
return
399+
}
400+
if (event?.id) {
401+
processedEventIds.add(event.id)
338402
}
339403

340-
const ydoc = getYdocInstance()
341-
if (ydoc) {
342-
const bookmarksMap = ydoc.getMap('bookmarks')
343-
bookmarksMap.delete(bookmarkId)
404+
// Deduplicate: skip if we've already deleted this bookmark
405+
if (deletedBookmarkIds.has(bookmarkId)) {
406+
return
407+
}
408+
deletedBookmarkIds.add(bookmarkId)
409+
// Limit set size
410+
if (deletedBookmarkIds.size > 500) {
411+
const idsToRemove = [...deletedBookmarkIds].slice(0, 250)
412+
idsToRemove.forEach(id => deletedBookmarkIds.delete(id))
344413
}
414+
415+
// Defer to not block main thread
416+
setTimeout(() => {
417+
const ydoc = getYdocInstance()
418+
if (ydoc) {
419+
const bookmarksMap = ydoc.getMap('bookmarks')
420+
// Use transaction with 'nostr-sync' origin so observer knows not to re-publish
421+
ydoc.transact(() => {
422+
bookmarksMap.delete(bookmarkId)
423+
}, 'nostr-sync')
424+
}
425+
setLastSyncTime(Date.now())
426+
}, 0)
345427
}
346428
)
347429

@@ -363,17 +445,35 @@ export function useNostrSync(options = {}) {
363445
}
364446
}
365447

448+
// Clean up existing observer before attaching new one
449+
// (prevents duplicate observers if initialize runs multiple times)
450+
if (yjsObserverRef.current) {
451+
bookmarksMap.unobserve(yjsObserverRef.current)
452+
}
453+
366454
yjsObserverRef.current = (ymapEvent) => {
455+
// Skip publishing for changes that came from Nostr sync (avoid feedback loop)
456+
if (ymapEvent.transaction.origin === 'nostr-sync') {
457+
return
458+
}
459+
367460
ymapEvent.changes.keys.forEach((change, key) => {
368-
console.log('[useNostrSync] Bookmark change detected:', {
369-
action: change.action,
370-
bookmarkId: key,
371-
serviceInitialized: nostrSyncService?.isInitialized
372-
})
373461
if (nostrSyncService && nostrSyncService.isInitialized) {
374462
if (change.action === 'add' || change.action === 'update') {
375-
const bookmarkData = bookmarksMap.get(key)
376-
if (bookmarkData) {
463+
const bookmarkYMap = bookmarksMap.get(key)
464+
if (bookmarkYMap) {
465+
// Convert Y.Map to plain object for publishing
466+
const bookmarkData = bookmarkYMap.get ? {
467+
url: bookmarkYMap.get('url'),
468+
title: bookmarkYMap.get('title'),
469+
description: bookmarkYMap.get('description') || '',
470+
tags: bookmarkYMap.get('tags')?.toArray() || [],
471+
readLater: bookmarkYMap.get('readLater') || false,
472+
favicon: bookmarkYMap.get('favicon') || null,
473+
preview: bookmarkYMap.get('preview') || null,
474+
createdAt: bookmarkYMap.get('createdAt'),
475+
updatedAt: bookmarkYMap.get('updatedAt'),
476+
} : bookmarkYMap // Already a plain object
377477
// Queue for debounced publishing
378478
nostrSyncService.queueBookmarkUpdate(key, bookmarkData)
379479
}
@@ -499,14 +599,22 @@ export function useNostrSync(options = {}) {
499599
return null
500600
}, [])
501601

502-
// Auto-initialize effect
602+
// Auto-initialize effect - deferred to not block initial render
503603
useEffect(() => {
604+
let timeoutId = null
605+
504606
if (autoInitialize) {
505-
initialize()
607+
// Defer initialization to let the UI render first
608+
timeoutId = setTimeout(() => {
609+
initialize()
610+
}, 100)
506611
}
507612

508613
// Cleanup on unmount
509614
return () => {
615+
if (timeoutId) {
616+
clearTimeout(timeoutId)
617+
}
510618
if (yjsObserverRef.current) {
511619
const ydoc = getYdocInstance()
512620
if (ydoc) {

src/services/nostr-sync.js

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1228,22 +1228,32 @@ export class NostrSyncService {
12281228

12291229
const xOnlyPubkey = getXOnlyPubkey(this.nostrKeypair.publicKeyBytes)
12301230

1231+
// Note: We use #t (indexed) instead of #app (not indexed by most relays)
1232+
// The app tag is verified client-side in the event handler
12311233
const filters = [
12321234
{
12331235
kinds: [NOSTR_KINDS.REPLACEABLE_EVENT],
12341236
authors: [xOnlyPubkey],
1235-
'#app': ['hypermark'],
1237+
'#t': ['bookmark'],
12361238
},
12371239
{
12381240
kinds: [NOSTR_KINDS.DELETE],
12391241
authors: [xOnlyPubkey],
1240-
'#app': ['hypermark'],
1242+
// Delete events don't have #t tag, so just filter by kind and author
1243+
// We verify it's our event by checking the 'a' tag references our kind
12411244
},
12421245
]
12431246

12441247
return this.subscribe(filters, async (event, relayUrl) => {
12451248
try {
12461249
if (event.kind === NOSTR_KINDS.REPLACEABLE_EVENT) {
1250+
// Verify this is a hypermark event (client-side check since #app isn't indexed)
1251+
const appTag = event.tags.find(t => t[0] === 'app')
1252+
if (!appTag || appTag[1] !== 'hypermark') {
1253+
this._log('Ignoring non-hypermark event', { eventId: event.id, app: appTag?.[1] })
1254+
return
1255+
}
1256+
12471257
// Extract bookmark ID from 'd' tag
12481258
const dTag = event.tags.find(t => t[0] === 'd')
12491259
if (!dTag) {
@@ -1267,9 +1277,12 @@ export class NostrSyncService {
12671277
if (!aTag) return
12681278

12691279
// Parse addressable event reference (kind:pubkey:d-tag)
1280+
// Note: d-tag may contain colons (e.g., "bookmark:123456-abc")
1281+
// so we only split on first 2 colons
12701282
const parts = aTag[1].split(':')
12711283
if (parts.length >= 3) {
1272-
const bookmarkId = parts[2]
1284+
// Rejoin everything from index 2 onwards to get the full d-tag
1285+
const bookmarkId = parts.slice(2).join(':')
12731286
if (onBookmarkDelete) {
12741287
await onBookmarkDelete(bookmarkId, event)
12751288
}
@@ -1765,6 +1778,7 @@ export class NostrSyncService {
17651778
return
17661779
}
17671780

1781+
this._log(`Event received and validated from ${relayUrl}`, { id: event?.id?.substring(0, 8), kind: event?.kind })
17681782
// Call subscription handler
17691783
await subscription.onEvent(event, relayUrl)
17701784

@@ -1779,7 +1793,7 @@ export class NostrSyncService {
17791793
}
17801794

17811795
_handleEndOfStoredEvents(relayUrl, [subscriptionId]) {
1782-
this._log(`End of stored events for subscription ${subscriptionId} from ${relayUrl}`)
1796+
this._log(`EOSE: End of stored events for subscription ${subscriptionId} from ${relayUrl}`)
17831797
}
17841798

17851799
_handleNoticeMessage(relayUrl, [message]) {

0 commit comments

Comments
 (0)