@wordpress/core-data 7.40.2-next.v.202602271551.0 → 7.40.2

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 (182) hide show
  1. package/README.md +0 -27
  2. package/build/actions.cjs +9 -26
  3. package/build/actions.cjs.map +2 -2
  4. package/build/awareness/post-editor-awareness.cjs +45 -6
  5. package/build/awareness/post-editor-awareness.cjs.map +2 -2
  6. package/build/awareness/types.cjs.map +1 -1
  7. package/build/entities.cjs +36 -16
  8. package/build/entities.cjs.map +2 -2
  9. package/build/entity-provider.cjs +15 -6
  10. package/build/entity-provider.cjs.map +2 -2
  11. package/build/hooks/use-entity-prop.cjs +33 -2
  12. package/build/hooks/use-entity-prop.cjs.map +2 -2
  13. package/build/hooks/use-post-editor-awareness-state.cjs +120 -1
  14. package/build/hooks/use-post-editor-awareness-state.cjs.map +2 -2
  15. package/build/index.cjs +0 -3
  16. package/build/index.cjs.map +2 -2
  17. package/build/private-actions.cjs +27 -3
  18. package/build/private-actions.cjs.map +2 -2
  19. package/build/private-apis.cjs +14 -5
  20. package/build/private-apis.cjs.map +3 -3
  21. package/build/private-selectors.cjs +22 -2
  22. package/build/private-selectors.cjs.map +2 -2
  23. package/build/queried-data/actions.cjs +1 -1
  24. package/build/queried-data/actions.cjs.map +2 -2
  25. package/build/queried-data/reducer.cjs +19 -13
  26. package/build/queried-data/reducer.cjs.map +2 -2
  27. package/build/reducer.cjs +19 -2
  28. package/build/reducer.cjs.map +2 -2
  29. package/build/resolvers.cjs +24 -17
  30. package/build/resolvers.cjs.map +2 -2
  31. package/build/selectors.cjs +0 -15
  32. package/build/selectors.cjs.map +2 -2
  33. package/build/sync.cjs +12 -3
  34. package/build/sync.cjs.map +2 -2
  35. package/build/types.cjs.map +1 -1
  36. package/build/utils/block-selection-history.cjs +1 -1
  37. package/build/utils/block-selection-history.cjs.map +2 -2
  38. package/build/utils/crdt-blocks.cjs +85 -28
  39. package/build/utils/crdt-blocks.cjs.map +2 -2
  40. package/build/utils/crdt-selection.cjs +4 -1
  41. package/build/utils/crdt-selection.cjs.map +2 -2
  42. package/build/utils/crdt-text.cjs +52 -0
  43. package/build/utils/crdt-text.cjs.map +7 -0
  44. package/build/utils/crdt-user-selections.cjs +16 -6
  45. package/build/utils/crdt-user-selections.cjs.map +3 -3
  46. package/build/utils/crdt-utils.cjs +54 -2
  47. package/build/utils/crdt-utils.cjs.map +2 -2
  48. package/build/utils/crdt.cjs +26 -28
  49. package/build/utils/crdt.cjs.map +2 -2
  50. package/build-module/actions.mjs +14 -26
  51. package/build-module/actions.mjs.map +2 -2
  52. package/build-module/awareness/post-editor-awareness.mjs +47 -7
  53. package/build-module/awareness/post-editor-awareness.mjs.map +2 -2
  54. package/build-module/entities.mjs +37 -16
  55. package/build-module/entities.mjs.map +2 -2
  56. package/build-module/entity-provider.mjs +15 -6
  57. package/build-module/entity-provider.mjs.map +2 -2
  58. package/build-module/hooks/use-entity-prop.mjs +34 -3
  59. package/build-module/hooks/use-entity-prop.mjs.map +2 -2
  60. package/build-module/hooks/use-post-editor-awareness-state.mjs +117 -1
  61. package/build-module/hooks/use-post-editor-awareness-state.mjs.map +2 -2
  62. package/build-module/index.mjs +0 -2
  63. package/build-module/index.mjs.map +2 -2
  64. package/build-module/private-actions.mjs +24 -2
  65. package/build-module/private-actions.mjs.map +2 -2
  66. package/build-module/private-apis.mjs +21 -6
  67. package/build-module/private-apis.mjs.map +3 -3
  68. package/build-module/private-selectors.mjs +19 -1
  69. package/build-module/private-selectors.mjs.map +2 -2
  70. package/build-module/queried-data/actions.mjs +1 -1
  71. package/build-module/queried-data/actions.mjs.map +2 -2
  72. package/build-module/queried-data/reducer.mjs +19 -13
  73. package/build-module/queried-data/reducer.mjs.map +2 -2
  74. package/build-module/reducer.mjs +18 -2
  75. package/build-module/reducer.mjs.map +2 -2
  76. package/build-module/resolvers.mjs +24 -17
  77. package/build-module/resolvers.mjs.map +2 -2
  78. package/build-module/selectors.mjs +0 -14
  79. package/build-module/selectors.mjs.map +2 -2
  80. package/build-module/sync.mjs +8 -2
  81. package/build-module/sync.mjs.map +2 -2
  82. package/build-module/utils/block-selection-history.mjs +5 -2
  83. package/build-module/utils/block-selection-history.mjs.map +2 -2
  84. package/build-module/utils/crdt-blocks.mjs +84 -28
  85. package/build-module/utils/crdt-blocks.mjs.map +2 -2
  86. package/build-module/utils/crdt-selection.mjs +8 -2
  87. package/build-module/utils/crdt-selection.mjs.map +2 -2
  88. package/build-module/utils/crdt-text.mjs +26 -0
  89. package/build-module/utils/crdt-text.mjs.map +7 -0
  90. package/build-module/utils/crdt-user-selections.mjs +16 -7
  91. package/build-module/utils/crdt-user-selections.mjs.map +3 -3
  92. package/build-module/utils/crdt-utils.mjs +51 -1
  93. package/build-module/utils/crdt-utils.mjs.map +2 -2
  94. package/build-module/utils/crdt.mjs +26 -28
  95. package/build-module/utils/crdt.mjs.map +2 -2
  96. package/build-types/actions.d.ts +0 -11
  97. package/build-types/actions.d.ts.map +1 -1
  98. package/build-types/awareness/post-editor-awareness.d.ts +2 -2
  99. package/build-types/awareness/post-editor-awareness.d.ts.map +1 -1
  100. package/build-types/awareness/types.d.ts +6 -1
  101. package/build-types/awareness/types.d.ts.map +1 -1
  102. package/build-types/entities.d.ts +20 -1
  103. package/build-types/entities.d.ts.map +1 -1
  104. package/build-types/entity-provider.d.ts +11 -6
  105. package/build-types/entity-provider.d.ts.map +1 -1
  106. package/build-types/hooks/use-entity-prop.d.ts.map +1 -1
  107. package/build-types/hooks/use-post-editor-awareness-state.d.ts +40 -7
  108. package/build-types/hooks/use-post-editor-awareness-state.d.ts.map +1 -1
  109. package/build-types/index.d.ts +0 -3
  110. package/build-types/index.d.ts.map +1 -1
  111. package/build-types/parsed-blocks-cache.d.ts +10 -0
  112. package/build-types/parsed-blocks-cache.d.ts.map +1 -0
  113. package/build-types/private-actions.d.ts +12 -0
  114. package/build-types/private-actions.d.ts.map +1 -1
  115. package/build-types/private-apis.d.ts +21 -1
  116. package/build-types/private-apis.d.ts.map +1 -1
  117. package/build-types/private-selectors.d.ts +17 -0
  118. package/build-types/private-selectors.d.ts.map +1 -1
  119. package/build-types/queried-data/reducer.d.ts.map +1 -1
  120. package/build-types/reducer.d.ts +15 -0
  121. package/build-types/reducer.d.ts.map +1 -1
  122. package/build-types/resolvers.d.ts.map +1 -1
  123. package/build-types/selectors.d.ts +1 -9
  124. package/build-types/selectors.d.ts.map +1 -1
  125. package/build-types/sync.d.ts +2 -2
  126. package/build-types/sync.d.ts.map +1 -1
  127. package/build-types/types.d.ts +10 -2
  128. package/build-types/types.d.ts.map +1 -1
  129. package/build-types/utils/block-selection-history.d.ts.map +1 -1
  130. package/build-types/utils/crdt-blocks.d.ts +11 -0
  131. package/build-types/utils/crdt-blocks.d.ts.map +1 -1
  132. package/build-types/utils/crdt-selection.d.ts.map +1 -1
  133. package/build-types/utils/crdt-text.d.ts +16 -0
  134. package/build-types/utils/crdt-text.d.ts.map +1 -0
  135. package/build-types/utils/crdt-user-selections.d.ts +17 -4
  136. package/build-types/utils/crdt-user-selections.d.ts.map +1 -1
  137. package/build-types/utils/crdt-utils.d.ts +20 -0
  138. package/build-types/utils/crdt-utils.d.ts.map +1 -1
  139. package/build-types/utils/crdt.d.ts +13 -7
  140. package/build-types/utils/crdt.d.ts.map +1 -1
  141. package/build-types/utils/normalize-query-for-resolution.d.ts +12 -0
  142. package/build-types/utils/normalize-query-for-resolution.d.ts.map +1 -0
  143. package/build-types/utils/save-crdt-doc.d.ts +8 -0
  144. package/build-types/utils/save-crdt-doc.d.ts.map +1 -0
  145. package/build-types/utils/test/crdt-utils.d.ts +2 -0
  146. package/build-types/utils/test/crdt-utils.d.ts.map +1 -0
  147. package/package.json +18 -18
  148. package/src/actions.js +26 -41
  149. package/src/awareness/post-editor-awareness.ts +106 -7
  150. package/src/awareness/test/post-editor-awareness.ts +50 -10
  151. package/src/awareness/types.ts +7 -1
  152. package/src/entities.js +43 -17
  153. package/src/entity-provider.js +24 -11
  154. package/src/hooks/test/use-post-editor-awareness-state.ts +446 -3
  155. package/src/hooks/use-entity-prop.js +43 -3
  156. package/src/hooks/use-post-editor-awareness-state.ts +229 -7
  157. package/src/index.js +0 -6
  158. package/src/private-actions.js +43 -1
  159. package/src/{private-apis.js → private-apis.ts} +21 -3
  160. package/src/private-selectors.ts +43 -0
  161. package/src/queried-data/actions.js +1 -1
  162. package/src/queried-data/reducer.js +26 -14
  163. package/src/reducer.js +36 -1
  164. package/src/resolvers.js +33 -23
  165. package/src/selectors.ts +1 -32
  166. package/src/sync.ts +6 -0
  167. package/src/test/entities.js +204 -16
  168. package/src/test/private-actions.js +1 -1
  169. package/src/test/resolvers.js +144 -57
  170. package/src/test/store.js +116 -0
  171. package/src/types.ts +20 -2
  172. package/src/utils/block-selection-history.ts +5 -2
  173. package/src/utils/crdt-blocks.ts +159 -56
  174. package/src/utils/crdt-selection.ts +8 -2
  175. package/src/utils/crdt-text.ts +43 -0
  176. package/src/utils/crdt-user-selections.ts +29 -8
  177. package/src/utils/crdt-utils.ts +99 -0
  178. package/src/utils/crdt.ts +50 -40
  179. package/src/utils/test/crdt-blocks.ts +588 -24
  180. package/src/utils/test/crdt-user-selections.ts +5 -0
  181. package/src/utils/test/crdt-utils.ts +387 -0
  182. package/src/utils/test/crdt.ts +291 -54
@@ -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
  } );
@@ -1,13 +1,15 @@
1
1
  /**
2
2
  * WordPress dependencies
3
3
  */
4
- import { useCallback } from '@wordpress/element';
4
+ import { useCallback, useContext } from '@wordpress/element';
5
5
  import { useDispatch, useSelect } from '@wordpress/data';
6
6
 
7
7
  /**
8
8
  * Internal dependencies
9
9
  */
10
10
  import { STORE_NAME } from '../name';
11
+ import { DEFAULT_ENTITY_KEY } from '../entities';
12
+ import { EntityContext } from '../entity-context';
11
13
  import useEntityId from './use-entity-id';
12
14
 
13
15
  /**
@@ -30,9 +32,44 @@ import useEntityId from './use-entity-id';
30
32
  export default function useEntityProp( kind, name, prop, _id ) {
31
33
  const providerId = useEntityId( kind, name );
32
34
  const id = _id ?? providerId;
35
+ const context = useContext( EntityContext );
36
+ const revisionId = context?.revisionId;
33
37
 
34
38
  const { value, fullValue } = useSelect(
35
39
  ( select ) => {
40
+ if ( revisionId ) {
41
+ // Use getRevisions (not getRevision) to read from the
42
+ // already-cached collection. Using getRevision would
43
+ // trigger a redundant single-revision API fetch that
44
+ // can wipe the collection due to a race condition.
45
+ // See https://github.com/WordPress/gutenberg/pull/76043.
46
+ const revisions = select( STORE_NAME ).getRevisions(
47
+ kind,
48
+ name,
49
+ id,
50
+ {
51
+ per_page: -1,
52
+ context: 'edit',
53
+ _fields:
54
+ 'id,date,author,meta,title.raw,excerpt.raw,content.raw',
55
+ }
56
+ );
57
+ const entityConfig = select( STORE_NAME ).getEntityConfig(
58
+ kind,
59
+ name
60
+ );
61
+ const revKey = entityConfig?.revisionKey || DEFAULT_ENTITY_KEY;
62
+ const revision = revisions?.find(
63
+ ( r ) => r[ revKey ] === revisionId
64
+ );
65
+ return revision
66
+ ? {
67
+ value: revision[ prop ]?.raw ?? revision[ prop ],
68
+ fullValue: revision[ prop ],
69
+ }
70
+ : {};
71
+ }
72
+
36
73
  const { getEntityRecord, getEditedEntityRecord } =
37
74
  select( STORE_NAME );
38
75
  const record = getEntityRecord( kind, name, id ); // Trigger resolver.
@@ -44,16 +81,19 @@ export default function useEntityProp( kind, name, prop, _id ) {
44
81
  }
45
82
  : {};
46
83
  },
47
- [ kind, name, id, prop ]
84
+ [ kind, name, id, prop, revisionId ]
48
85
  );
49
86
  const { editEntityRecord } = useDispatch( STORE_NAME );
50
87
  const setValue = useCallback(
51
88
  ( newValue ) => {
89
+ if ( revisionId ) {
90
+ return;
91
+ }
52
92
  editEntityRecord( kind, name, id, {
53
93
  [ prop ]: newValue,
54
94
  } );
55
95
  },
56
- [ editEntityRecord, kind, name, id, prop ]
96
+ [ editEntityRecord, kind, name, id, prop, revisionId ]
57
97
  );
58
98
 
59
99
  return [ value, setValue, fullValue ];