@@ -77,6 +77,17 @@ function createMockDoc( clientID = 1 ) {
7777 return { clientID, on : jest . fn ( ) , off : jest . fn ( ) } ;
7878}
7979
80+ // Helper to extract the onDocUpdate callback registered via doc.on('updateV2', ...).
81+ function getOnDocUpdate ( doc : ReturnType < typeof createMockDoc > ) {
82+ const call = doc . on . mock . calls . find (
83+ ( args : unknown [ ] ) => args [ 0 ] === 'updateV2'
84+ ) ;
85+ if ( ! call ) {
86+ throw new Error ( 'onDocUpdate not registered' ) ;
87+ }
88+ return call [ 1 ] as ( update : Uint8Array , origin : unknown ) => void ;
89+ }
90+
8091function createMockAwareness ( ) {
8192 return {
8293 clientID : 1 ,
@@ -141,17 +152,6 @@ describe( 'polling-manager', () => {
141152 } ) ;
142153
143154 describe ( 'document size limit' , ( ) => {
144- // Helper to extract the onDocUpdate callback registered via doc.on('updateV2', ...).
145- function getOnDocUpdate ( doc : ReturnType < typeof createMockDoc > ) {
146- const call = doc . on . mock . calls . find (
147- ( args : unknown [ ] ) => args [ 0 ] === 'updateV2'
148- ) ;
149- if ( ! call ) {
150- throw new Error ( 'onDocUpdate not registered' ) ;
151- }
152- return call [ 1 ] as ( update : Uint8Array , origin : unknown ) => void ;
153- }
154-
155155 it ( 'emits document-size-limit-exceeded error when an update exceeds the size limit' , async ( ) => {
156156 mockPostSyncUpdate . mockResolvedValue ( syncResponse ) ;
157157
@@ -552,6 +552,257 @@ describe( 'polling-manager', () => {
552552 } ) ;
553553 } ) ;
554554
555+ describe ( 'collaborator queue resumption' , ( ) => {
556+ it ( 'resumes non-primary room queues when collaborators are detected on primary room' , async ( ) => {
557+ // First poll: primary room has collaborators, collection room has none.
558+ mockPostSyncUpdate . mockResolvedValue ( {
559+ rooms : [
560+ {
561+ room : 'primary-room' ,
562+ end_cursor : 1 ,
563+ awareness : {
564+ 1 : { collaboratorInfo : { id : 100 } } ,
565+ 2 : { collaboratorInfo : { id : 200 } } ,
566+ } ,
567+ updates : [ ] ,
568+ } ,
569+ {
570+ room : 'collection-room' ,
571+ end_cursor : 1 ,
572+ awareness : { } ,
573+ updates : [ ] ,
574+ } ,
575+ ] ,
576+ } ) ;
577+
578+ // Register primary room first (becomes isPrimaryRoom).
579+ pollingManager . registerRoom ( {
580+ room : 'primary-room' ,
581+ doc : createMockDoc ( 1 ) ,
582+ awareness : createMockAwareness ( ) ,
583+ log : jest . fn ( ) ,
584+ onStatusChange : jest . fn ( ) ,
585+ onSync : jest . fn ( ) ,
586+ } ) ;
587+
588+ pollingManager . registerRoom ( {
589+ room : 'collection-room' ,
590+ doc : createMockDoc ( 2 ) ,
591+ awareness : createMockAwareness ( ) ,
592+ log : jest . fn ( ) ,
593+ onStatusChange : jest . fn ( ) ,
594+ onSync : jest . fn ( ) ,
595+ } ) ;
596+
597+ // First poll: detects collaborators on primary room, resumes all queues.
598+ await jest . advanceTimersByTimeAsync ( 0 ) ;
599+
600+ // Second poll: collection room queue should now be unpaused,
601+ // so its initial sync_step1 update should be included.
602+ mockPostSyncUpdate . mockResolvedValue ( {
603+ rooms : [
604+ {
605+ room : 'primary-room' ,
606+ end_cursor : 2 ,
607+ awareness : {
608+ 1 : { collaboratorInfo : { id : 100 } } ,
609+ 2 : { collaboratorInfo : { id : 200 } } ,
610+ } ,
611+ updates : [ ] ,
612+ } ,
613+ {
614+ room : 'collection-room' ,
615+ end_cursor : 2 ,
616+ awareness : { } ,
617+ updates : [ ] ,
618+ } ,
619+ ] ,
620+ } ) ;
621+
622+ await jest . advanceTimersByTimeAsync ( 1000 ) ;
623+
624+ // The second call should include non-empty updates for the collection room.
625+ const secondCallPayload = mockPostSyncUpdate . mock . calls [ 1 ] [ 0 ] ;
626+ const collectionRoom = secondCallPayload . rooms . find (
627+ ( r : { room : string } ) => r . room === 'collection-room'
628+ ) ;
629+ expect ( collectionRoom ! . updates . length ) . toBeGreaterThan ( 0 ) ;
630+ } ) ;
631+
632+ it ( 'does not resume non-primary room queues when no collaborators are detected' , async ( ) => {
633+ // Only 1 client (self) — no collaborators.
634+ mockPostSyncUpdate . mockResolvedValue ( {
635+ rooms : [
636+ {
637+ room : 'primary-room' ,
638+ end_cursor : 1 ,
639+ awareness : { 1 : { collaboratorInfo : { id : 100 } } } ,
640+ updates : [ ] ,
641+ } ,
642+ {
643+ room : 'collection-room' ,
644+ end_cursor : 1 ,
645+ awareness : { } ,
646+ updates : [ ] ,
647+ } ,
648+ ] ,
649+ } ) ;
650+
651+ pollingManager . registerRoom ( {
652+ room : 'primary-room' ,
653+ doc : createMockDoc ( 1 ) ,
654+ awareness : createMockAwareness ( ) ,
655+ log : jest . fn ( ) ,
656+ onStatusChange : jest . fn ( ) ,
657+ onSync : jest . fn ( ) ,
658+ } ) ;
659+
660+ pollingManager . registerRoom ( {
661+ room : 'collection-room' ,
662+ doc : createMockDoc ( 2 ) ,
663+ awareness : createMockAwareness ( ) ,
664+ log : jest . fn ( ) ,
665+ onStatusChange : jest . fn ( ) ,
666+ onSync : jest . fn ( ) ,
667+ } ) ;
668+
669+ // First poll: no collaborators.
670+ await jest . advanceTimersByTimeAsync ( 0 ) ;
671+
672+ // Second poll: collection room queue should still be paused.
673+ await jest . advanceTimersByTimeAsync ( 4000 ) ;
674+
675+ const secondCallPayload = mockPostSyncUpdate . mock . calls [ 1 ] [ 0 ] ;
676+ const collectionRoom = secondCallPayload . rooms . find (
677+ ( r : { room : string } ) => r . room === 'collection-room'
678+ ) ;
679+ expect ( collectionRoom ! . updates ) . toEqual ( [ ] ) ;
680+ } ) ;
681+
682+ it ( 'sends accumulated collection room updates after collaborator detection' , async ( ) => {
683+ // First poll: no collaborators.
684+ mockPostSyncUpdate . mockResolvedValue ( {
685+ rooms : [
686+ {
687+ room : 'primary-room' ,
688+ end_cursor : 1 ,
689+ awareness : { 1 : { collaboratorInfo : { id : 100 } } } ,
690+ updates : [ ] ,
691+ } ,
692+ {
693+ room : 'collection-room' ,
694+ end_cursor : 1 ,
695+ awareness : { } ,
696+ updates : [ ] ,
697+ } ,
698+ ] ,
699+ } ) ;
700+
701+ const collectionDoc = createMockDoc ( 2 ) ;
702+
703+ pollingManager . registerRoom ( {
704+ room : 'primary-room' ,
705+ doc : createMockDoc ( 1 ) ,
706+ awareness : createMockAwareness ( ) ,
707+ log : jest . fn ( ) ,
708+ onStatusChange : jest . fn ( ) ,
709+ onSync : jest . fn ( ) ,
710+ } ) ;
711+
712+ pollingManager . registerRoom ( {
713+ room : 'collection-room' ,
714+ doc : collectionDoc ,
715+ awareness : createMockAwareness ( ) ,
716+ log : jest . fn ( ) ,
717+ onStatusChange : jest . fn ( ) ,
718+ onSync : jest . fn ( ) ,
719+ } ) ;
720+
721+ // First poll: no collaborators, queues stay paused.
722+ await jest . advanceTimersByTimeAsync ( 0 ) ;
723+
724+ // Simulate a local doc update on the collection room (e.g., a note was saved).
725+ const onDocUpdate = getOnDocUpdate ( collectionDoc ) ;
726+ onDocUpdate ( new Uint8Array ( [ 1 , 2 , 3 ] ) , 'local-origin' ) ;
727+
728+ // Second poll: still no collaborators, collection room updates should be empty.
729+ mockPostSyncUpdate . mockResolvedValue ( {
730+ rooms : [
731+ {
732+ room : 'primary-room' ,
733+ end_cursor : 2 ,
734+ awareness : { 1 : { collaboratorInfo : { id : 100 } } } ,
735+ updates : [ ] ,
736+ } ,
737+ {
738+ room : 'collection-room' ,
739+ end_cursor : 2 ,
740+ awareness : { } ,
741+ updates : [ ] ,
742+ } ,
743+ ] ,
744+ } ) ;
745+ await jest . advanceTimersByTimeAsync ( 4000 ) ;
746+
747+ const secondCallPayload = mockPostSyncUpdate . mock . calls [ 1 ] [ 0 ] ;
748+ const collectionRoomPoll2 = secondCallPayload . rooms . find (
749+ ( r : { room : string } ) => r . room === 'collection-room'
750+ ) ;
751+ expect ( collectionRoomPoll2 ! . updates ) . toEqual ( [ ] ) ;
752+
753+ // Third poll: collaborator joins — queues should be resumed.
754+ mockPostSyncUpdate . mockResolvedValue ( {
755+ rooms : [
756+ {
757+ room : 'primary-room' ,
758+ end_cursor : 3 ,
759+ awareness : {
760+ 1 : { collaboratorInfo : { id : 100 } } ,
761+ 2 : { collaboratorInfo : { id : 200 } } ,
762+ } ,
763+ updates : [ ] ,
764+ } ,
765+ {
766+ room : 'collection-room' ,
767+ end_cursor : 3 ,
768+ awareness : { } ,
769+ updates : [ ] ,
770+ } ,
771+ ] ,
772+ } ) ;
773+ await jest . advanceTimersByTimeAsync ( 4000 ) ;
774+
775+ // Fourth poll: collection room should now send accumulated updates.
776+ mockPostSyncUpdate . mockResolvedValue ( {
777+ rooms : [
778+ {
779+ room : 'primary-room' ,
780+ end_cursor : 4 ,
781+ awareness : {
782+ 1 : { collaboratorInfo : { id : 100 } } ,
783+ 2 : { collaboratorInfo : { id : 200 } } ,
784+ } ,
785+ updates : [ ] ,
786+ } ,
787+ {
788+ room : 'collection-room' ,
789+ end_cursor : 4 ,
790+ awareness : { } ,
791+ updates : [ ] ,
792+ } ,
793+ ] ,
794+ } ) ;
795+ await jest . advanceTimersByTimeAsync ( 1000 ) ;
796+
797+ const fourthCallPayload = mockPostSyncUpdate . mock . calls [ 3 ] [ 0 ] ;
798+ const collectionRoomPoll4 = fourthCallPayload . rooms . find (
799+ ( r : { room : string } ) => r . room === 'collection-room'
800+ ) ;
801+ // Should include the initial sync_step1 update + the local update.
802+ expect ( collectionRoomPoll4 ! . updates . length ) . toBeGreaterThan ( 0 ) ;
803+ } ) ;
804+ } ) ;
805+
555806 describe ( 'visibility change' , ( ) => {
556807 it ( 'does not spawn a duplicate poll when a request is in-flight' , ( ) => {
557808 // Keep the first postSyncUpdate pending so we can simulate
0 commit comments