1919 */
2020
2121import { useState , useEffect , useCallback , useRef } from 'react'
22+ import * as Y from 'yjs'
2223import { NostrSyncService , CONNECTION_STATES } from '../services/nostr-sync'
2324import { retrieveLEK } from '../services/key-storage'
2425import { getYdocInstance } from './useYjs'
@@ -34,6 +35,44 @@ import {
3435let nostrSyncService = null
3536let nostrSyncListeners = [ ]
3637let 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 */
86125export 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 ) {
0 commit comments