@@ -59,17 +59,30 @@ import { BROWSER_SCHEMA } from '$lib/epicenter/schema';
5959 * The observers check `origin` to distinguish remote vs local changes and only
6060 * act on remote changes (when a markdown file changes on the server).
6161 *
62- * Flags for secondary coordination:
63- * - `isProcessingYDocChange `: Set when calling Browser APIs from Y.Doc observers
62+ * Counters for secondary coordination (not booleans - see below) :
63+ * - `yDocChangeCount `: Incremented when calling Browser APIs from Y.Doc observers
6464 * Prevents Browser events from triggering refetch during our own API calls.
65- * - `isRefetching `: Set when syncing Browser → Y.Doc (refetch functions)
65+ * - `refetchCount `: Incremented when syncing Browser → Y.Doc (refetch functions)
6666 * Used as a secondary guard in refetch helpers.
67+ *
68+ * Why counters instead of booleans:
69+ * Multiple async operations can run concurrently. A boolean causes race conditions:
70+ * - Event A sets flag = true, awaits async work
71+ * - Event B sets flag = true, awaits async work
72+ * - Event A completes, sets flag = false (BUG! B is still working)
73+ * - Observer sees false, processes B's side effect, creates infinite loop
74+ *
75+ * With counters:
76+ * - Event A increments to 1, awaits async work
77+ * - Event B increments to 2, awaits async work
78+ * - Event A completes, decrements to 1 (still > 0, protected)
79+ * - Event B completes, decrements to 0
6780 */
6881const syncCoordination = {
69- /** True when we're processing a Y.Doc change ( calling Browser APIs) */
70- isProcessingYDocChange : false ,
71- /** True when we're refetching Browser state into Y.Doc */
72- isRefetching : false ,
82+ /** Count of concurrent Y.Doc change handlers calling Browser APIs */
83+ yDocChangeCount : 0 ,
84+ /** Count of concurrent refetch operations ( Browser → Y.Doc) */
85+ refetchCount : 0 ,
7386} ;
7487
7588// ─────────────────────────────────────────────────────────────────────────────
@@ -382,59 +395,59 @@ export default defineBackground(() => {
382395 // onCreated: Full Tab object provided
383396 browser . tabs . onCreated . addListener ( async ( tab ) => {
384397 await initPromise ;
385- if ( syncCoordination . isProcessingYDocChange ) return ;
398+ if ( syncCoordination . yDocChangeCount > 0 ) return ;
386399 if ( tab . id === undefined ) return ;
387400
388401 const deviceId = await deviceIdPromise ;
389- syncCoordination . isRefetching = true ;
402+ syncCoordination . refetchCount ++ ;
390403 tables . tabs . upsert ( browserTabToRow ( { tab, deviceId } ) ) ;
391- syncCoordination . isRefetching = false ;
404+ syncCoordination . refetchCount -- ;
392405 } ) ;
393406
394407 // onRemoved: Only tabId provided - delete directly
395408 browser . tabs . onRemoved . addListener ( async ( tabId ) => {
396409 await initPromise ;
397- if ( syncCoordination . isProcessingYDocChange ) return ;
410+ if ( syncCoordination . yDocChangeCount > 0 ) return ;
398411
399412 const deviceId = await deviceIdPromise ;
400413 const { TabId } = createCompositeIds ( deviceId ) ;
401- syncCoordination . isRefetching = true ;
414+ syncCoordination . refetchCount ++ ;
402415 tables . tabs . delete ( { id : TabId ( tabId ) } ) ;
403- syncCoordination . isRefetching = false ;
416+ syncCoordination . refetchCount -- ;
404417 } ) ;
405418
406419 // onUpdated: Full Tab object provided (3rd arg)
407420 browser . tabs . onUpdated . addListener ( async ( _tabId , _changeInfo , tab ) => {
408421 await initPromise ;
409- if ( syncCoordination . isProcessingYDocChange ) return ;
422+ if ( syncCoordination . yDocChangeCount > 0 ) return ;
410423 if ( tab . id === undefined ) return ;
411424
412425 const deviceId = await deviceIdPromise ;
413- syncCoordination . isRefetching = true ;
426+ syncCoordination . refetchCount ++ ;
414427 tables . tabs . upsert ( browserTabToRow ( { tab, deviceId } ) ) ;
415- syncCoordination . isRefetching = false ;
428+ syncCoordination . refetchCount -- ;
416429 } ) ;
417430
418431 // onMoved: Only tabId + moveInfo provided - need to query Browser
419432 browser . tabs . onMoved . addListener ( async ( tabId ) => {
420433 await initPromise ;
421- if ( syncCoordination . isProcessingYDocChange ) return ;
434+ if ( syncCoordination . yDocChangeCount > 0 ) return ;
422435
423- syncCoordination . isRefetching = true ;
436+ syncCoordination . refetchCount ++ ;
424437 await upsertTabById ( tabId ) ;
425- syncCoordination . isRefetching = false ;
438+ syncCoordination . refetchCount -- ;
426439 } ) ;
427440
428441 // onActivated: Only activeInfo provided - need to query Browser
429442 // Note: We need to update BOTH the newly activated tab AND the previously active tab
430443 // in the same window (to set active: false on the old one)
431444 browser . tabs . onActivated . addListener ( async ( activeInfo ) => {
432445 await initPromise ;
433- if ( syncCoordination . isProcessingYDocChange ) return ;
446+ if ( syncCoordination . yDocChangeCount > 0 ) return ;
434447
435448 const deviceId = await deviceIdPromise ;
436449 const { TabId, WindowId } = createCompositeIds ( deviceId ) ;
437- syncCoordination . isRefetching = true ;
450+ syncCoordination . refetchCount ++ ;
438451
439452 const deviceWindowId = WindowId ( activeInfo . windowId ) ;
440453 const deviceTabId = TabId ( activeInfo . tabId ) ;
@@ -451,27 +464,27 @@ export default defineBackground(() => {
451464 // Update the newly activated tab
452465 await upsertTabById ( activeInfo . tabId ) ;
453466
454- syncCoordination . isRefetching = false ;
467+ syncCoordination . refetchCount -- ;
455468 } ) ;
456469
457470 // onAttached: Tab moved between windows - need to query Browser
458471 browser . tabs . onAttached . addListener ( async ( tabId ) => {
459472 await initPromise ;
460- if ( syncCoordination . isProcessingYDocChange ) return ;
473+ if ( syncCoordination . yDocChangeCount > 0 ) return ;
461474
462- syncCoordination . isRefetching = true ;
475+ syncCoordination . refetchCount ++ ;
463476 await upsertTabById ( tabId ) ;
464- syncCoordination . isRefetching = false ;
477+ syncCoordination . refetchCount -- ;
465478 } ) ;
466479
467480 // onDetached: Tab detached from window - need to query Browser
468481 browser . tabs . onDetached . addListener ( async ( tabId ) => {
469482 await initPromise ;
470- if ( syncCoordination . isProcessingYDocChange ) return ;
483+ if ( syncCoordination . yDocChangeCount > 0 ) return ;
471484
472- syncCoordination . isRefetching = true ;
485+ syncCoordination . refetchCount ++ ;
473486 await upsertTabById ( tabId ) ;
474- syncCoordination . isRefetching = false ;
487+ syncCoordination . refetchCount -- ;
475488 } ) ;
476489
477490 // ─────────────────────────────────────────────────────────────────────────
@@ -481,37 +494,37 @@ export default defineBackground(() => {
481494 // onCreated: Full Window object provided
482495 browser . windows . onCreated . addListener ( async ( window ) => {
483496 await initPromise ;
484- if ( syncCoordination . isProcessingYDocChange ) return ;
497+ if ( syncCoordination . yDocChangeCount > 0 ) return ;
485498 if ( window . id === undefined ) return ;
486499
487500 const deviceId = await deviceIdPromise ;
488- syncCoordination . isRefetching = true ;
501+ syncCoordination . refetchCount ++ ;
489502 tables . windows . upsert ( browserWindowToRow ( { window, deviceId } ) ) ;
490- syncCoordination . isRefetching = false ;
503+ syncCoordination . refetchCount -- ;
491504 } ) ;
492505
493506 // onRemoved: Only windowId provided - delete directly
494507 browser . windows . onRemoved . addListener ( async ( windowId ) => {
495508 await initPromise ;
496- if ( syncCoordination . isProcessingYDocChange ) return ;
509+ if ( syncCoordination . yDocChangeCount > 0 ) return ;
497510
498511 const deviceId = await deviceIdPromise ;
499512 const { WindowId } = createCompositeIds ( deviceId ) ;
500- syncCoordination . isRefetching = true ;
513+ syncCoordination . refetchCount ++ ;
501514 tables . windows . delete ( { id : WindowId ( windowId ) } ) ;
502- syncCoordination . isRefetching = false ;
515+ syncCoordination . refetchCount -- ;
503516 } ) ;
504517
505518 // onFocusChanged: Only windowId provided - need to query Browser
506519 // Note: windowId can be WINDOW_ID_NONE (-1) when all windows lose focus
507520 // We need to update BOTH the newly focused window AND previously focused windows
508521 browser . windows . onFocusChanged . addListener ( async ( windowId ) => {
509522 await initPromise ;
510- if ( syncCoordination . isProcessingYDocChange ) return ;
523+ if ( syncCoordination . yDocChangeCount > 0 ) return ;
511524
512525 const deviceId = await deviceIdPromise ;
513526 const { WindowId } = createCompositeIds ( deviceId ) ;
514- syncCoordination . isRefetching = true ;
527+ syncCoordination . refetchCount ++ ;
515528
516529 const deviceWindowId = WindowId ( windowId ) ;
517530
@@ -529,7 +542,7 @@ export default defineBackground(() => {
529542 await upsertWindowById ( windowId ) ;
530543 }
531544
532- syncCoordination . isRefetching = false ;
545+ syncCoordination . refetchCount -- ;
533546 } ) ;
534547
535548 // ─────────────────────────────────────────────────────────────────────────
@@ -540,35 +553,35 @@ export default defineBackground(() => {
540553 // onCreated: Full TabGroup object provided
541554 browser . tabGroups . onCreated . addListener ( async ( group ) => {
542555 await initPromise ;
543- if ( syncCoordination . isProcessingYDocChange ) return ;
556+ if ( syncCoordination . yDocChangeCount > 0 ) return ;
544557
545558 const deviceId = await deviceIdPromise ;
546- syncCoordination . isRefetching = true ;
559+ syncCoordination . refetchCount ++ ;
547560 tables . tab_groups . upsert ( browserTabGroupToRow ( { group, deviceId } ) ) ;
548- syncCoordination . isRefetching = false ;
561+ syncCoordination . refetchCount -- ;
549562 } ) ;
550563
551564 // onRemoved: Full TabGroup object provided
552565 browser . tabGroups . onRemoved . addListener ( async ( group ) => {
553566 await initPromise ;
554- if ( syncCoordination . isProcessingYDocChange ) return ;
567+ if ( syncCoordination . yDocChangeCount > 0 ) return ;
555568
556569 const deviceId = await deviceIdPromise ;
557570 const { GroupId } = createCompositeIds ( deviceId ) ;
558- syncCoordination . isRefetching = true ;
571+ syncCoordination . refetchCount ++ ;
559572 tables . tab_groups . delete ( { id : GroupId ( group . id ) } ) ;
560- syncCoordination . isRefetching = false ;
573+ syncCoordination . refetchCount -- ;
561574 } ) ;
562575
563576 // onUpdated: Full TabGroup object provided
564577 browser . tabGroups . onUpdated . addListener ( async ( group ) => {
565578 await initPromise ;
566- if ( syncCoordination . isProcessingYDocChange ) return ;
579+ if ( syncCoordination . yDocChangeCount > 0 ) return ;
567580
568581 const deviceId = await deviceIdPromise ;
569- syncCoordination . isRefetching = true ;
582+ syncCoordination . refetchCount ++ ;
570583 tables . tab_groups . upsert ( browserTabGroupToRow ( { group, deviceId } ) ) ;
571- syncCoordination . isRefetching = false ;
584+ syncCoordination . refetchCount -- ;
572585 } ) ;
573586 }
574587
@@ -619,8 +632,8 @@ export default defineBackground(() => {
619632 }
620633
621634 console . log ( '[Background] tabs.onAdd CREATING tab with URL:' , row . url ) ;
622- // Set flag to prevent Browser events from triggering refetch
623- syncCoordination . isProcessingYDocChange = true ;
635+ // Increment counter to prevent Browser events from triggering refetch
636+ syncCoordination . yDocChangeCount ++ ;
624637 await tryAsync ( {
625638 try : async ( ) => {
626639 // Create the tab with the URL from the markdown file
@@ -632,9 +645,9 @@ export default defineBackground(() => {
632645 // Refetch to clean up - this will:
633646 // 1. Add the new Browser tab (with Browser's real ID)
634647 // 2. Delete the old row (wrong ID from markdown, not in Browser)
635- syncCoordination . isRefetching = true ;
648+ syncCoordination . refetchCount ++ ;
636649 await client . refetchTabs ( ) ;
637- syncCoordination . isRefetching = false ;
650+ syncCoordination . refetchCount -- ;
638651 console . log ( '[Background] tabs.onAdd refetch complete' ) ;
639652 } ,
640653 catch : ( error ) => {
@@ -645,7 +658,7 @@ export default defineBackground(() => {
645658 return Ok ( undefined ) ;
646659 } ,
647660 } ) ;
648- syncCoordination . isProcessingYDocChange = false ;
661+ syncCoordination . yDocChangeCount -- ;
649662 } ,
650663 onDelete : async ( id , transaction ) => {
651664 await initPromise ;
@@ -676,8 +689,8 @@ export default defineBackground(() => {
676689 }
677690
678691 console . log ( '[Background] tabs.onDelete REMOVING Browser tab:' , parsed . tabId ) ;
679- // Set flag to prevent Browser events from triggering refetch
680- syncCoordination . isProcessingYDocChange = true ;
692+ // Increment counter to prevent Browser events from triggering refetch
693+ syncCoordination . yDocChangeCount ++ ;
681694 await tryAsync ( {
682695 try : async ( ) => {
683696 await browser . tabs . remove ( parsed . tabId ) ;
@@ -692,7 +705,7 @@ export default defineBackground(() => {
692705 return Ok ( undefined ) ;
693706 } ,
694707 } ) ;
695- syncCoordination . isProcessingYDocChange = false ;
708+ syncCoordination . yDocChangeCount -- ;
696709 } ,
697710 } ) ;
698711
@@ -709,16 +722,16 @@ export default defineBackground(() => {
709722 // Only process if this window is meant for THIS device
710723 if ( result . data . device_id !== deviceId ) return ;
711724
712- syncCoordination . isProcessingYDocChange = true ;
725+ syncCoordination . yDocChangeCount ++ ;
713726 await tryAsync ( {
714727 try : async ( ) => {
715728 // Create a new window
716729 await browser . windows . create ( { } ) ;
717730
718731 // Refetch to clean up
719- syncCoordination . isRefetching = true ;
732+ syncCoordination . refetchCount ++ ;
720733 await client . refetchWindows ( ) ;
721- syncCoordination . isRefetching = false ;
734+ syncCoordination . refetchCount -- ;
722735 } ,
723736 catch : ( error ) => {
724737 console . log (
@@ -728,7 +741,7 @@ export default defineBackground(() => {
728741 return Ok ( undefined ) ;
729742 } ,
730743 } ) ;
731- syncCoordination . isProcessingYDocChange = false ;
744+ syncCoordination . yDocChangeCount -- ;
732745 } ,
733746 onDelete : async ( id , transaction ) => {
734747 await initPromise ;
@@ -742,7 +755,7 @@ export default defineBackground(() => {
742755 // Only close windows that belong to THIS device
743756 if ( ! parsed || parsed . deviceId !== deviceId ) return ;
744757
745- syncCoordination . isProcessingYDocChange = true ;
758+ syncCoordination . yDocChangeCount ++ ;
746759 await tryAsync ( {
747760 try : async ( ) => {
748761 await browser . windows . remove ( parsed . windowId ) ;
@@ -752,7 +765,7 @@ export default defineBackground(() => {
752765 return Ok ( undefined ) ;
753766 } ,
754767 } ) ;
755- syncCoordination . isProcessingYDocChange = false ;
768+ syncCoordination . yDocChangeCount -- ;
756769 } ,
757770 } ) ;
758771
@@ -770,7 +783,7 @@ export default defineBackground(() => {
770783 // Only ungroup tabs from THIS device's groups
771784 if ( ! parsed || parsed . deviceId !== deviceId ) return ;
772785
773- syncCoordination . isProcessingYDocChange = true ;
786+ syncCoordination . yDocChangeCount ++ ;
774787 await tryAsync ( {
775788 try : async ( ) => {
776789 // Note: Browser doesn't have tabGroups.remove(), but we can ungroup tabs
@@ -789,7 +802,7 @@ export default defineBackground(() => {
789802 return Ok ( undefined ) ;
790803 } ,
791804 } ) ;
792- syncCoordination . isProcessingYDocChange = false ;
805+ syncCoordination . yDocChangeCount -- ;
793806 } ,
794807 } ) ;
795808 }
0 commit comments