@wordpress/core-data 7.41.2-next.v.202603161435.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 (101) hide show
  1. package/CHANGELOG.md +1 -1
  2. package/build/awareness/post-editor-awareness.cjs +12 -5
  3. package/build/awareness/post-editor-awareness.cjs.map +2 -2
  4. package/build/entities.cjs +30 -5
  5. package/build/entities.cjs.map +2 -2
  6. package/build/hooks/use-post-editor-awareness-state.cjs +1 -1
  7. package/build/hooks/use-post-editor-awareness-state.cjs.map +2 -2
  8. package/build/queried-data/get-query-parts.cjs +7 -0
  9. package/build/queried-data/get-query-parts.cjs.map +2 -2
  10. package/build/queried-data/selectors.cjs +14 -3
  11. package/build/queried-data/selectors.cjs.map +2 -2
  12. package/build/reducer.cjs +6 -0
  13. package/build/reducer.cjs.map +2 -2
  14. package/build/sync.cjs +3 -0
  15. package/build/sync.cjs.map +2 -2
  16. package/build/types.cjs.map +2 -2
  17. package/build/utils/block-selection-history.cjs +1 -1
  18. package/build/utils/block-selection-history.cjs.map +2 -2
  19. package/build/utils/crdt-blocks.cjs +17 -3
  20. package/build/utils/crdt-blocks.cjs.map +2 -2
  21. package/build/utils/crdt-selection.cjs +4 -1
  22. package/build/utils/crdt-selection.cjs.map +2 -2
  23. package/build/utils/crdt-user-selections.cjs +1 -1
  24. package/build/utils/crdt-user-selections.cjs.map +2 -2
  25. package/build/utils/crdt-utils.cjs +54 -2
  26. package/build/utils/crdt-utils.cjs.map +2 -2
  27. package/build/utils/crdt.cjs +4 -23
  28. package/build/utils/crdt.cjs.map +2 -2
  29. package/build-module/awareness/post-editor-awareness.mjs +12 -5
  30. package/build-module/awareness/post-editor-awareness.mjs.map +2 -2
  31. package/build-module/entities.mjs +30 -5
  32. package/build-module/entities.mjs.map +2 -2
  33. package/build-module/hooks/use-post-editor-awareness-state.mjs +1 -1
  34. package/build-module/hooks/use-post-editor-awareness-state.mjs.map +2 -2
  35. package/build-module/queried-data/get-query-parts.mjs +7 -0
  36. package/build-module/queried-data/get-query-parts.mjs.map +2 -2
  37. package/build-module/queried-data/selectors.mjs +14 -3
  38. package/build-module/queried-data/selectors.mjs.map +2 -2
  39. package/build-module/reducer.mjs +6 -0
  40. package/build-module/reducer.mjs.map +2 -2
  41. package/build-module/sync.mjs +2 -0
  42. package/build-module/sync.mjs.map +2 -2
  43. package/build-module/types.mjs.map +2 -2
  44. package/build-module/utils/block-selection-history.mjs +5 -2
  45. package/build-module/utils/block-selection-history.mjs.map +2 -2
  46. package/build-module/utils/crdt-blocks.mjs +17 -3
  47. package/build-module/utils/crdt-blocks.mjs.map +2 -2
  48. package/build-module/utils/crdt-selection.mjs +8 -2
  49. package/build-module/utils/crdt-selection.mjs.map +2 -2
  50. package/build-module/utils/crdt-user-selections.mjs +2 -2
  51. package/build-module/utils/crdt-user-selections.mjs.map +2 -2
  52. package/build-module/utils/crdt-utils.mjs +51 -1
  53. package/build-module/utils/crdt-utils.mjs.map +2 -2
  54. package/build-module/utils/crdt.mjs +4 -23
  55. package/build-module/utils/crdt.mjs.map +2 -2
  56. package/build-types/awareness/post-editor-awareness.d.ts +2 -2
  57. package/build-types/awareness/post-editor-awareness.d.ts.map +1 -1
  58. package/build-types/entities.d.ts.map +1 -1
  59. package/build-types/queried-data/get-query-parts.d.ts +7 -0
  60. package/build-types/queried-data/get-query-parts.d.ts.map +1 -1
  61. package/build-types/queried-data/selectors.d.ts.map +1 -1
  62. package/build-types/reducer.d.ts.map +1 -1
  63. package/build-types/sync.d.ts +2 -2
  64. package/build-types/sync.d.ts.map +1 -1
  65. package/build-types/types.d.ts +4 -2
  66. package/build-types/types.d.ts.map +1 -1
  67. package/build-types/utils/block-selection-history.d.ts.map +1 -1
  68. package/build-types/utils/crdt-blocks.d.ts.map +1 -1
  69. package/build-types/utils/crdt-selection.d.ts.map +1 -1
  70. package/build-types/utils/crdt-user-selections.d.ts +1 -2
  71. package/build-types/utils/crdt-user-selections.d.ts.map +1 -1
  72. package/build-types/utils/crdt-utils.d.ts +20 -0
  73. package/build-types/utils/crdt-utils.d.ts.map +1 -1
  74. package/build-types/utils/crdt.d.ts +6 -7
  75. package/build-types/utils/crdt.d.ts.map +1 -1
  76. package/build-types/utils/test/crdt-utils.d.ts +2 -0
  77. package/build-types/utils/test/crdt-utils.d.ts.map +1 -0
  78. package/package.json +18 -18
  79. package/src/awareness/post-editor-awareness.ts +13 -6
  80. package/src/awareness/test/post-editor-awareness.ts +15 -10
  81. package/src/entities.js +36 -5
  82. package/src/hooks/test/use-post-editor-awareness-state.ts +3 -3
  83. package/src/hooks/use-post-editor-awareness-state.ts +1 -1
  84. package/src/queried-data/get-query-parts.js +13 -0
  85. package/src/queried-data/selectors.js +22 -4
  86. package/src/queried-data/test/get-query-parts.js +34 -0
  87. package/src/queried-data/test/selectors.js +158 -0
  88. package/src/reducer.js +11 -0
  89. package/src/sync.ts +2 -0
  90. package/src/test/entities.js +185 -1
  91. package/src/types.ts +8 -2
  92. package/src/utils/block-selection-history.ts +5 -2
  93. package/src/utils/crdt-blocks.ts +32 -3
  94. package/src/utils/crdt-selection.ts +8 -2
  95. package/src/utils/crdt-user-selections.ts +13 -13
  96. package/src/utils/crdt-utils.ts +99 -0
  97. package/src/utils/crdt.ts +8 -30
  98. package/src/utils/test/crdt-blocks.ts +146 -0
  99. package/src/utils/test/crdt-user-selections.ts +5 -0
  100. package/src/utils/test/crdt-utils.ts +387 -0
  101. package/src/utils/test/crdt.ts +120 -53
@@ -11,6 +11,7 @@ describe( 'getQueryParts', () => {
11
11
  context: 'default',
12
12
  page: 2,
13
13
  perPage: 2,
14
+ offset: undefined,
14
15
  stableKey: '',
15
16
  fields: null,
16
17
  include: null,
@@ -28,6 +29,7 @@ describe( 'getQueryParts', () => {
28
29
  context: 'default',
29
30
  page: 1,
30
31
  perPage: 10,
32
+ offset: undefined,
31
33
  stableKey: 'include=1',
32
34
  fields: null,
33
35
  include: [ 1 ],
@@ -43,6 +45,7 @@ describe( 'getQueryParts', () => {
43
45
  context: 'default',
44
46
  page: 1,
45
47
  perPage: 10,
48
+ offset: undefined,
46
49
  stableKey: '%3F=%26&b=2',
47
50
  fields: null,
48
51
  include: null,
@@ -56,6 +59,7 @@ describe( 'getQueryParts', () => {
56
59
  context: 'default',
57
60
  page: 1,
58
61
  perPage: 10,
62
+ offset: undefined,
59
63
  stableKey: 'a%5B0%5D=1&a%5B1%5D=2',
60
64
  fields: null,
61
65
  include: null,
@@ -71,6 +75,7 @@ describe( 'getQueryParts', () => {
71
75
  context: 'default',
72
76
  page: 1,
73
77
  perPage: 10,
78
+ offset: undefined,
74
79
  stableKey: 'b=2',
75
80
  fields: null,
76
81
  include: null,
@@ -84,6 +89,7 @@ describe( 'getQueryParts', () => {
84
89
  context: 'default',
85
90
  page: 1,
86
91
  perPage: -1,
92
+ offset: undefined,
87
93
  stableKey: 'b=2',
88
94
  fields: null,
89
95
  include: null,
@@ -97,6 +103,7 @@ describe( 'getQueryParts', () => {
97
103
  context: 'default',
98
104
  page: 1,
99
105
  perPage: 10,
106
+ offset: undefined,
100
107
  stableKey: '_fields=id%2Ctitle',
101
108
  fields: [ 'id', 'title' ],
102
109
  include: null,
@@ -109,10 +116,37 @@ describe( 'getQueryParts', () => {
109
116
  expect( parts ).toEqual( {
110
117
  page: 1,
111
118
  perPage: 10,
119
+ offset: undefined,
112
120
  stableKey: '',
113
121
  include: null,
114
122
  fields: null,
115
123
  context: 'view',
116
124
  } );
117
125
  } );
126
+
127
+ it( 'extracts offset and includes it in stableKey', () => {
128
+ const parts = getQueryParts( {
129
+ per_page: 50,
130
+ offset: 100,
131
+ } );
132
+
133
+ expect( parts ).toEqual( {
134
+ context: 'default',
135
+ page: 1,
136
+ perPage: 50,
137
+ offset: 100,
138
+ stableKey: 'offset=100',
139
+ fields: null,
140
+ include: null,
141
+ } );
142
+ } );
143
+
144
+ it( 'ignores non-numeric offset values', () => {
145
+ const parts = getQueryParts( {
146
+ per_page: 10,
147
+ offset: 'abc',
148
+ } );
149
+
150
+ expect( parts.offset ).toBeUndefined();
151
+ } );
118
152
  } );
@@ -264,4 +264,162 @@ describe( 'getQueriedItems', () => {
264
264
  const result = getQueriedItems( state, { per_page: 3 } );
265
265
  expect( result ).toBe( null );
266
266
  } );
267
+
268
+ it( 'should return items for offset-based query on the last partial page', () => {
269
+ // Infinite scroll scenario: 103 total items, perPage=50, and the
270
+ // last batch starts at offset=100. The API returns 3 items (items
271
+ // 101-103). X-WP-Total is 103 (the global count). The selector
272
+ // should recognise this as a complete response since
273
+ // 103 - 100 = 3 expected items.
274
+ const state = {
275
+ items: {
276
+ default: {
277
+ 101: { id: 101 },
278
+ 102: { id: 102 },
279
+ 103: { id: 103 },
280
+ },
281
+ },
282
+ itemIsComplete: {
283
+ default: { 101: true, 102: true, 103: true },
284
+ },
285
+ queries: {
286
+ default: {
287
+ 'offset=100': {
288
+ itemIds: [ 101, 102, 103 ],
289
+ meta: { totalItems: 103 },
290
+ },
291
+ },
292
+ },
293
+ };
294
+
295
+ const result = getQueriedItems( state, {
296
+ per_page: 50,
297
+ offset: 100,
298
+ } );
299
+ expect( result ).toEqual( [ { id: 101 }, { id: 102 }, { id: 103 } ] );
300
+ } );
301
+
302
+ it( 'should return null for offset-based query when items are still missing', () => {
303
+ // Offset=50, perPage=50, totalItems=200: the API should return
304
+ // 50 items for this batch but only 2 are stored so far.
305
+ const state = {
306
+ items: {
307
+ default: {
308
+ 51: { id: 51 },
309
+ 52: { id: 52 },
310
+ },
311
+ },
312
+ itemIsComplete: {
313
+ default: { 51: true, 52: true },
314
+ },
315
+ queries: {
316
+ default: {
317
+ 'offset=50': {
318
+ itemIds: [ 51, 52 ],
319
+ meta: { totalItems: 200 },
320
+ },
321
+ },
322
+ },
323
+ };
324
+
325
+ const result = getQueriedItems( state, {
326
+ per_page: 50,
327
+ offset: 50,
328
+ } );
329
+ expect( result ).toBe( null );
330
+ } );
331
+
332
+ it( 'should return null for offset query when items are still missing', () => {
333
+ // Query Block scenario: offset=3 with per_page=10. The effective
334
+ // total is totalItems - offset = 47. Only 5 items are stored, so
335
+ // the data is still incomplete.
336
+ const state = {
337
+ items: {
338
+ default: {
339
+ 4: { id: 4 },
340
+ 5: { id: 5 },
341
+ 6: { id: 6 },
342
+ 7: { id: 7 },
343
+ 8: { id: 8 },
344
+ },
345
+ },
346
+ itemIsComplete: {
347
+ default: { 4: true, 5: true, 6: true, 7: true, 8: true },
348
+ },
349
+ queries: {
350
+ default: {
351
+ 'offset=3': {
352
+ itemIds: [ 4, 5, 6, 7, 8 ],
353
+ meta: { totalItems: 50 },
354
+ },
355
+ },
356
+ },
357
+ };
358
+
359
+ const result = getQueriedItems( state, {
360
+ per_page: 10,
361
+ offset: 3,
362
+ } );
363
+ expect( result ).toBe( null );
364
+ } );
365
+
366
+ it( 'should treat offset=0 the same as no offset', () => {
367
+ // The Query Block defaults to offset=0. Since
368
+ // effectiveTotal = totalItems - 0 = totalItems, this should
369
+ // behave identically to a query without offset.
370
+ const state = {
371
+ items: {
372
+ default: {
373
+ 1: { id: 1 },
374
+ 2: { id: 2 },
375
+ },
376
+ },
377
+ itemIsComplete: {
378
+ default: { 1: true, 2: true },
379
+ },
380
+ queries: {
381
+ default: {
382
+ 'offset=0': {
383
+ itemIds: [ 1, 2 ],
384
+ meta: { totalItems: 5 },
385
+ },
386
+ },
387
+ },
388
+ };
389
+
390
+ // 2 items stored, but 5 total exist — should return null.
391
+ const result = getQueriedItems( state, {
392
+ per_page: 3,
393
+ offset: 0,
394
+ } );
395
+ expect( result ).toBe( null );
396
+ } );
397
+
398
+ it( 'should return empty array when offset equals totalItems', () => {
399
+ // Edge case: offset lands exactly at the end (e.g. 84 items,
400
+ // per_page=7, offset=84). The API returns 0 items and that is
401
+ // a complete response — effectiveTotal is 0.
402
+ const state = {
403
+ items: {
404
+ default: {},
405
+ },
406
+ itemIsComplete: {
407
+ default: {},
408
+ },
409
+ queries: {
410
+ default: {
411
+ 'offset=84': {
412
+ itemIds: [],
413
+ meta: { totalItems: 84 },
414
+ },
415
+ },
416
+ },
417
+ };
418
+
419
+ const result = getQueriedItems( state, {
420
+ per_page: 7,
421
+ offset: 84,
422
+ } );
423
+ expect( result ).toEqual( [] );
424
+ } );
267
425
  } );
package/src/reducer.js CHANGED
@@ -16,6 +16,7 @@ import { createUndoManager } from '@wordpress/undo-manager';
16
16
  import { ifMatchingAction, replaceAction } from './utils';
17
17
  import { reducer as queriedDataReducer } from './queried-data';
18
18
  import { rootEntitiesConfig, DEFAULT_ENTITY_KEY } from './entities';
19
+ import { ConnectionErrorCode } from './sync';
19
20
 
20
21
  /** @typedef {import('./types').AnyFunction} AnyFunction */
21
22
 
@@ -706,6 +707,16 @@ export function collaborationSupported( state = true, action ) {
706
707
  switch ( action.type ) {
707
708
  case 'SET_COLLABORATION_SUPPORTED':
708
709
  return action.supported;
710
+
711
+ case 'SET_SYNC_CONNECTION_STATUS':
712
+ if (
713
+ ConnectionErrorCode.DOCUMENT_SIZE_LIMIT_EXCEEDED ===
714
+ action.status?.error?.code
715
+ ) {
716
+ return false;
717
+ }
718
+
719
+ return state;
709
720
  }
710
721
  return state;
711
722
  }
package/src/sync.ts CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  import { unlock } from './lock-unlock';
13
13
 
14
14
  const {
15
+ ConnectionErrorCode,
15
16
  createSyncManager,
16
17
  Delta,
17
18
  CRDT_DOC_META_PERSISTENCE_KEY,
@@ -22,6 +23,7 @@ const {
22
23
  } = unlock( syncPrivateApis );
23
24
 
24
25
  export {
26
+ ConnectionErrorCode,
25
27
  Delta,
26
28
  CRDT_DOC_META_PERSISTENCE_KEY,
27
29
  CRDT_RECORD_MAP_KEY,
@@ -8,6 +8,10 @@ jest.mock( '../sync', () => ( {
8
8
  ...jest.requireActual( '../sync' ),
9
9
  getSyncManager: jest.fn(),
10
10
  } ) );
11
+ jest.mock( '../utils/crdt', () => ( {
12
+ ...jest.requireActual( '../utils/crdt' ),
13
+ applyPostChangesToCRDTDoc: jest.fn(),
14
+ } ) );
11
15
 
12
16
  /**
13
17
  * Internal dependencies
@@ -19,7 +23,10 @@ import {
19
23
  additionalEntityConfigLoaders,
20
24
  } from '../entities';
21
25
  import { getSyncManager } from '../sync';
22
- import { POST_META_KEY_FOR_CRDT_DOC_PERSISTENCE } from '../utils/crdt';
26
+ import {
27
+ applyPostChangesToCRDTDoc,
28
+ POST_META_KEY_FOR_CRDT_DOC_PERSISTENCE,
29
+ } from '../utils/crdt';
23
30
 
24
31
  describe( 'getMethodName', () => {
25
32
  it( 'should return the right method name for an entity with the root kind', () => {
@@ -127,6 +134,183 @@ describe( 'prePersistPostType', () => {
127
134
  } );
128
135
  } );
129
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 );
311
+ } );
312
+ } );
313
+
130
314
  describe( 'loadTaxonomyEntities', () => {
131
315
  beforeEach( () => {
132
316
  apiFetch.mockReset();
package/src/types.ts CHANGED
@@ -1,13 +1,19 @@
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
9
  import type { SelectionType } from './utils/crdt-user-selections';
10
10
 
11
+ export type { ConnectionStatus } from '@wordpress/sync';
12
+
13
+ export type ConnectionError = NonNullable<
14
+ ConnectionStatusDisconnected[ 'error' ]
15
+ >;
16
+
11
17
  export interface AnyFunction {
12
18
  ( ...args: any[] ): any;
13
19
  }
@@ -127,6 +133,6 @@ export type SelectionState =
127
133
  | SelectionWholeBlock;
128
134
 
129
135
  export interface ResolvedSelection {
130
- textIndex: number | null;
136
+ richTextOffset: number | null;
131
137
  localClientId: string | null;
132
138
  }
@@ -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 {
@@ -62,6 +62,37 @@ export type YBlockAttributes = Y.Map< Y.Text | unknown >;
62
62
 
63
63
  const serializableBlocksCache = new WeakMap< WeakKey, Block[] >();
64
64
 
65
+ /**
66
+ * Recursively walk an attribute value and convert any RichTextData instances
67
+ * to their string (HTML) representation. This is necessary for array-type and
68
+ * object-type attributes, which can contain nested RichTextData.
69
+ *
70
+ * @param value The attribute value to serialize.
71
+ * @return The value with all RichTextData instances replaced by strings.
72
+ */
73
+ function serializeAttributeValue( value: unknown ): unknown {
74
+ if ( value instanceof RichTextData ) {
75
+ return value.valueOf();
76
+ }
77
+
78
+ // e.g. core/table `body`: [ { cells: [ { content: RichTextData } ] } ]
79
+ if ( Array.isArray( value ) ) {
80
+ return value.map( serializeAttributeValue );
81
+ }
82
+
83
+ // e.g. a single row inside core/table `body`: { cells: [ ... ] }
84
+ if ( value && typeof value === 'object' ) {
85
+ const result: Record< string, unknown > = {};
86
+
87
+ for ( const [ k, v ] of Object.entries( value ) ) {
88
+ result[ k ] = serializeAttributeValue( v );
89
+ }
90
+ return result;
91
+ }
92
+
93
+ return value;
94
+ }
95
+
65
96
  function makeBlockAttributesSerializable(
66
97
  blockName: string,
67
98
  attributes: BlockAttributes
@@ -73,9 +104,7 @@ function makeBlockAttributesSerializable(
73
104
  continue;
74
105
  }
75
106
 
76
- if ( value instanceof RichTextData ) {
77
- newAttributes[ key ] = value.valueOf();
78
- }
107
+ newAttributes[ key ] = serializeAttributeValue( value );
79
108
  }
80
109
  return newAttributes;
81
110
  }
@@ -18,7 +18,10 @@ import {
18
18
  type YFullSelection,
19
19
  type YSelection,
20
20
  } from './block-selection-history';
21
- import { findBlockByClientIdInDoc } from './crdt-utils';
21
+ import {
22
+ findBlockByClientIdInDoc,
23
+ htmlIndexToRichTextOffset,
24
+ } from './crdt-utils';
22
25
  import type { WPBlockSelection, WPSelection } from '../types';
23
26
 
24
27
  // WeakMap to store BlockSelectionHistory instances per Y.Doc
@@ -74,7 +77,10 @@ function convertYSelectionToBlockSelection(
74
77
  return {
75
78
  clientId,
76
79
  attributeKey,
77
- offset: absolutePosition.index,
80
+ offset: htmlIndexToRichTextOffset(
81
+ absolutePosition.type.toString(),
82
+ absolutePosition.index
83
+ ),
78
84
  };
79
85
  }
80
86
  } else if ( ySelection.type === YSelectionType.BlockSelection ) {
@@ -12,18 +12,18 @@ import { store as blockEditorStore } from '@wordpress/block-editor';
12
12
  import { CRDT_RECORD_MAP_KEY } from '../sync';
13
13
  import type { YPostRecord } from './crdt';
14
14
  import type { YBlock, YBlocks } from './crdt-blocks';
15
- import { getRootMap } from './crdt-utils';
16
- import type { SelectionDirection } from '../types';
17
- import {
18
- type AbsoluteBlockIndexPath,
19
- type WPBlockSelection,
20
- type SelectionState,
21
- type SelectionNone,
22
- type SelectionCursor,
23
- type SelectionInOneBlock,
24
- type SelectionInMultipleBlocks,
25
- type SelectionWholeBlock,
26
- type CursorPosition,
15
+ import { getRootMap, richTextOffsetToHtmlIndex } from './crdt-utils';
16
+ import type {
17
+ AbsoluteBlockIndexPath,
18
+ WPBlockSelection,
19
+ SelectionState,
20
+ SelectionNone,
21
+ SelectionCursor,
22
+ SelectionInOneBlock,
23
+ SelectionInMultipleBlocks,
24
+ SelectionWholeBlock,
25
+ SelectionDirection,
26
+ CursorPosition,
27
27
  } from '../types';
28
28
 
29
29
  /**
@@ -178,7 +178,7 @@ function getCursorPosition(
178
178
 
179
179
  const relativePosition = Y.createRelativePositionFromTypeIndex(
180
180
  currentYText,
181
- selection.offset
181
+ richTextOffsetToHtmlIndex( currentYText.toString(), selection.offset )
182
182
  );
183
183
 
184
184
  return {