@wordpress/core-data 7.40.2-next.v.202602241322.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 (183) 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 +50 -23
  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 +16 -6
  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 +11 -5
  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 +125 -49
  39. package/build/utils/crdt-blocks.cjs.map +2 -2
  40. package/build/utils/crdt-selection.cjs +50 -19
  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 +38 -29
  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 +53 -24
  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 +16 -6
  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 +7 -3
  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 +124 -49
  85. package/build-module/utils/crdt-blocks.mjs.map +2 -2
  86. package/build-module/utils/crdt-selection.mjs +53 -20
  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 +42 -34
  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 +11 -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 +12 -1
  131. package/build-types/utils/crdt-blocks.d.ts.map +1 -1
  132. package/build-types/utils/crdt-selection.d.ts +10 -0
  133. package/build-types/utils/crdt-selection.d.ts.map +1 -1
  134. package/build-types/utils/crdt-text.d.ts +16 -0
  135. package/build-types/utils/crdt-text.d.ts.map +1 -0
  136. package/build-types/utils/crdt-user-selections.d.ts +17 -4
  137. package/build-types/utils/crdt-user-selections.d.ts.map +1 -1
  138. package/build-types/utils/crdt-utils.d.ts +20 -0
  139. package/build-types/utils/crdt-utils.d.ts.map +1 -1
  140. package/build-types/utils/crdt.d.ts +14 -7
  141. package/build-types/utils/crdt.d.ts.map +1 -1
  142. package/build-types/utils/normalize-query-for-resolution.d.ts +12 -0
  143. package/build-types/utils/normalize-query-for-resolution.d.ts.map +1 -0
  144. package/build-types/utils/save-crdt-doc.d.ts +8 -0
  145. package/build-types/utils/save-crdt-doc.d.ts.map +1 -0
  146. package/build-types/utils/test/crdt-utils.d.ts +2 -0
  147. package/build-types/utils/test/crdt-utils.d.ts.map +1 -0
  148. package/package.json +18 -18
  149. package/src/actions.js +26 -41
  150. package/src/awareness/post-editor-awareness.ts +106 -7
  151. package/src/awareness/test/post-editor-awareness.ts +50 -10
  152. package/src/awareness/types.ts +7 -1
  153. package/src/entities.js +62 -24
  154. package/src/entity-provider.js +24 -11
  155. package/src/hooks/test/use-post-editor-awareness-state.ts +446 -3
  156. package/src/hooks/use-entity-prop.js +43 -3
  157. package/src/hooks/use-post-editor-awareness-state.ts +229 -7
  158. package/src/index.js +0 -6
  159. package/src/private-actions.js +43 -1
  160. package/src/{private-apis.js → private-apis.ts} +21 -3
  161. package/src/private-selectors.ts +43 -0
  162. package/src/queried-data/actions.js +1 -1
  163. package/src/queried-data/reducer.js +26 -14
  164. package/src/reducer.js +36 -1
  165. package/src/resolvers.js +23 -7
  166. package/src/selectors.ts +1 -32
  167. package/src/sync.ts +6 -2
  168. package/src/test/entities.js +231 -14
  169. package/src/test/private-actions.js +1 -1
  170. package/src/test/resolvers.js +64 -11
  171. package/src/test/store.js +116 -0
  172. package/src/types.ts +21 -2
  173. package/src/utils/block-selection-history.ts +5 -2
  174. package/src/utils/crdt-blocks.ts +250 -81
  175. package/src/utils/crdt-selection.ts +92 -26
  176. package/src/utils/crdt-text.ts +43 -0
  177. package/src/utils/crdt-user-selections.ts +29 -8
  178. package/src/utils/crdt-utils.ts +99 -0
  179. package/src/utils/crdt.ts +72 -46
  180. package/src/utils/test/crdt-blocks.ts +1177 -22
  181. package/src/utils/test/crdt-user-selections.ts +5 -0
  182. package/src/utils/test/crdt-utils.ts +387 -0
  183. package/src/utils/test/crdt.ts +423 -60
@@ -4,6 +4,14 @@
4
4
  import apiFetch from '@wordpress/api-fetch';
5
5
 
6
6
  jest.mock( '@wordpress/api-fetch' );
7
+ jest.mock( '../sync', () => ( {
8
+ ...jest.requireActual( '../sync' ),
9
+ getSyncManager: jest.fn(),
10
+ } ) );
11
+ jest.mock( '../utils/crdt', () => ( {
12
+ ...jest.requireActual( '../utils/crdt' ),
13
+ applyPostChangesToCRDTDoc: jest.fn(),
14
+ } ) );
7
15
 
8
16
  /**
9
17
  * Internal dependencies
@@ -14,6 +22,11 @@ import {
14
22
  prePersistPostType,
15
23
  additionalEntityConfigLoaders,
16
24
  } from '../entities';
25
+ import { getSyncManager } from '../sync';
26
+ import {
27
+ applyPostChangesToCRDTDoc,
28
+ POST_META_KEY_FOR_CRDT_DOC_PERSISTENCE,
29
+ } from '../utils/crdt';
17
30
 
18
31
  describe( 'getMethodName', () => {
19
32
  it( 'should return the right method name for an entity with the root kind', () => {
@@ -45,52 +58,256 @@ describe( 'getMethodName', () => {
45
58
  } );
46
59
 
47
60
  describe( 'prePersistPostType', () => {
48
- it( 'set the status to draft and empty the title when saving auto-draft posts', () => {
61
+ it( 'set the status to draft and empty the title when saving auto-draft posts', async () => {
49
62
  let record = {
50
63
  status: 'auto-draft',
51
64
  };
52
65
  const edits = {};
53
- expect( prePersistPostType( record, edits, 'post', false ) ).toEqual( {
66
+ expect(
67
+ await prePersistPostType( record, edits, 'post', false )
68
+ ).toEqual( {
54
69
  status: 'draft',
55
70
  title: '',
56
- meta: {},
57
71
  } );
58
72
 
59
73
  record = {
60
74
  status: 'publish',
61
75
  };
62
- expect( prePersistPostType( record, edits, 'post', false ) ).toEqual( {
63
- meta: {},
64
- } );
76
+ expect(
77
+ await prePersistPostType( record, edits, 'post', false )
78
+ ).toEqual( {} );
65
79
 
66
80
  record = {
67
81
  status: 'auto-draft',
68
82
  title: 'Auto Draft',
69
83
  };
70
- expect( prePersistPostType( record, edits, 'post', false ) ).toEqual( {
84
+ expect(
85
+ await prePersistPostType( record, edits, 'post', false )
86
+ ).toEqual( {
71
87
  status: 'draft',
72
88
  title: '',
73
- meta: {},
74
89
  } );
75
90
 
76
91
  record = {
77
92
  status: 'publish',
78
93
  title: 'My Title',
79
94
  };
80
- expect( prePersistPostType( record, edits, 'post', false ) ).toEqual( {
81
- meta: {},
82
- } );
95
+ expect(
96
+ await prePersistPostType( record, edits, 'post', false )
97
+ ).toEqual( {} );
83
98
  } );
84
99
 
85
- it( 'does not set the status to draft and empty the title when saving templates', () => {
100
+ it( 'does not set the status to draft and empty the title when saving templates', async () => {
86
101
  const record = {
87
102
  status: 'auto-draft',
88
103
  title: 'Auto Draft',
89
104
  };
90
105
  const edits = {};
91
- expect( prePersistPostType( record, edits, 'post', true ) ).toEqual( {
92
- meta: {},
106
+ expect(
107
+ await prePersistPostType( record, edits, 'post', true )
108
+ ).toEqual( {} );
109
+ } );
110
+
111
+ it( 'adds meta with serialized CRDT doc when createPersistedCRDTDoc returns a value', async () => {
112
+ const mockSerializedDoc = 'serialized-crdt-doc-data';
113
+ getSyncManager.mockReturnValue( {
114
+ createPersistedCRDTDoc: jest
115
+ .fn()
116
+ .mockReturnValue( mockSerializedDoc ),
117
+ } );
118
+
119
+ const record = { id: 123, status: 'publish' };
120
+ const edits = {};
121
+ const result = await prePersistPostType( record, edits, 'post', false );
122
+
123
+ expect( result.meta ).toEqual( {
124
+ [ POST_META_KEY_FOR_CRDT_DOC_PERSISTENCE ]: mockSerializedDoc,
93
125
  } );
126
+
127
+ expect( getSyncManager ).toHaveBeenCalled();
128
+ expect( getSyncManager().createPersistedCRDTDoc ).toHaveBeenCalledWith(
129
+ 'postType/post',
130
+ 123
131
+ );
132
+
133
+ getSyncManager.mockReset();
134
+ } );
135
+ } );
136
+
137
+ describe( 'loadPostTypeEntities', () => {
138
+ let originalCollaborationEnabled;
139
+
140
+ beforeEach( () => {
141
+ apiFetch.mockReset();
142
+ applyPostChangesToCRDTDoc.mockReset();
143
+ originalCollaborationEnabled = window._wpCollaborationEnabled;
144
+ } );
145
+
146
+ afterEach( () => {
147
+ window._wpCollaborationEnabled = originalCollaborationEnabled;
148
+ } );
149
+
150
+ it( 'should include custom taxonomy rest_bases in synced properties when collaboration is enabled', async () => {
151
+ window._wpCollaborationEnabled = true;
152
+
153
+ const mockPostTypes = {
154
+ book: {
155
+ name: 'Books',
156
+ rest_base: 'books',
157
+ rest_namespace: 'wp/v2',
158
+ taxonomies: [ 'genre', 'audience' ],
159
+ },
160
+ };
161
+ const mockTaxonomies = {
162
+ genre: {
163
+ name: 'Genres',
164
+ rest_base: 'genres',
165
+ rest_namespace: 'wp/v2',
166
+ },
167
+ audience: {
168
+ name: 'Audiences',
169
+ rest_base: 'audiences',
170
+ rest_namespace: 'wp/v2',
171
+ },
172
+ };
173
+
174
+ apiFetch
175
+ .mockResolvedValueOnce( mockPostTypes )
176
+ .mockResolvedValueOnce( mockTaxonomies );
177
+
178
+ const postTypeLoader = additionalEntityConfigLoaders.find(
179
+ ( loader ) => loader.kind === 'postType'
180
+ );
181
+ const entities = await postTypeLoader.loadEntities();
182
+ const bookEntity = entities.find( ( e ) => e.name === 'book' );
183
+
184
+ bookEntity.syncConfig.applyChangesToCRDTDoc( {}, {} );
185
+
186
+ expect( applyPostChangesToCRDTDoc ).toHaveBeenCalledWith(
187
+ {},
188
+ {},
189
+ expect.any( Set )
190
+ );
191
+
192
+ const syncedProperties = applyPostChangesToCRDTDoc.mock.calls[ 0 ][ 2 ];
193
+ expect( syncedProperties ).toContain( 'genres' );
194
+ expect( syncedProperties ).toContain( 'audiences' );
195
+ } );
196
+
197
+ it( 'should not fetch taxonomies when collaboration is disabled', async () => {
198
+ window._wpCollaborationEnabled = false;
199
+
200
+ const mockPostTypes = {
201
+ post: {
202
+ name: 'Posts',
203
+ rest_base: 'posts',
204
+ rest_namespace: 'wp/v2',
205
+ taxonomies: [ 'category', 'post_tag' ],
206
+ },
207
+ };
208
+
209
+ apiFetch.mockResolvedValueOnce( mockPostTypes );
210
+
211
+ const postTypeLoader = additionalEntityConfigLoaders.find(
212
+ ( loader ) => loader.kind === 'postType'
213
+ );
214
+ const entities = await postTypeLoader.loadEntities();
215
+ const postEntity = entities.find( ( e ) => e.name === 'post' );
216
+
217
+ postEntity.syncConfig.applyChangesToCRDTDoc( {}, {} );
218
+
219
+ // Only one apiFetch call (post types), no taxonomy fetch.
220
+ expect( apiFetch ).toHaveBeenCalledTimes( 1 );
221
+
222
+ const syncedProperties = applyPostChangesToCRDTDoc.mock.calls[ 0 ][ 2 ];
223
+ expect( syncedProperties ).not.toContain( 'categories' );
224
+ expect( syncedProperties ).not.toContain( 'tags' );
225
+ } );
226
+
227
+ it( 'should skip taxonomy rest_base when taxonomy is not found in fetched taxonomies', async () => {
228
+ window._wpCollaborationEnabled = true;
229
+
230
+ const mockPostTypes = {
231
+ book: {
232
+ name: 'Books',
233
+ rest_base: 'books',
234
+ rest_namespace: 'wp/v2',
235
+ taxonomies: [ 'genre', 'missing_taxonomy' ],
236
+ },
237
+ };
238
+ const mockTaxonomies = {
239
+ genre: {
240
+ name: 'Genres',
241
+ rest_base: 'genres',
242
+ rest_namespace: 'wp/v2',
243
+ },
244
+ // 'missing_taxonomy' is intentionally absent.
245
+ };
246
+
247
+ apiFetch
248
+ .mockResolvedValueOnce( mockPostTypes )
249
+ .mockResolvedValueOnce( mockTaxonomies );
250
+
251
+ const postTypeLoader = additionalEntityConfigLoaders.find(
252
+ ( loader ) => loader.kind === 'postType'
253
+ );
254
+ const entities = await postTypeLoader.loadEntities();
255
+ const bookEntity = entities.find( ( e ) => e.name === 'book' );
256
+
257
+ bookEntity.syncConfig.applyChangesToCRDTDoc( {}, {} );
258
+
259
+ const syncedProperties = applyPostChangesToCRDTDoc.mock.calls[ 0 ][ 2 ];
260
+ expect( syncedProperties ).toContain( 'genres' );
261
+ // missing_taxonomy has no rest_base entry, so nothing should be added for it.
262
+ expect( syncedProperties.size ).toBe( 16 ); // 15 base + 1 taxonomy (genres)
263
+ } );
264
+
265
+ it( 'should include base synced properties regardless of taxonomies', async () => {
266
+ window._wpCollaborationEnabled = true;
267
+
268
+ const mockPostTypes = {
269
+ page: {
270
+ name: 'Pages',
271
+ rest_base: 'pages',
272
+ rest_namespace: 'wp/v2',
273
+ taxonomies: [],
274
+ },
275
+ };
276
+
277
+ apiFetch
278
+ .mockResolvedValueOnce( mockPostTypes )
279
+ .mockResolvedValueOnce( {} );
280
+
281
+ const postTypeLoader = additionalEntityConfigLoaders.find(
282
+ ( loader ) => loader.kind === 'postType'
283
+ );
284
+ const entities = await postTypeLoader.loadEntities();
285
+ const pageEntity = entities.find( ( e ) => e.name === 'page' );
286
+
287
+ pageEntity.syncConfig.applyChangesToCRDTDoc( {}, {} );
288
+
289
+ const syncedProperties = applyPostChangesToCRDTDoc.mock.calls[ 0 ][ 2 ];
290
+ const expectedBase = [
291
+ 'author',
292
+ 'blocks',
293
+ 'content',
294
+ 'comment_status',
295
+ 'date',
296
+ 'excerpt',
297
+ 'featured_media',
298
+ 'format',
299
+ 'meta',
300
+ 'ping_status',
301
+ 'slug',
302
+ 'status',
303
+ 'sticky',
304
+ 'template',
305
+ 'title',
306
+ ];
307
+ for ( const prop of expectedBase ) {
308
+ expect( syncedProperties ).toContain( prop );
309
+ }
310
+ expect( syncedProperties.size ).toBe( 15 );
94
311
  } );
95
312
  } );
96
313
 
@@ -102,7 +102,7 @@ describe( 'editMediaEntity', () => {
102
102
  expect( dispatch.receiveEntityRecords ).toHaveBeenCalledWith(
103
103
  'postType',
104
104
  'attachment',
105
- [ updatedRecord ],
105
+ updatedRecord,
106
106
  undefined,
107
107
  true,
108
108
  undefined,
@@ -172,16 +172,69 @@ describe( 'getEntityRecord', () => {
172
172
  editRecord: expect.any( Function ),
173
173
  getEditedRecord: expect.any( Function ),
174
174
  onStatusChange: expect.any( Function ),
175
+ persistCRDTDoc: expect.any( Function ),
175
176
  refetchRecord: expect.any( Function ),
176
177
  restoreUndoMeta: expect.any( Function ),
177
- saveRecord: expect.any( Function ),
178
178
  }
179
179
  );
180
180
  } );
181
181
 
182
- it( 'saveRecord fetches edited record and saves full entity record', async () => {
183
- const POST_RECORD = { id: 1, title: 'Test Post' };
184
- const EDITED_RECORD = { id: 1, title: 'Edited Post' };
182
+ it( 'persistCRDTDoc fetches edited record and does not save full entity record when the entity does not support meta', async () => {
183
+ const ENTITY_RECORD = { id: 1, title: 'Test Record' };
184
+ const EDITED_RECORD = { id: 1, title: 'Edited Record' };
185
+ const ENTITY_RESPONSE = {
186
+ json: () => Promise.resolve( ENTITY_RECORD ),
187
+ };
188
+ const ENTITIES_WITH_SYNC = [
189
+ {
190
+ name: 'bar',
191
+ kind: 'foo',
192
+ baseURL: '/wp/v2/foo',
193
+ baseURLParams: { context: 'edit' },
194
+ syncConfig: {},
195
+ },
196
+ ];
197
+
198
+ dispatch.saveEntityRecord = jest.fn();
199
+
200
+ const resolveSelectWithSync = {
201
+ getEntitiesConfig: jest.fn( () => ENTITIES_WITH_SYNC ),
202
+ getEditedEntityRecord: jest.fn( () =>
203
+ Promise.resolve( EDITED_RECORD )
204
+ ),
205
+ };
206
+
207
+ triggerFetch.mockImplementation( () => ENTITY_RESPONSE );
208
+
209
+ await getEntityRecord(
210
+ 'foo',
211
+ 'bar',
212
+ 1
213
+ )( {
214
+ dispatch,
215
+ registry,
216
+ resolveSelect: resolveSelectWithSync,
217
+ } );
218
+
219
+ // Extract the handlers passed to syncManager.load.
220
+ const handlers = syncManager.load.mock.calls[ 0 ][ 4 ];
221
+
222
+ // Call persistCRDTDoc and wait for the internal promise chain.
223
+ handlers.persistCRDTDoc();
224
+ await resolveSelectWithSync.getEditedEntityRecord();
225
+
226
+ // Should have fetched the full edited entity record.
227
+ expect(
228
+ resolveSelectWithSync.getEditedEntityRecord
229
+ ).toHaveBeenCalledWith( 'foo', 'bar', 1 );
230
+
231
+ // Should not have called saveEntityRecord.
232
+ expect( dispatch.saveEntityRecord ).not.toHaveBeenCalled();
233
+ } );
234
+
235
+ it( 'persistCRDTDoc fetches edited record and saves full entity record', async () => {
236
+ const POST_RECORD = { id: 1, title: 'Test Post', meta: {} };
237
+ const EDITED_RECORD = { id: 1, title: 'Edited Post', meta: {} };
185
238
  const POST_RESPONSE = {
186
239
  json: () => Promise.resolve( POST_RECORD ),
187
240
  };
@@ -219,8 +272,8 @@ describe( 'getEntityRecord', () => {
219
272
  // Extract the handlers passed to syncManager.load.
220
273
  const handlers = syncManager.load.mock.calls[ 0 ][ 4 ];
221
274
 
222
- // Call saveRecord and wait for the internal promise chain.
223
- handlers.saveRecord();
275
+ // Call persistCRDTDoc and wait for the internal promise chain.
276
+ handlers.persistCRDTDoc();
224
277
  await resolveSelectWithSync.getEditedEntityRecord();
225
278
 
226
279
  // Should have fetched the full edited entity record.
@@ -236,8 +289,8 @@ describe( 'getEntityRecord', () => {
236
289
  );
237
290
  } );
238
291
 
239
- it( 'saveRecord saves even when there are no unsaved edits', async () => {
240
- const POST_RECORD = { id: 1, title: 'Test Post' };
292
+ it( 'persistCRDTDoc saves even when there are no unsaved edits', async () => {
293
+ const POST_RECORD = { id: 1, title: 'Test Post', meta: {} };
241
294
  const POST_RESPONSE = {
242
295
  json: () => Promise.resolve( POST_RECORD ),
243
296
  };
@@ -275,8 +328,8 @@ describe( 'getEntityRecord', () => {
275
328
 
276
329
  const handlers = syncManager.load.mock.calls[ 0 ][ 4 ];
277
330
 
278
- // Call saveRecord and wait for the internal promise chain.
279
- handlers.saveRecord();
331
+ // Call persistCRDTDoc and wait for the internal promise chain.
332
+ handlers.persistCRDTDoc();
280
333
  await resolveSelectWithSync.getEditedEntityRecord();
281
334
 
282
335
  // Should save the record even with no edits (the whole point of the fix).
@@ -336,9 +389,9 @@ describe( 'getEntityRecord', () => {
336
389
  editRecord: expect.any( Function ),
337
390
  getEditedRecord: expect.any( Function ),
338
391
  onStatusChange: expect.any( Function ),
392
+ persistCRDTDoc: expect.any( Function ),
339
393
  refetchRecord: expect.any( Function ),
340
394
  restoreUndoMeta: expect.any( Function ),
341
- saveRecord: expect.any( Function ),
342
395
  }
343
396
  );
344
397
  } );
package/src/test/store.js CHANGED
@@ -113,6 +113,122 @@ describe( 'getEntityRecord', () => {
113
113
  } );
114
114
  } );
115
115
 
116
+ describe( 'getEntityRecords', () => {
117
+ const POSTS = [
118
+ createTestPost( 1 ),
119
+ createTestPost( 2 ),
120
+ createTestPost( 3 ),
121
+ ];
122
+
123
+ let registry;
124
+
125
+ beforeEach( () => {
126
+ registry = createTestRegistry();
127
+ triggerFetch.mockReset();
128
+ } );
129
+
130
+ it( 'preserves collection when getEntityRecord resolves after getEntityRecords', async () => {
131
+ let resolveSlowFetch;
132
+ const slowFetchPromise = new Promise( ( resolve ) => {
133
+ resolveSlowFetch = resolve;
134
+ } );
135
+
136
+ triggerFetch.mockImplementation( ( { path } ) => {
137
+ // Single post fetch (e.g. /wp/v2/posts/1): return slow promise.
138
+ if ( /\/wp\/v2\/posts\/\d+/.test( path ) ) {
139
+ return slowFetchPromise;
140
+ }
141
+ // Collection fetch: return immediately.
142
+ return Promise.resolve( {
143
+ json: () => Promise.resolve( POSTS ),
144
+ headers: {
145
+ get: ( header ) => {
146
+ if ( header === 'X-WP-Total' ) {
147
+ return String( POSTS.length );
148
+ }
149
+ if ( header === 'X-WP-TotalPages' ) {
150
+ return '1';
151
+ }
152
+ return null;
153
+ },
154
+ },
155
+ } );
156
+ } );
157
+
158
+ const resolveSelectStore = registry.resolveSelect( coreDataStore );
159
+
160
+ // Start getEntityRecord first (slow), then getEntityRecords (fast).
161
+ const singlePromise = resolveSelectStore.getEntityRecord(
162
+ 'postType',
163
+ 'post',
164
+ 1,
165
+ { context: 'edit' }
166
+ );
167
+ await resolveSelectStore.getEntityRecords( 'postType', 'post', {
168
+ context: 'edit',
169
+ } );
170
+
171
+ // Now resolve the slow single-record fetch.
172
+ resolveSlowFetch( {
173
+ json: () => Promise.resolve( POSTS[ 0 ] ),
174
+ headers: { get: () => null },
175
+ } );
176
+ await singlePromise;
177
+
178
+ // Wait for all pending thunks to settle.
179
+ await new Promise( ( resolve ) => setTimeout( resolve, 0 ) );
180
+
181
+ const allPosts = registry
182
+ .select( coreDataStore )
183
+ .getEntityRecords( 'postType', 'post', { context: 'edit' } );
184
+ expect( allPosts.map( ( p ) => p.id ) ).toEqual( [ 1, 2, 3 ] );
185
+ } );
186
+
187
+ it( 'preserves collection when getEntityRecord is called after getEntityRecords', async () => {
188
+ triggerFetch.mockImplementation( ( { path } ) => {
189
+ // Collection fetch.
190
+ if ( ! /\/wp\/v2\/posts\/\d+/.test( path ) ) {
191
+ return Promise.resolve( {
192
+ json: () => Promise.resolve( POSTS ),
193
+ headers: {
194
+ get: ( header ) => {
195
+ if ( header === 'X-WP-Total' ) {
196
+ return String( POSTS.length );
197
+ }
198
+ if ( header === 'X-WP-TotalPages' ) {
199
+ return '1';
200
+ }
201
+ return null;
202
+ },
203
+ },
204
+ } );
205
+ }
206
+ // Single post fetch.
207
+ return Promise.resolve( {
208
+ json: () => Promise.resolve( POSTS[ 0 ] ),
209
+ headers: { get: () => null },
210
+ } );
211
+ } );
212
+
213
+ const resolveSelectStore = registry.resolveSelect( coreDataStore );
214
+
215
+ // First resolve the collection.
216
+ await resolveSelectStore.getEntityRecords( 'postType', 'post', {
217
+ context: 'edit',
218
+ } );
219
+
220
+ // Then resolve a single record.
221
+ await resolveSelectStore.getEntityRecord( 'postType', 'post', 1, {
222
+ context: 'edit',
223
+ } );
224
+
225
+ const allPosts = registry
226
+ .select( coreDataStore )
227
+ .getEntityRecords( 'postType', 'post', { context: 'edit' } );
228
+ expect( allPosts.map( ( p ) => p.id ) ).toEqual( [ 1, 2, 3 ] );
229
+ } );
230
+ } );
231
+
116
232
  describe( 'clearEntityRecordEdits', () => {
117
233
  let registry;
118
234
 
package/src/types.ts CHANGED
@@ -1,12 +1,21 @@
1
1
  /**
2
2
  * WordPress dependencies
3
3
  */
4
- import type { Y } from '@wordpress/sync';
4
+ import type { ConnectionStatusDisconnected, Y } from '@wordpress/sync';
5
5
 
6
6
  /**
7
7
  * Internal dependencies
8
8
  */
9
- import type { SelectionType } from './utils/crdt-user-selections';
9
+ import type {
10
+ SelectionType,
11
+ SelectionDirection,
12
+ } from './utils/crdt-user-selections';
13
+
14
+ export type { ConnectionStatus } from '@wordpress/sync';
15
+
16
+ export type ConnectionError = NonNullable<
17
+ ConnectionStatusDisconnected[ 'error' ]
18
+ >;
10
19
 
11
20
  export interface AnyFunction {
12
21
  ( ...args: any[] ): any;
@@ -44,6 +53,7 @@ export interface WPBlockSelection {
44
53
  export interface WPSelection {
45
54
  selectionEnd: WPBlockSelection;
46
55
  selectionStart: WPBlockSelection;
56
+ initialPosition?: number | null;
47
57
  }
48
58
 
49
59
  /**
@@ -85,6 +95,8 @@ export type SelectionInOneBlock = {
85
95
  type: SelectionType.SelectionInOneBlock;
86
96
  cursorStartPosition: CursorPosition;
87
97
  cursorEndPosition: CursorPosition;
98
+ // The direction of the selection, indicating where the caret sits.
99
+ selectionDirection?: SelectionDirection;
88
100
  };
89
101
 
90
102
  export type SelectionInMultipleBlocks = {
@@ -94,6 +106,8 @@ export type SelectionInMultipleBlocks = {
94
106
  type: SelectionType.SelectionInMultipleBlocks;
95
107
  cursorStartPosition: CursorPosition;
96
108
  cursorEndPosition: CursorPosition;
109
+ // The direction of the selection, indicating where the caret sits.
110
+ selectionDirection?: SelectionDirection;
97
111
  };
98
112
 
99
113
  export type SelectionWholeBlock = {
@@ -110,3 +124,8 @@ export type SelectionState =
110
124
  | SelectionInOneBlock
111
125
  | SelectionInMultipleBlocks
112
126
  | SelectionWholeBlock;
127
+
128
+ export interface ResolvedSelection {
129
+ richTextOffset: number | null;
130
+ localClientId: string | null;
131
+ }
@@ -9,7 +9,10 @@ import { Y } from '@wordpress/sync';
9
9
  /**
10
10
  * Internal dependencies
11
11
  */
12
- import { findBlockByClientIdInDoc } from './crdt-utils';
12
+ import {
13
+ findBlockByClientIdInDoc,
14
+ richTextOffsetToHtmlIndex,
15
+ } from './crdt-utils';
13
16
  import type { WPBlockSelection, WPSelection } from '../types';
14
17
 
15
18
  // Default size for selection history (not including current selection)
@@ -163,7 +166,7 @@ function convertWPBlockSelectionToSelection(
163
166
  const offset = selection.offset ?? 0;
164
167
  const relativePosition = Y.createRelativePositionFromTypeIndex(
165
168
  changedYText,
166
- offset
169
+ richTextOffsetToHtmlIndex( changedYText.toString(), offset )
167
170
  );
168
171
 
169
172
  return {