@wordpress/core-data 7.41.2-next.v.202603102151.0 → 7.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (166) hide show
  1. package/CHANGELOG.md +1 -1
  2. package/README.md +19 -0
  3. package/build/actions.cjs +17 -25
  4. package/build/actions.cjs.map +2 -2
  5. package/build/awareness/post-editor-awareness.cjs +46 -6
  6. package/build/awareness/post-editor-awareness.cjs.map +2 -2
  7. package/build/awareness/types.cjs.map +1 -1
  8. package/build/entities.cjs +33 -7
  9. package/build/entities.cjs.map +2 -2
  10. package/build/hooks/use-entity-prop.cjs +2 -1
  11. package/build/hooks/use-entity-prop.cjs.map +2 -2
  12. package/build/hooks/use-post-editor-awareness-state.cjs +84 -3
  13. package/build/hooks/use-post-editor-awareness-state.cjs.map +2 -2
  14. package/build/index.cjs +3 -0
  15. package/build/index.cjs.map +2 -2
  16. package/build/private-apis.cjs +3 -1
  17. package/build/private-apis.cjs.map +2 -2
  18. package/build/queried-data/get-query-parts.cjs +7 -0
  19. package/build/queried-data/get-query-parts.cjs.map +2 -2
  20. package/build/queried-data/selectors.cjs +19 -5
  21. package/build/queried-data/selectors.cjs.map +2 -2
  22. package/build/reducer.cjs +6 -0
  23. package/build/reducer.cjs.map +2 -2
  24. package/build/resolvers.cjs +110 -74
  25. package/build/resolvers.cjs.map +2 -2
  26. package/build/selectors.cjs +29 -0
  27. package/build/selectors.cjs.map +2 -2
  28. package/build/sync.cjs +3 -0
  29. package/build/sync.cjs.map +2 -2
  30. package/build/types.cjs +16 -0
  31. package/build/types.cjs.map +3 -3
  32. package/build/utils/block-selection-history.cjs +1 -1
  33. package/build/utils/block-selection-history.cjs.map +2 -2
  34. package/build/utils/crdt-blocks.cjs +17 -3
  35. package/build/utils/crdt-blocks.cjs.map +2 -2
  36. package/build/utils/crdt-selection.cjs +4 -1
  37. package/build/utils/crdt-selection.cjs.map +2 -2
  38. package/build/utils/crdt-user-selections.cjs +9 -6
  39. package/build/utils/crdt-user-selections.cjs.map +2 -2
  40. package/build/utils/crdt-utils.cjs +54 -2
  41. package/build/utils/crdt-utils.cjs.map +2 -2
  42. package/build/utils/crdt.cjs +4 -23
  43. package/build/utils/crdt.cjs.map +2 -2
  44. package/build/utils/index.cjs +3 -0
  45. package/build/utils/index.cjs.map +2 -2
  46. package/build/utils/normalize-query-for-resolution.cjs +35 -0
  47. package/build/utils/normalize-query-for-resolution.cjs.map +7 -0
  48. package/build-module/actions.mjs +17 -25
  49. package/build-module/actions.mjs.map +2 -2
  50. package/build-module/awareness/post-editor-awareness.mjs +46 -6
  51. package/build-module/awareness/post-editor-awareness.mjs.map +2 -2
  52. package/build-module/entities.mjs +33 -7
  53. package/build-module/entities.mjs.map +2 -2
  54. package/build-module/hooks/use-entity-prop.mjs +2 -1
  55. package/build-module/hooks/use-entity-prop.mjs.map +2 -2
  56. package/build-module/hooks/use-post-editor-awareness-state.mjs +81 -2
  57. package/build-module/hooks/use-post-editor-awareness-state.mjs.map +2 -2
  58. package/build-module/index.mjs +2 -0
  59. package/build-module/index.mjs.map +2 -2
  60. package/build-module/private-apis.mjs +6 -2
  61. package/build-module/private-apis.mjs.map +2 -2
  62. package/build-module/queried-data/get-query-parts.mjs +7 -0
  63. package/build-module/queried-data/get-query-parts.mjs.map +2 -2
  64. package/build-module/queried-data/selectors.mjs +19 -5
  65. package/build-module/queried-data/selectors.mjs.map +2 -2
  66. package/build-module/reducer.mjs +6 -0
  67. package/build-module/reducer.mjs.map +2 -2
  68. package/build-module/resolvers.mjs +112 -75
  69. package/build-module/resolvers.mjs.map +2 -2
  70. package/build-module/selectors.mjs +28 -0
  71. package/build-module/selectors.mjs.map +2 -2
  72. package/build-module/sync.mjs +2 -0
  73. package/build-module/sync.mjs.map +2 -2
  74. package/build-module/types.mjs +9 -0
  75. package/build-module/types.mjs.map +4 -4
  76. package/build-module/utils/block-selection-history.mjs +5 -2
  77. package/build-module/utils/block-selection-history.mjs.map +2 -2
  78. package/build-module/utils/crdt-blocks.mjs +17 -3
  79. package/build-module/utils/crdt-blocks.mjs.map +2 -2
  80. package/build-module/utils/crdt-selection.mjs +8 -2
  81. package/build-module/utils/crdt-selection.mjs.map +2 -2
  82. package/build-module/utils/crdt-user-selections.mjs +10 -7
  83. package/build-module/utils/crdt-user-selections.mjs.map +2 -2
  84. package/build-module/utils/crdt-utils.mjs +51 -1
  85. package/build-module/utils/crdt-utils.mjs.map +2 -2
  86. package/build-module/utils/crdt.mjs +4 -23
  87. package/build-module/utils/crdt.mjs.map +2 -2
  88. package/build-module/utils/index.mjs +2 -0
  89. package/build-module/utils/index.mjs.map +2 -2
  90. package/build-module/utils/normalize-query-for-resolution.mjs +14 -0
  91. package/build-module/utils/normalize-query-for-resolution.mjs.map +7 -0
  92. package/build-types/actions.d.ts.map +1 -1
  93. package/build-types/awareness/post-editor-awareness.d.ts +2 -2
  94. package/build-types/awareness/post-editor-awareness.d.ts.map +1 -1
  95. package/build-types/awareness/types.d.ts +1 -1
  96. package/build-types/awareness/types.d.ts.map +1 -1
  97. package/build-types/entities.d.ts +1 -1
  98. package/build-types/entities.d.ts.map +1 -1
  99. package/build-types/hooks/use-entity-prop.d.ts.map +1 -1
  100. package/build-types/hooks/use-post-editor-awareness-state.d.ts +34 -10
  101. package/build-types/hooks/use-post-editor-awareness-state.d.ts.map +1 -1
  102. package/build-types/index.d.ts +2 -0
  103. package/build-types/index.d.ts.map +1 -1
  104. package/build-types/private-apis.d.ts.map +1 -1
  105. package/build-types/queried-data/get-query-parts.d.ts +7 -0
  106. package/build-types/queried-data/get-query-parts.d.ts.map +1 -1
  107. package/build-types/queried-data/selectors.d.ts.map +1 -1
  108. package/build-types/reducer.d.ts.map +1 -1
  109. package/build-types/resolvers.d.ts +2 -1
  110. package/build-types/resolvers.d.ts.map +1 -1
  111. package/build-types/selectors.d.ts +17 -0
  112. package/build-types/selectors.d.ts.map +1 -1
  113. package/build-types/sync.d.ts +2 -2
  114. package/build-types/sync.d.ts.map +1 -1
  115. package/build-types/types.d.ts +18 -1
  116. package/build-types/types.d.ts.map +1 -1
  117. package/build-types/utils/block-selection-history.d.ts.map +1 -1
  118. package/build-types/utils/crdt-blocks.d.ts.map +1 -1
  119. package/build-types/utils/crdt-selection.d.ts.map +1 -1
  120. package/build-types/utils/crdt-user-selections.d.ts +9 -5
  121. package/build-types/utils/crdt-user-selections.d.ts.map +1 -1
  122. package/build-types/utils/crdt-utils.d.ts +20 -0
  123. package/build-types/utils/crdt-utils.d.ts.map +1 -1
  124. package/build-types/utils/crdt.d.ts +6 -7
  125. package/build-types/utils/crdt.d.ts.map +1 -1
  126. package/build-types/utils/index.d.ts +1 -0
  127. package/build-types/utils/normalize-query-for-resolution.d.ts +12 -0
  128. package/build-types/utils/normalize-query-for-resolution.d.ts.map +1 -0
  129. package/build-types/utils/test/crdt-utils.d.ts +2 -0
  130. package/build-types/utils/test/crdt-utils.d.ts.map +1 -0
  131. package/package.json +18 -18
  132. package/src/actions.js +25 -40
  133. package/src/awareness/post-editor-awareness.ts +106 -7
  134. package/src/awareness/test/post-editor-awareness.ts +50 -10
  135. package/src/awareness/types.ts +1 -1
  136. package/src/entities.js +38 -6
  137. package/src/hooks/test/use-post-editor-awareness-state.ts +446 -3
  138. package/src/hooks/use-entity-prop.js +2 -0
  139. package/src/hooks/use-post-editor-awareness-state.ts +160 -8
  140. package/src/index.js +1 -0
  141. package/src/private-apis.js +6 -2
  142. package/src/queried-data/get-query-parts.js +13 -0
  143. package/src/queried-data/selectors.js +33 -8
  144. package/src/queried-data/test/get-query-parts.js +34 -0
  145. package/src/queried-data/test/selectors.js +183 -0
  146. package/src/reducer.js +11 -0
  147. package/src/resolvers.js +136 -88
  148. package/src/selectors.ts +56 -0
  149. package/src/sync.ts +2 -0
  150. package/src/test/entities.js +185 -1
  151. package/src/test/resolvers.js +64 -11
  152. package/src/test/selectors.js +150 -0
  153. package/src/test/store.js +66 -0
  154. package/src/types.ts +26 -1
  155. package/src/utils/block-selection-history.ts +5 -2
  156. package/src/utils/crdt-blocks.ts +32 -3
  157. package/src/utils/crdt-selection.ts +8 -2
  158. package/src/utils/crdt-user-selections.ts +20 -8
  159. package/src/utils/crdt-utils.ts +99 -0
  160. package/src/utils/crdt.ts +8 -32
  161. package/src/utils/index.js +1 -0
  162. package/src/utils/normalize-query-for-resolution.js +23 -0
  163. package/src/utils/test/crdt-blocks.ts +146 -0
  164. package/src/utils/test/crdt-user-selections.ts +5 -0
  165. package/src/utils/test/crdt-utils.ts +387 -0
  166. package/src/utils/test/crdt.ts +120 -53
@@ -11,6 +11,9 @@ import {
11
11
  useResolvedSelection,
12
12
  useGetDebugData,
13
13
  useIsDisconnected,
14
+ useOnCollaboratorJoin,
15
+ useOnCollaboratorLeave,
16
+ useOnPostSave,
14
17
  } from '../use-post-editor-awareness-state';
15
18
  import { getSyncManager } from '../../sync';
16
19
  import { SelectionType } from '../../utils/crdt-user-selections';
@@ -66,6 +69,9 @@ describe( 'use-post-editor-awareness-state hooks', () => {
66
69
  onStateChange: jest.Mock;
67
70
  convertSelectionStateToAbsolute: jest.Mock;
68
71
  getDebugData: jest.Mock;
72
+ doc: {
73
+ getMap: jest.Mock;
74
+ };
69
75
  };
70
76
  let mockSyncManager: {
71
77
  getAwareness: jest.Mock;
@@ -73,9 +79,29 @@ describe( 'use-post-editor-awareness-state hooks', () => {
73
79
  let stateChangeCallback:
74
80
  | ( ( newState: PostEditorAwarenessState[] ) => void )
75
81
  | null;
82
+ let stateMapObserver:
83
+ | ( ( event: { keysChanged: Set< string > } ) => void )
84
+ | null;
85
+ let mockStateMapData: Record< string, unknown >;
86
+ let mockRecordMapData: Record< string, unknown >;
76
87
 
77
88
  beforeEach( () => {
78
89
  stateChangeCallback = null;
90
+ stateMapObserver = null;
91
+ mockStateMapData = {};
92
+ mockRecordMapData = {};
93
+
94
+ const mockStateMap = {
95
+ get: jest.fn( ( key: string ) => mockStateMapData[ key ] ),
96
+ observe: jest.fn( ( observer: typeof stateMapObserver ) => {
97
+ stateMapObserver = observer;
98
+ } ),
99
+ unobserve: jest.fn(),
100
+ };
101
+
102
+ const mockRecordMap = {
103
+ get: jest.fn( ( key: string ) => mockRecordMapData[ key ] ),
104
+ };
79
105
 
80
106
  mockAwareness = {
81
107
  setUp: jest.fn(),
@@ -86,6 +112,17 @@ describe( 'use-post-editor-awareness-state hooks', () => {
86
112
  } ),
87
113
  convertSelectionStateToAbsolute: jest.fn().mockReturnValue( null ),
88
114
  getDebugData: jest.fn().mockReturnValue( createMockDebugData() ),
115
+ doc: {
116
+ getMap: jest.fn( ( name: string ) => {
117
+ if ( name === 'state' ) {
118
+ return mockStateMap;
119
+ }
120
+ if ( name === 'document' ) {
121
+ return mockRecordMap;
122
+ }
123
+ return null;
124
+ } ),
125
+ },
89
126
  };
90
127
 
91
128
  mockSyncManager = {
@@ -256,7 +293,7 @@ describe( 'use-post-editor-awareness-state hooks', () => {
256
293
  };
257
294
 
258
295
  expect( result.current( mockSelection ) ).toEqual( {
259
- textIndex: null,
296
+ richTextOffset: null,
260
297
  localClientId: null,
261
298
  } );
262
299
  } );
@@ -270,7 +307,7 @@ describe( 'use-post-editor-awareness-state hooks', () => {
270
307
  },
271
308
  };
272
309
  mockAwareness.convertSelectionStateToAbsolute.mockReturnValue( {
273
- textIndex: 10,
310
+ richTextOffset: 10,
274
311
  localClientId: 'block-1',
275
312
  } );
276
313
 
@@ -284,7 +321,7 @@ describe( 'use-post-editor-awareness-state hooks', () => {
284
321
  mockAwareness.convertSelectionStateToAbsolute
285
322
  ).toHaveBeenCalledWith( mockSelection );
286
323
  expect( position ).toEqual( {
287
- textIndex: 10,
324
+ richTextOffset: 10,
288
325
  localClientId: 'block-1',
289
326
  } );
290
327
  } );
@@ -481,4 +518,410 @@ describe( 'use-post-editor-awareness-state hooks', () => {
481
518
  expect( result.current ).toBe( false );
482
519
  } );
483
520
  } );
521
+
522
+ describe( 'useOnCollaboratorJoin', () => {
523
+ const me = createMockActiveUser( {
524
+ clientId: 1,
525
+ isMe: true,
526
+ collaboratorInfo: {
527
+ id: 1,
528
+ name: 'Me',
529
+ slug: 'me',
530
+ avatar_urls: mockAvatarUrls,
531
+ browserType: 'Chrome',
532
+ enteredAt: 1704067200000,
533
+ },
534
+ } );
535
+
536
+ const alice = createMockActiveUser( {
537
+ clientId: 2,
538
+ isMe: false,
539
+ collaboratorInfo: {
540
+ id: 100,
541
+ name: 'Alice',
542
+ slug: 'alice',
543
+ avatar_urls: mockAvatarUrls,
544
+ browserType: 'Chrome',
545
+ enteredAt: 1704067300000,
546
+ },
547
+ } );
548
+
549
+ test( 'should not fire on initial mount', () => {
550
+ const callback = jest.fn();
551
+ mockAwareness.getCurrentState.mockReturnValue( [ me, alice ] );
552
+
553
+ renderHook( () => useOnCollaboratorJoin( 123, 'post', callback ) );
554
+
555
+ expect( callback ).not.toHaveBeenCalled();
556
+ } );
557
+
558
+ test( 'should not fire when collaborators load after initially empty state', async () => {
559
+ const callback = jest.fn();
560
+ mockAwareness.getCurrentState.mockReturnValue( [] );
561
+
562
+ renderHook( () => useOnCollaboratorJoin( 123, 'post', callback ) );
563
+
564
+ // Simulate store hydration: collaborators appear
565
+ act( () => {
566
+ stateChangeCallback?.( [ me, alice ] );
567
+ } );
568
+
569
+ await waitFor( () => {
570
+ expect( callback ).not.toHaveBeenCalled();
571
+ } );
572
+ } );
573
+
574
+ test( 'should fire callback when a new collaborator joins', async () => {
575
+ const callback = jest.fn();
576
+ mockAwareness.getCurrentState.mockReturnValue( [ me ] );
577
+
578
+ renderHook( () => useOnCollaboratorJoin( 123, 'post', callback ) );
579
+
580
+ // Alice joins
581
+ act( () => {
582
+ stateChangeCallback?.( [ me, alice ] );
583
+ } );
584
+
585
+ await waitFor( () => {
586
+ expect( callback ).toHaveBeenCalledWith( alice, me );
587
+ } );
588
+ } );
589
+
590
+ test( 'should not fire callback for the current user', async () => {
591
+ const callback = jest.fn();
592
+ mockAwareness.getCurrentState.mockReturnValue( [] );
593
+
594
+ renderHook( () => useOnCollaboratorJoin( 123, 'post', callback ) );
595
+
596
+ // First: hydrate with alice so prevCollaborators is non-empty
597
+ act( () => {
598
+ stateChangeCallback?.( [ alice ] );
599
+ } );
600
+
601
+ // Now "me" appears
602
+ act( () => {
603
+ stateChangeCallback?.( [ alice, me ] );
604
+ } );
605
+
606
+ await waitFor( () => {
607
+ expect( callback ).not.toHaveBeenCalled();
608
+ } );
609
+ } );
610
+
611
+ test( 'should not fire when postId is null', () => {
612
+ const callback = jest.fn();
613
+
614
+ renderHook( () => useOnCollaboratorJoin( null, 'post', callback ) );
615
+
616
+ expect( callback ).not.toHaveBeenCalled();
617
+ expect( mockSyncManager.getAwareness ).not.toHaveBeenCalled();
618
+ } );
619
+ } );
620
+
621
+ describe( 'useOnCollaboratorLeave', () => {
622
+ const me = createMockActiveUser( {
623
+ clientId: 1,
624
+ isMe: true,
625
+ } );
626
+
627
+ const alice = createMockActiveUser( {
628
+ clientId: 2,
629
+ isMe: false,
630
+ isConnected: true,
631
+ collaboratorInfo: {
632
+ id: 100,
633
+ name: 'Alice',
634
+ slug: 'alice',
635
+ avatar_urls: mockAvatarUrls,
636
+ browserType: 'Chrome',
637
+ enteredAt: 1704067300000,
638
+ },
639
+ } );
640
+
641
+ test( 'should fire callback when a connected collaborator disconnects', async () => {
642
+ const callback = jest.fn();
643
+ mockAwareness.getCurrentState.mockReturnValue( [ me, alice ] );
644
+
645
+ renderHook( () => useOnCollaboratorLeave( 123, 'post', callback ) );
646
+
647
+ // Alice disconnects
648
+ act( () => {
649
+ stateChangeCallback?.( [
650
+ me,
651
+ { ...alice, isConnected: false },
652
+ ] );
653
+ } );
654
+
655
+ await waitFor( () => {
656
+ expect( callback ).toHaveBeenCalledWith( alice );
657
+ } );
658
+ } );
659
+
660
+ test( 'should fire callback when a connected collaborator disappears from the list', async () => {
661
+ const callback = jest.fn();
662
+ mockAwareness.getCurrentState.mockReturnValue( [ me, alice ] );
663
+
664
+ renderHook( () => useOnCollaboratorLeave( 123, 'post', callback ) );
665
+
666
+ // Alice disappears entirely
667
+ act( () => {
668
+ stateChangeCallback?.( [ me ] );
669
+ } );
670
+
671
+ await waitFor( () => {
672
+ expect( callback ).toHaveBeenCalledWith( alice );
673
+ } );
674
+ } );
675
+
676
+ test( 'should not fire callback when an already-disconnected collaborator is removed', async () => {
677
+ const callback = jest.fn();
678
+ const disconnectedAlice = { ...alice, isConnected: false };
679
+ mockAwareness.getCurrentState.mockReturnValue( [
680
+ me,
681
+ disconnectedAlice,
682
+ ] );
683
+
684
+ renderHook( () => useOnCollaboratorLeave( 123, 'post', callback ) );
685
+
686
+ // Disconnected Alice is removed from list (cleanup after delay)
687
+ act( () => {
688
+ stateChangeCallback?.( [ me ] );
689
+ } );
690
+
691
+ await waitFor( () => {
692
+ expect( callback ).not.toHaveBeenCalled();
693
+ } );
694
+ } );
695
+
696
+ test( 'should not fire callback for the current user disconnecting', async () => {
697
+ const callback = jest.fn();
698
+ mockAwareness.getCurrentState.mockReturnValue( [ me, alice ] );
699
+
700
+ renderHook( () => useOnCollaboratorLeave( 123, 'post', callback ) );
701
+
702
+ // "Me" disconnects
703
+ act( () => {
704
+ stateChangeCallback?.( [
705
+ { ...me, isConnected: false },
706
+ alice,
707
+ ] );
708
+ } );
709
+
710
+ await waitFor( () => {
711
+ expect( callback ).not.toHaveBeenCalled();
712
+ } );
713
+ } );
714
+
715
+ test( 'should not fire on initial mount', () => {
716
+ const callback = jest.fn();
717
+ mockAwareness.getCurrentState.mockReturnValue( [ me, alice ] );
718
+
719
+ renderHook( () => useOnCollaboratorLeave( 123, 'post', callback ) );
720
+
721
+ expect( callback ).not.toHaveBeenCalled();
722
+ } );
723
+ } );
724
+
725
+ describe( 'useOnPostSave', () => {
726
+ const me = createMockActiveUser( {
727
+ clientId: 1,
728
+ isMe: true,
729
+ } );
730
+
731
+ const alice = createMockActiveUser( {
732
+ clientId: 2,
733
+ isMe: false,
734
+ collaboratorInfo: {
735
+ id: 100,
736
+ name: 'Alice',
737
+ slug: 'alice',
738
+ avatar_urls: mockAvatarUrls,
739
+ browserType: 'Chrome',
740
+ enteredAt: 1704067300000,
741
+ },
742
+ } );
743
+
744
+ test( 'should fire callback when a remote collaborator saves', async () => {
745
+ const callback = jest.fn();
746
+ mockAwareness.getCurrentState.mockReturnValue( [ me, alice ] );
747
+
748
+ renderHook( () => useOnPostSave( 123, 'post', callback ) );
749
+
750
+ // Simulate a save event via the Y.Doc state map
751
+ const savedAt = Date.now() + 1000;
752
+ mockStateMapData = {
753
+ savedAt,
754
+ savedBy: alice.clientId,
755
+ };
756
+ mockRecordMapData = { status: 'draft' };
757
+
758
+ act( () => {
759
+ stateMapObserver?.( {
760
+ keysChanged: new Set( [ 'savedAt' ] ),
761
+ } );
762
+ } );
763
+
764
+ await waitFor( () => {
765
+ expect( callback ).toHaveBeenCalledWith(
766
+ {
767
+ savedAt,
768
+ savedByClientId: alice.clientId,
769
+ postStatus: 'draft',
770
+ },
771
+ alice,
772
+ null
773
+ );
774
+ } );
775
+ } );
776
+
777
+ test( 'should pass previous save event on subsequent saves', async () => {
778
+ const callback = jest.fn();
779
+ mockAwareness.getCurrentState.mockReturnValue( [ me, alice ] );
780
+
781
+ renderHook( () => useOnPostSave( 123, 'post', callback ) );
782
+
783
+ // First save
784
+ const firstSavedAt = Date.now() + 1000;
785
+ mockStateMapData = {
786
+ savedAt: firstSavedAt,
787
+ savedBy: alice.clientId,
788
+ };
789
+ mockRecordMapData = { status: 'draft' };
790
+
791
+ act( () => {
792
+ stateMapObserver?.( {
793
+ keysChanged: new Set( [ 'savedAt' ] ),
794
+ } );
795
+ } );
796
+
797
+ await waitFor( () => {
798
+ expect( callback ).toHaveBeenCalledTimes( 1 );
799
+ } );
800
+
801
+ // Second save
802
+ const secondSavedAt = Date.now() + 2000;
803
+ mockStateMapData = {
804
+ savedAt: secondSavedAt,
805
+ savedBy: alice.clientId,
806
+ };
807
+ mockRecordMapData = { status: 'publish' };
808
+
809
+ act( () => {
810
+ stateMapObserver?.( {
811
+ keysChanged: new Set( [ 'savedAt' ] ),
812
+ } );
813
+ } );
814
+
815
+ await waitFor( () => {
816
+ expect( callback ).toHaveBeenCalledTimes( 2 );
817
+ } );
818
+
819
+ expect( callback ).toHaveBeenLastCalledWith(
820
+ {
821
+ savedAt: secondSavedAt,
822
+ savedByClientId: alice.clientId,
823
+ postStatus: 'publish',
824
+ },
825
+ alice,
826
+ {
827
+ savedAt: firstSavedAt,
828
+ savedByClientId: alice.clientId,
829
+ postStatus: 'draft',
830
+ }
831
+ );
832
+ } );
833
+
834
+ test( 'should not fire callback when the current user saves', async () => {
835
+ const callback = jest.fn();
836
+ mockAwareness.getCurrentState.mockReturnValue( [ me, alice ] );
837
+
838
+ renderHook( () => useOnPostSave( 123, 'post', callback ) );
839
+
840
+ // Simulate a save event by "me"
841
+ const savedAt = Date.now() + 1000;
842
+ mockStateMapData = {
843
+ savedAt,
844
+ savedBy: me.clientId,
845
+ };
846
+ mockRecordMapData = { status: 'draft' };
847
+
848
+ act( () => {
849
+ stateMapObserver?.( {
850
+ keysChanged: new Set( [ 'savedAt' ] ),
851
+ } );
852
+ } );
853
+
854
+ await waitFor( () => {
855
+ expect( callback ).not.toHaveBeenCalled();
856
+ } );
857
+ } );
858
+
859
+ test( 'should not fire callback when saver is not in the collaborator list', async () => {
860
+ const callback = jest.fn();
861
+ mockAwareness.getCurrentState.mockReturnValue( [ me ] );
862
+
863
+ renderHook( () => useOnPostSave( 123, 'post', callback ) );
864
+
865
+ // Simulate a save from an unknown client
866
+ const savedAt = Date.now() + 1000;
867
+ mockStateMapData = {
868
+ savedAt,
869
+ savedBy: 99999,
870
+ };
871
+ mockRecordMapData = { status: 'draft' };
872
+
873
+ act( () => {
874
+ stateMapObserver?.( {
875
+ keysChanged: new Set( [ 'savedAt' ] ),
876
+ } );
877
+ } );
878
+
879
+ await waitFor( () => {
880
+ expect( callback ).not.toHaveBeenCalled();
881
+ } );
882
+ } );
883
+
884
+ test( 'should not fire duplicate callbacks for the same savedAt timestamp', async () => {
885
+ const callback = jest.fn();
886
+ mockAwareness.getCurrentState.mockReturnValue( [ me, alice ] );
887
+
888
+ renderHook( () => useOnPostSave( 123, 'post', callback ) );
889
+
890
+ const savedAt = Date.now() + 1000;
891
+ mockStateMapData = {
892
+ savedAt,
893
+ savedBy: alice.clientId,
894
+ };
895
+ mockRecordMapData = { status: 'draft' };
896
+
897
+ // First save event
898
+ act( () => {
899
+ stateMapObserver?.( {
900
+ keysChanged: new Set( [ 'savedAt' ] ),
901
+ } );
902
+ } );
903
+
904
+ await waitFor( () => {
905
+ expect( callback ).toHaveBeenCalledTimes( 1 );
906
+ } );
907
+
908
+ // Same savedAt again (e.g. component re-render)
909
+ act( () => {
910
+ stateMapObserver?.( {
911
+ keysChanged: new Set( [ 'savedAt' ] ),
912
+ } );
913
+ } );
914
+
915
+ // Should still be 1 call
916
+ expect( callback ).toHaveBeenCalledTimes( 1 );
917
+ } );
918
+
919
+ test( 'should not fire when postId is null', () => {
920
+ const callback = jest.fn();
921
+
922
+ renderHook( () => useOnPostSave( null, 'post', callback ) );
923
+
924
+ expect( callback ).not.toHaveBeenCalled();
925
+ } );
926
+ } );
484
927
  } );
@@ -50,6 +50,8 @@ export default function useEntityProp( kind, name, prop, _id ) {
50
50
  {
51
51
  per_page: -1,
52
52
  context: 'edit',
53
+ _fields:
54
+ 'id,date,author,meta,title.raw,excerpt.raw,content.raw',
53
55
  }
54
56
  );
55
57
  const entityConfig = select( STORE_NAME ).getEntityConfig(
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * External dependencies
3
3
  */
4
+ import { usePrevious } from '@wordpress/compose';
4
5
  import { useEffect, useState } from '@wordpress/element';
5
6
  import type { Y } from '@wordpress/sync';
6
7
 
@@ -13,14 +14,9 @@ import type {
13
14
  PostSaveEvent,
14
15
  YDocDebugData,
15
16
  } from '../awareness/types';
16
- import type { SelectionState } from '../types';
17
+ import type { SelectionState, ResolvedSelection } from '../types';
17
18
  import type { PostEditorAwareness } from '../awareness/post-editor-awareness';
18
19
 
19
- interface ResolvedSelection {
20
- textIndex: number | null;
21
- localClientId: string | null;
22
- }
23
-
24
20
  interface AwarenessState {
25
21
  activeCollaborators: ActiveCollaborator[];
26
22
  resolveSelection: ( selection: SelectionState ) => ResolvedSelection;
@@ -29,7 +25,7 @@ interface AwarenessState {
29
25
  }
30
26
 
31
27
  const defaultResolvedSelection: ResolvedSelection = {
32
- textIndex: null,
28
+ richTextOffset: null,
33
29
  localClientId: null,
34
30
  };
35
31
 
@@ -167,7 +163,7 @@ export function useIsDisconnected(
167
163
  * @param postId The ID of the post.
168
164
  * @param postType The type of the post.
169
165
  */
170
- export function useLastPostSave(
166
+ function useLastPostSave(
171
167
  postId: number | null,
172
168
  postType: string | null
173
169
  ): PostSaveEvent | null {
@@ -226,3 +222,159 @@ export function useLastPostSave(
226
222
 
227
223
  return lastSave;
228
224
  }
225
+
226
+ /**
227
+ * Hook that fires a callback when a new collaborator joins the post.
228
+ * Handles initial hydration and state diffing internally—consumers
229
+ * only receive "join" events for collaborators that appear after the
230
+ * initial state has loaded.
231
+ *
232
+ * The callback receives the joining collaborator and, when available,
233
+ * the current user's state (useful for comparing `enteredAt` timestamps).
234
+ *
235
+ * @param postId The ID of the post.
236
+ * @param postType The type of the post.
237
+ * @param callback Invoked for each collaborator that joins.
238
+ */
239
+ export function useOnCollaboratorJoin(
240
+ postId: number | null,
241
+ postType: string | null,
242
+ callback: (
243
+ collaborator: ActiveCollaborator,
244
+ me?: ActiveCollaborator
245
+ ) => void
246
+ ): void {
247
+ const { activeCollaborators } = usePostEditorAwarenessState(
248
+ postId,
249
+ postType
250
+ );
251
+ const prevCollaborators = usePrevious( activeCollaborators );
252
+
253
+ useEffect( () => {
254
+ /*
255
+ * On first render usePrevious returns undefined. On subsequent
256
+ * renders the list may still be empty while the store hydrates.
257
+ * In both cases, skip to avoid spurious "joined" callbacks for
258
+ * users already present.
259
+ */
260
+ if ( ! prevCollaborators || prevCollaborators.length === 0 ) {
261
+ return;
262
+ }
263
+
264
+ const prevMap = new Map< number, ActiveCollaborator >(
265
+ prevCollaborators.map( ( collaborator ) => [
266
+ collaborator.clientId,
267
+ collaborator,
268
+ ] )
269
+ );
270
+ const me = activeCollaborators.find(
271
+ ( collaborator ) => collaborator.isMe
272
+ );
273
+
274
+ for ( const collaborator of activeCollaborators ) {
275
+ if (
276
+ ! prevMap.has( collaborator.clientId ) &&
277
+ ! collaborator.isMe
278
+ ) {
279
+ callback( collaborator, me );
280
+ }
281
+ }
282
+ }, [ activeCollaborators, prevCollaborators, callback ] );
283
+ }
284
+
285
+ /**
286
+ * Hook that fires a callback when a collaborator leaves the post.
287
+ * A "leave" is detected when a previously-connected collaborator either
288
+ * transitions to `isConnected = false` or disappears from the list
289
+ * entirely while still connected. Already-disconnected collaborators
290
+ * that are later removed from the list are silently ignored.
291
+ *
292
+ * @param postId The ID of the post.
293
+ * @param postType The type of the post.
294
+ * @param callback Invoked for each collaborator that leaves.
295
+ */
296
+ export function useOnCollaboratorLeave(
297
+ postId: number | null,
298
+ postType: string | null,
299
+ callback: ( collaborator: ActiveCollaborator ) => void
300
+ ): void {
301
+ const { activeCollaborators } = usePostEditorAwarenessState(
302
+ postId,
303
+ postType
304
+ );
305
+ const prevCollaborators = usePrevious( activeCollaborators );
306
+
307
+ useEffect( () => {
308
+ if ( ! prevCollaborators || prevCollaborators.length === 0 ) {
309
+ return;
310
+ }
311
+
312
+ const newMap = new Map< number, ActiveCollaborator >(
313
+ activeCollaborators.map( ( collaborator ) => [
314
+ collaborator.clientId,
315
+ collaborator,
316
+ ] )
317
+ );
318
+
319
+ for ( const prevCollab of prevCollaborators ) {
320
+ if ( prevCollab.isMe || ! prevCollab.isConnected ) {
321
+ continue;
322
+ }
323
+
324
+ const newCollab = newMap.get( prevCollab.clientId );
325
+ if ( ! newCollab?.isConnected ) {
326
+ callback( prevCollab );
327
+ }
328
+ }
329
+ }, [ activeCollaborators, prevCollaborators, callback ] );
330
+ }
331
+
332
+ /**
333
+ * Hook that fires a callback when a remote collaborator saves the post.
334
+ * Only fires for saves by other collaborators (not the current user).
335
+ * Deduplicates by `savedAt` timestamp so the same save event is never
336
+ * reported twice.
337
+ *
338
+ * @param postId The ID of the post.
339
+ * @param postType The type of the post.
340
+ * @param callback Invoked with the save event, the collaborator who saved,
341
+ * and the previous save event (if any) for transition detection.
342
+ */
343
+ export function useOnPostSave(
344
+ postId: number | null,
345
+ postType: string | null,
346
+ callback: (
347
+ event: PostSaveEvent,
348
+ saver: ActiveCollaborator,
349
+ prevEvent: PostSaveEvent | null
350
+ ) => void
351
+ ): void {
352
+ const { activeCollaborators } = usePostEditorAwarenessState(
353
+ postId,
354
+ postType
355
+ );
356
+ const lastPostSave = useLastPostSave( postId, postType );
357
+ const prevPostSave = usePrevious( lastPostSave );
358
+
359
+ useEffect( () => {
360
+ if ( ! lastPostSave ) {
361
+ return;
362
+ }
363
+
364
+ if ( prevPostSave && lastPostSave.savedAt === prevPostSave.savedAt ) {
365
+ return;
366
+ }
367
+
368
+ const saver = activeCollaborators.find(
369
+ ( collaborator ) =>
370
+ collaborator.clientId === lastPostSave.savedByClientId &&
371
+ ! collaborator.isMe
372
+ );
373
+
374
+ if ( ! saver ) {
375
+ return;
376
+ }
377
+
378
+ callback( lastPostSave, saver, prevPostSave ?? null );
379
+ }, [ lastPostSave, prevPostSave, activeCollaborators, callback ] );
380
+ }
package/src/index.js CHANGED
@@ -138,6 +138,7 @@ register( store ); // Register store after unlocking private selectors to allow
138
138
  * based on their values (they blur to string type).
139
139
  */
140
140
  export { SelectionType } from './utils/crdt-user-selections';
141
+ export { SelectionDirection } from './types';
141
142
 
142
143
  export { default as EntityProvider } from './entity-provider';
143
144
  export * from './entity-provider';