@wordpress/core-data 7.48.0 → 7.48.2-next.v.202606191442.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 (148) hide show
  1. package/CHANGELOG.md +7 -1
  2. package/build/actions.cjs +1 -7
  3. package/build/actions.cjs.map +3 -3
  4. package/build/awareness/block-lookup.cjs +14 -26
  5. package/build/awareness/block-lookup.cjs.map +2 -2
  6. package/build/awareness/post-editor-awareness.cjs +4 -3
  7. package/build/awareness/post-editor-awareness.cjs.map +2 -2
  8. package/build/entities.cjs +6 -3
  9. package/build/entities.cjs.map +2 -2
  10. package/build/entity-types/helpers.cjs.map +1 -1
  11. package/build/hooks/use-entity-record.cjs +21 -19
  12. package/build/hooks/use-entity-record.cjs.map +3 -3
  13. package/build/hooks/use-entity-records.cjs +22 -20
  14. package/build/hooks/use-entity-records.cjs.map +3 -3
  15. package/build/hooks/use-post-editor-awareness-state.cjs +8 -2
  16. package/build/hooks/use-post-editor-awareness-state.cjs.map +2 -2
  17. package/build/hooks/use-query-select.cjs +2 -20
  18. package/build/hooks/use-query-select.cjs.map +2 -2
  19. package/build/hooks/utils.cjs +53 -0
  20. package/build/hooks/utils.cjs.map +7 -0
  21. package/build/private-actions.cjs +8 -0
  22. package/build/private-actions.cjs.map +2 -2
  23. package/build/private-selectors.cjs.map +2 -2
  24. package/build/reducer.cjs +23 -7
  25. package/build/reducer.cjs.map +2 -2
  26. package/build/resolvers.cjs +13 -8
  27. package/build/resolvers.cjs.map +2 -2
  28. package/build/selectors.cjs +7 -0
  29. package/build/selectors.cjs.map +2 -2
  30. package/build/types.cjs.map +1 -1
  31. package/build/utils/clear-unchanged-edits.cjs +51 -0
  32. package/build/utils/clear-unchanged-edits.cjs.map +7 -0
  33. package/build/utils/crdt-blocks.cjs +12 -2
  34. package/build/utils/crdt-blocks.cjs.map +2 -2
  35. package/build/utils/crdt-user-selections.cjs.map +1 -1
  36. package/build/utils/crdt-utils.cjs.map +1 -1
  37. package/build/utils/crdt.cjs +2 -1
  38. package/build/utils/crdt.cjs.map +2 -2
  39. package/build/utils/index.cjs +6 -0
  40. package/build/utils/index.cjs.map +2 -2
  41. package/build/utils/save-crdt-doc.cjs +75 -0
  42. package/build/utils/save-crdt-doc.cjs.map +7 -0
  43. package/build/utils/set-nested-value.cjs.map +1 -1
  44. package/build-module/actions.mjs +2 -8
  45. package/build-module/actions.mjs.map +2 -2
  46. package/build-module/awareness/block-lookup.mjs +13 -26
  47. package/build-module/awareness/block-lookup.mjs.map +2 -2
  48. package/build-module/awareness/post-editor-awareness.mjs +4 -3
  49. package/build-module/awareness/post-editor-awareness.mjs.map +2 -2
  50. package/build-module/entities.mjs +6 -3
  51. package/build-module/entities.mjs.map +2 -2
  52. package/build-module/hooks/use-entity-record.mjs +21 -19
  53. package/build-module/hooks/use-entity-record.mjs.map +2 -2
  54. package/build-module/hooks/use-entity-records.mjs +20 -18
  55. package/build-module/hooks/use-entity-records.mjs.map +2 -2
  56. package/build-module/hooks/use-post-editor-awareness-state.mjs +9 -3
  57. package/build-module/hooks/use-post-editor-awareness-state.mjs.map +2 -2
  58. package/build-module/hooks/use-query-select.mjs +2 -20
  59. package/build-module/hooks/use-query-select.mjs.map +2 -2
  60. package/build-module/hooks/utils.mjs +28 -0
  61. package/build-module/hooks/utils.mjs.map +7 -0
  62. package/build-module/private-actions.mjs +7 -0
  63. package/build-module/private-actions.mjs.map +2 -2
  64. package/build-module/private-selectors.mjs.map +2 -2
  65. package/build-module/reducer.mjs +23 -8
  66. package/build-module/reducer.mjs.map +2 -2
  67. package/build-module/resolvers.mjs +15 -9
  68. package/build-module/resolvers.mjs.map +2 -2
  69. package/build-module/selectors.mjs +7 -0
  70. package/build-module/selectors.mjs.map +2 -2
  71. package/build-module/utils/clear-unchanged-edits.mjs +20 -0
  72. package/build-module/utils/clear-unchanged-edits.mjs.map +7 -0
  73. package/build-module/utils/crdt-blocks.mjs +12 -2
  74. package/build-module/utils/crdt-blocks.mjs.map +2 -2
  75. package/build-module/utils/crdt-user-selections.mjs.map +1 -1
  76. package/build-module/utils/crdt-utils.mjs.map +1 -1
  77. package/build-module/utils/crdt.mjs +2 -1
  78. package/build-module/utils/crdt.mjs.map +2 -2
  79. package/build-module/utils/index.mjs +24 -20
  80. package/build-module/utils/index.mjs.map +2 -2
  81. package/build-module/utils/save-crdt-doc.mjs +40 -0
  82. package/build-module/utils/save-crdt-doc.mjs.map +7 -0
  83. package/build-module/utils/set-nested-value.mjs.map +1 -1
  84. package/build-types/actions.d.ts.map +1 -1
  85. package/build-types/awareness/block-lookup.d.ts +27 -7
  86. package/build-types/awareness/block-lookup.d.ts.map +1 -1
  87. package/build-types/awareness/post-editor-awareness.d.ts +3 -1
  88. package/build-types/awareness/post-editor-awareness.d.ts.map +1 -1
  89. package/build-types/entities.d.ts.map +1 -1
  90. package/build-types/hooks/use-entity-record.d.ts +4 -0
  91. package/build-types/hooks/use-entity-record.d.ts.map +1 -1
  92. package/build-types/hooks/use-entity-records.d.ts +5 -1
  93. package/build-types/hooks/use-entity-records.d.ts.map +1 -1
  94. package/build-types/hooks/use-post-editor-awareness-state.d.ts.map +1 -1
  95. package/build-types/hooks/utils.d.ts +22 -0
  96. package/build-types/hooks/utils.d.ts.map +1 -0
  97. package/build-types/index.d.ts +8 -8
  98. package/build-types/private-actions.d.ts +15 -0
  99. package/build-types/private-actions.d.ts.map +1 -1
  100. package/build-types/private-selectors.d.ts +0 -12
  101. package/build-types/private-selectors.d.ts.map +1 -1
  102. package/build-types/reducer.d.ts +15 -0
  103. package/build-types/reducer.d.ts.map +1 -1
  104. package/build-types/resolvers.d.ts.map +1 -1
  105. package/build-types/selectors.d.ts +12 -8
  106. package/build-types/selectors.d.ts.map +1 -1
  107. package/build-types/utils/clear-unchanged-edits.d.ts +12 -0
  108. package/build-types/utils/clear-unchanged-edits.d.ts.map +1 -0
  109. package/build-types/utils/crdt-blocks.d.ts +5 -1
  110. package/build-types/utils/crdt-blocks.d.ts.map +1 -1
  111. package/build-types/utils/crdt.d.ts.map +1 -1
  112. package/build-types/utils/index.d.ts +2 -0
  113. package/build-types/utils/index.d.ts.map +1 -1
  114. package/build-types/utils/save-crdt-doc.d.ts +8 -0
  115. package/build-types/utils/save-crdt-doc.d.ts.map +1 -0
  116. package/package.json +27 -20
  117. package/src/actions.js +2 -10
  118. package/src/awareness/block-lookup.ts +21 -62
  119. package/src/awareness/post-editor-awareness.ts +8 -3
  120. package/src/awareness/test/block-lookup.ts +98 -94
  121. package/src/awareness/test/post-editor-awareness.ts +177 -180
  122. package/src/entities.js +14 -3
  123. package/src/hooks/test/use-entity-record.js +5 -1
  124. package/src/hooks/test/use-post-editor-awareness-state.ts +10 -2
  125. package/src/hooks/use-entity-record.ts +26 -19
  126. package/src/hooks/use-entity-records.ts +26 -18
  127. package/src/hooks/use-post-editor-awareness-state.ts +20 -7
  128. package/src/hooks/use-query-select.ts +2 -23
  129. package/src/hooks/utils.ts +40 -0
  130. package/src/private-actions.js +18 -0
  131. package/src/private-selectors.ts +0 -12
  132. package/src/reducer.js +30 -9
  133. package/src/resolvers.js +20 -13
  134. package/src/selectors.ts +11 -0
  135. package/src/test/entities.js +51 -0
  136. package/src/test/private-selectors.js +66 -0
  137. package/src/test/reducer.js +44 -0
  138. package/src/test/resolvers.js +121 -113
  139. package/src/test/selectors.js +48 -0
  140. package/src/utils/clear-unchanged-edits.ts +34 -0
  141. package/src/utils/crdt-blocks.ts +27 -22
  142. package/src/utils/crdt.ts +2 -1
  143. package/src/utils/index.js +2 -0
  144. package/src/utils/save-crdt-doc.js +64 -0
  145. package/src/utils/test/clear-unchanged-edits.js +42 -0
  146. package/src/utils/test/crdt-blocks.ts +57 -2
  147. package/src/utils/test/rtc-rich-text-cursor-scope.test.js +2 -2
  148. package/src/utils/test/save-crdt-doc.js +185 -0
@@ -12,6 +12,8 @@ import {
12
12
  userPermissions,
13
13
  autosaves,
14
14
  currentUser,
15
+ syncUndoManagerState,
16
+ undoManager,
15
17
  } from '../reducer';
16
18
 
17
19
  describe( 'entities', () => {
@@ -531,3 +533,45 @@ describe( 'currentUser', () => {
531
533
  expect( state ).toEqual( currentUserData );
532
534
  } );
533
535
  } );
536
+
537
+ describe( 'undoManager', () => {
538
+ it( 'returns the same reference for unrelated actions', () => {
539
+ const originalState = undoManager( undefined, {} );
540
+ const state = undoManager( originalState, {
541
+ type: 'UNRELATED',
542
+ } );
543
+
544
+ expect( state ).toBe( originalState );
545
+ } );
546
+ } );
547
+
548
+ describe( 'syncUndoManagerState', () => {
549
+ it( 'stores sync undo manager availability', () => {
550
+ const state = syncUndoManagerState( undefined, {
551
+ type: 'SYNC_UNDO_MANAGER_CHANGE',
552
+ hasRedo: false,
553
+ hasUndo: true,
554
+ } );
555
+
556
+ expect( state ).toEqual( {
557
+ hasRedo: false,
558
+ hasUndo: true,
559
+ } );
560
+ } );
561
+
562
+ it( 'updates sync undo manager availability', () => {
563
+ const state = syncUndoManagerState(
564
+ { hasRedo: false, hasUndo: true },
565
+ {
566
+ type: 'SYNC_UNDO_MANAGER_CHANGE',
567
+ hasRedo: true,
568
+ hasUndo: false,
569
+ }
570
+ );
571
+
572
+ expect( state ).toEqual( {
573
+ hasRedo: true,
574
+ hasUndo: false,
575
+ } );
576
+ } );
577
+ } );
@@ -25,7 +25,6 @@ import {
25
25
  getAutosaves,
26
26
  getCurrentUser,
27
27
  } from '../resolvers';
28
- import { saveEntityRecord } from '../actions';
29
28
  import { RECEIVE_INTERMEDIATE_RESULTS } from '../utils';
30
29
 
31
30
  describe( 'getEntityRecord', () => {
@@ -50,6 +49,7 @@ describe( 'getEntityRecord', () => {
50
49
  receiveEntityRecords: jest.fn(),
51
50
  __unstableAcquireStoreLock: jest.fn(),
52
51
  __unstableReleaseStoreLock: jest.fn(),
52
+ __unstableNotifySyncUndoManagerChange: jest.fn(),
53
53
  receiveUserPermissions: jest.fn(),
54
54
  finishResolutions: jest.fn(),
55
55
  } );
@@ -57,6 +57,7 @@ describe( 'getEntityRecord', () => {
57
57
 
58
58
  syncManager = {
59
59
  load: jest.fn(),
60
+ update: jest.fn(),
60
61
  };
61
62
  getSyncManager.mockImplementation( () => syncManager );
62
63
  } );
@@ -173,6 +174,7 @@ describe( 'getEntityRecord', () => {
173
174
  addUndoMeta: expect.any( Function ),
174
175
  editRecord: expect.any( Function ),
175
176
  getEditedRecord: expect.any( Function ),
177
+ onUndoStackChange: expect.any( Function ),
176
178
  onStatusChange: expect.any( Function ),
177
179
  persistCRDTDoc: expect.any( Function ),
178
180
  refetchRecord: expect.any( Function ),
@@ -181,7 +183,48 @@ describe( 'getEntityRecord', () => {
181
183
  );
182
184
  } );
183
185
 
184
- it( 'persistCRDTDoc fetches edited record and does not save full entity record when the entity does not support meta', async () => {
186
+ it( 'notifies core-data when the sync undo manager stack changes', async () => {
187
+ const POST_RECORD = { id: 1, title: 'Test Post' };
188
+ const POST_RESPONSE = {
189
+ json: () => Promise.resolve( POST_RECORD ),
190
+ };
191
+ const ENTITIES_WITH_SYNC = [
192
+ {
193
+ name: 'post',
194
+ kind: 'postType',
195
+ baseURL: '/wp/v2/posts',
196
+ baseURLParams: { context: 'edit' },
197
+ syncConfig: {},
198
+ },
199
+ ];
200
+
201
+ const resolveSelectWithSync = {
202
+ getEntitiesConfig: jest.fn( () => ENTITIES_WITH_SYNC ),
203
+ getEditedEntityRecord: jest.fn(),
204
+ };
205
+
206
+ triggerFetch.mockImplementation( () => POST_RESPONSE );
207
+
208
+ await getEntityRecord(
209
+ 'postType',
210
+ 'post',
211
+ 1
212
+ )( {
213
+ dispatch,
214
+ registry,
215
+ resolveSelect: resolveSelectWithSync,
216
+ } );
217
+
218
+ const handlers = syncManager.load.mock.calls[ 0 ][ 4 ];
219
+
220
+ handlers.onUndoStackChange( { hasRedo: false, hasUndo: true } );
221
+
222
+ expect(
223
+ dispatch.__unstableNotifySyncUndoManagerChange
224
+ ).toHaveBeenCalledWith( { hasRedo: false, hasUndo: true } );
225
+ } );
226
+
227
+ it( 'persistCRDTDoc fetches edited post record and does not save when the entity does not support meta', async () => {
185
228
  const ENTITY_RECORD = { id: 1, title: 'Test Record' };
186
229
  const EDITED_RECORD = { id: 1, title: 'Edited Record' };
187
230
  const ENTITY_RESPONSE = {
@@ -189,15 +232,16 @@ describe( 'getEntityRecord', () => {
189
232
  };
190
233
  const ENTITIES_WITH_SYNC = [
191
234
  {
192
- name: 'bar',
193
- kind: 'foo',
194
- baseURL: '/wp/v2/foo',
235
+ name: 'post',
236
+ kind: 'postType',
237
+ baseURL: '/wp/v2/posts',
195
238
  baseURLParams: { context: 'edit' },
196
- syncConfig: {},
239
+ syncConfig: { supportsPersistence: true },
197
240
  },
198
241
  ];
199
242
 
200
243
  dispatch.saveEntityRecord = jest.fn();
244
+ syncManager.createPersistedCRDTDoc = jest.fn();
201
245
 
202
246
  const resolveSelectWithSync = {
203
247
  getEntitiesConfig: jest.fn( () => ENTITIES_WITH_SYNC ),
@@ -209,8 +253,8 @@ describe( 'getEntityRecord', () => {
209
253
  triggerFetch.mockImplementation( () => ENTITY_RESPONSE );
210
254
 
211
255
  await getEntityRecord(
212
- 'foo',
213
- 'bar',
256
+ 'postType',
257
+ 'post',
214
258
  1
215
259
  )( {
216
260
  dispatch,
@@ -222,19 +266,20 @@ describe( 'getEntityRecord', () => {
222
266
  const handlers = syncManager.load.mock.calls[ 0 ][ 4 ];
223
267
 
224
268
  // Call persistCRDTDoc and wait for the internal promise chain.
225
- handlers.persistCRDTDoc();
226
- await resolveSelectWithSync.getEditedEntityRecord();
269
+ await handlers.persistCRDTDoc();
227
270
 
228
271
  // Should have fetched the full edited entity record.
229
272
  expect(
230
273
  resolveSelectWithSync.getEditedEntityRecord
231
- ).toHaveBeenCalledWith( 'foo', 'bar', 1 );
274
+ ).toHaveBeenCalledWith( 'postType', 'post', 1 );
232
275
 
233
276
  // Should not have called saveEntityRecord.
234
277
  expect( dispatch.saveEntityRecord ).not.toHaveBeenCalled();
278
+ expect( syncManager.createPersistedCRDTDoc ).not.toHaveBeenCalled();
235
279
  } );
236
280
 
237
- it( 'persistCRDTDoc saves only the entity ID and omits REST-invalid fields', async () => {
281
+ it( 'persistCRDTDoc saves post CRDT docs through the sync endpoint', async () => {
282
+ const SERIALIZED_DOC = 'serialized-crdt-doc';
238
283
  const POST_RECORD = { id: 1, title: 'Test Post', meta: {} };
239
284
  const EDITED_RECORD = {
240
285
  id: 1,
@@ -251,11 +296,14 @@ describe( 'getEntityRecord', () => {
251
296
  kind: 'postType',
252
297
  baseURL: '/wp/v2/posts',
253
298
  baseURLParams: { context: 'edit' },
254
- syncConfig: {},
299
+ syncConfig: { supportsPersistence: true },
255
300
  },
256
301
  ];
257
302
 
258
303
  dispatch.saveEntityRecord = jest.fn();
304
+ syncManager.createPersistedCRDTDoc = jest.fn( () =>
305
+ Promise.resolve( SERIALIZED_DOC )
306
+ );
259
307
 
260
308
  const resolveSelectWithSync = {
261
309
  getEntitiesConfig: jest.fn( () => ENTITIES_WITH_SYNC ),
@@ -264,7 +312,9 @@ describe( 'getEntityRecord', () => {
264
312
  ),
265
313
  };
266
314
 
267
- triggerFetch.mockImplementation( () => POST_RESPONSE );
315
+ triggerFetch
316
+ .mockImplementationOnce( () => POST_RESPONSE )
317
+ .mockImplementationOnce( () => [] );
268
318
 
269
319
  await getEntityRecord(
270
320
  'postType',
@@ -280,25 +330,31 @@ describe( 'getEntityRecord', () => {
280
330
  const handlers = syncManager.load.mock.calls[ 0 ][ 4 ];
281
331
 
282
332
  // Call persistCRDTDoc and wait for the internal promise chain.
283
- handlers.persistCRDTDoc();
284
- await resolveSelectWithSync.getEditedEntityRecord();
333
+ await handlers.persistCRDTDoc();
285
334
 
286
335
  // Should have fetched the full edited entity record.
287
336
  expect(
288
337
  resolveSelectWithSync.getEditedEntityRecord
289
338
  ).toHaveBeenCalledWith( 'postType', 'post', 1 );
290
339
 
291
- // Should only send the entity ID. The pre-persist hook creates the
292
- // persisted CRDT meta without round-tripping the full edited record.
293
- expect( dispatch.saveEntityRecord ).toHaveBeenCalledWith(
294
- 'postType',
295
- 'post',
296
- { id: 1 },
297
- { __unstableSkipSyncUpdate: true }
340
+ expect( syncManager.createPersistedCRDTDoc ).toHaveBeenCalledWith(
341
+ 'postType/post',
342
+ 1
298
343
  );
344
+ expect( triggerFetch ).toHaveBeenLastCalledWith( {
345
+ path: '/wp-sync/v1/save',
346
+ method: 'POST',
347
+ data: {
348
+ room: 'postType/post:1',
349
+ doc: SERIALIZED_DOC,
350
+ },
351
+ } );
352
+ expect( syncManager.update ).not.toHaveBeenCalled();
353
+ expect( dispatch.saveEntityRecord ).not.toHaveBeenCalled();
299
354
  } );
300
355
 
301
- it( 'persistCRDTDoc saves even when there are no unsaved edits', async () => {
356
+ it( 'persistCRDTDoc persists post CRDT docs even when there are no unsaved edits', async () => {
357
+ const SERIALIZED_DOC = 'serialized-crdt-doc';
302
358
  const POST_RECORD = { id: 1, title: 'Test Post', meta: {} };
303
359
  const POST_RESPONSE = {
304
360
  json: () => Promise.resolve( POST_RECORD ),
@@ -309,11 +365,14 @@ describe( 'getEntityRecord', () => {
309
365
  kind: 'postType',
310
366
  baseURL: '/wp/v2/posts',
311
367
  baseURLParams: { context: 'edit' },
312
- syncConfig: {},
368
+ syncConfig: { supportsPersistence: true },
313
369
  },
314
370
  ];
315
371
 
316
372
  dispatch.saveEntityRecord = jest.fn();
373
+ syncManager.createPersistedCRDTDoc = jest.fn( () =>
374
+ Promise.resolve( SERIALIZED_DOC )
375
+ );
317
376
 
318
377
  // Return the same record (no edits) from getEditedEntityRecord.
319
378
  const resolveSelectWithSync = {
@@ -323,7 +382,9 @@ describe( 'getEntityRecord', () => {
323
382
  ),
324
383
  };
325
384
 
326
- triggerFetch.mockImplementation( () => POST_RESPONSE );
385
+ triggerFetch
386
+ .mockImplementationOnce( () => POST_RESPONSE )
387
+ .mockImplementationOnce( () => [] );
327
388
 
328
389
  await getEntityRecord(
329
390
  'postType',
@@ -338,95 +399,55 @@ describe( 'getEntityRecord', () => {
338
399
  const handlers = syncManager.load.mock.calls[ 0 ][ 4 ];
339
400
 
340
401
  // Call persistCRDTDoc and wait for the internal promise chain.
341
- handlers.persistCRDTDoc();
342
- await resolveSelectWithSync.getEditedEntityRecord();
402
+ await handlers.persistCRDTDoc();
343
403
 
344
- // Should save only the entity ID even with no edits.
345
- expect( dispatch.saveEntityRecord ).toHaveBeenCalledWith(
346
- 'postType',
347
- 'post',
348
- { id: 1 },
349
- { __unstableSkipSyncUpdate: true }
350
- );
404
+ expect( triggerFetch ).toHaveBeenLastCalledWith( {
405
+ path: '/wp-sync/v1/save',
406
+ method: 'POST',
407
+ data: {
408
+ room: 'postType/post:1',
409
+ doc: SERIALIZED_DOC,
410
+ },
411
+ } );
412
+ expect( syncManager.update ).not.toHaveBeenCalled();
413
+ expect( dispatch.saveEntityRecord ).not.toHaveBeenCalled();
351
414
  } );
352
415
 
353
- it( 'persistCRDTDoc does not replay a stale save response into the sync document', async () => {
354
- const INITIAL_TITLE = 'Initial Title';
355
- const SYNCED_TITLE = 'Synced Title';
356
- const POST_RECORD = { id: 1, title: INITIAL_TITLE, meta: {} };
357
- const EDITED_RECORD = { id: 1, title: SYNCED_TITLE, meta: {} };
358
- const STALE_SAVE_RESPONSE = {
416
+ it( 'persistCRDTDoc does not persist entities whose sync config does not support persistence', async () => {
417
+ const TERM_RECORD = { id: 1, name: 'Category', meta: {} };
418
+ const EDITED_RECORD = {
359
419
  id: 1,
360
- title: INITIAL_TITLE,
361
- meta: { _crdt_document: 'serialized-crdt-doc' },
362
- };
363
- const liveSyncState = {
364
- isSaved: false,
365
- title: SYNCED_TITLE,
420
+ name: 'Edited Category',
421
+ description: '',
422
+ meta: {},
366
423
  };
367
- const POST_RESPONSE = {
368
- json: () => Promise.resolve( POST_RECORD ),
424
+ const TERM_RESPONSE = {
425
+ json: () => Promise.resolve( TERM_RECORD ),
369
426
  };
370
427
  const ENTITIES_WITH_SYNC = [
371
428
  {
372
- name: 'post',
373
- kind: 'postType',
374
- baseURL: '/wp/v2/posts',
429
+ name: 'category',
430
+ kind: 'taxonomy',
431
+ baseURL: '/wp/v2/categories',
375
432
  baseURLParams: { context: 'edit' },
376
433
  syncConfig: {},
377
- __unstablePrePersist: jest.fn( async () => ( {
378
- meta: { _crdt_document: 'serialized-crdt-doc' },
379
- } ) ),
380
434
  },
381
435
  ];
382
436
 
383
- const select = {
384
- getEditedEntityRecord: jest.fn( () => EDITED_RECORD ),
385
- getRawEntityRecord: jest.fn( () => POST_RECORD ),
386
- };
387
437
  const resolveSelectWithSync = {
388
438
  getEntitiesConfig: jest.fn( () => ENTITIES_WITH_SYNC ),
389
439
  getEditedEntityRecord: jest.fn( () =>
390
440
  Promise.resolve( EDITED_RECORD )
391
441
  ),
392
442
  };
393
- let savePromise;
394
-
395
- syncManager.update = jest.fn(
396
- ( _objectType, _objectId, changes, _origin, options ) => {
397
- if (
398
- Object.prototype.hasOwnProperty.call( changes, 'title' )
399
- ) {
400
- liveSyncState.title = changes.title;
401
- }
402
- if ( options?.isSave ) {
403
- liveSyncState.isSaved = true;
404
- }
405
- }
406
- );
407
- dispatch.saveEntityRecord = jest.fn(
408
- ( kind, name, record, options ) => {
409
- savePromise = saveEntityRecord(
410
- kind,
411
- name,
412
- record,
413
- options
414
- )( {
415
- select,
416
- dispatch,
417
- resolveSelect: resolveSelectWithSync,
418
- } );
419
- return savePromise;
420
- }
421
- );
443
+ dispatch.saveEntityRecord = jest.fn();
444
+ syncManager.createPersistedCRDTDoc = jest.fn();
422
445
 
423
- triggerFetch
424
- .mockImplementationOnce( () => POST_RESPONSE )
425
- .mockImplementationOnce( () => STALE_SAVE_RESPONSE );
446
+ triggerFetch.mockImplementation( () => TERM_RESPONSE );
426
447
 
427
448
  await getEntityRecord(
428
- 'postType',
429
- 'post',
449
+ 'taxonomy',
450
+ 'category',
430
451
  1
431
452
  )( {
432
453
  dispatch,
@@ -436,27 +457,13 @@ describe( 'getEntityRecord', () => {
436
457
 
437
458
  const handlers = syncManager.load.mock.calls[ 0 ][ 4 ];
438
459
 
439
- handlers.persistCRDTDoc();
440
- await Promise.resolve();
441
- await savePromise;
460
+ await handlers.persistCRDTDoc();
442
461
 
443
- expect( dispatch.saveEntityRecord ).toHaveBeenCalledWith(
444
- 'postType',
445
- 'post',
446
- { id: 1 },
447
- { __unstableSkipSyncUpdate: true }
448
- );
449
- expect( syncManager.update ).toHaveBeenCalledWith(
450
- 'postType/post',
451
- 1,
452
- {},
453
- 'local-undo-ignored',
454
- { isSave: true }
455
- );
456
- expect( liveSyncState ).toEqual( {
457
- isSaved: true,
458
- title: SYNCED_TITLE,
459
- } );
462
+ expect(
463
+ resolveSelectWithSync.getEditedEntityRecord
464
+ ).not.toHaveBeenCalled();
465
+ expect( dispatch.saveEntityRecord ).not.toHaveBeenCalled();
466
+ expect( syncManager.createPersistedCRDTDoc ).not.toHaveBeenCalled();
460
467
  } );
461
468
 
462
469
  it( 'provides transient properties when read/write config is supplied', async () => {
@@ -507,6 +514,7 @@ describe( 'getEntityRecord', () => {
507
514
  addUndoMeta: expect.any( Function ),
508
515
  editRecord: expect.any( Function ),
509
516
  getEditedRecord: expect.any( Function ),
517
+ onUndoStackChange: expect.any( Function ),
510
518
  onStatusChange: expect.any( Function ),
511
519
  persistCRDTDoc: expect.any( Function ),
512
520
  refetchRecord: expect.any( Function ),
@@ -24,7 +24,55 @@ import {
24
24
  getRevisions,
25
25
  getRevision,
26
26
  hasRevision,
27
+ hasUndo,
28
+ hasRedo,
27
29
  } from '../selectors';
30
+ import { getSyncManager } from '../sync';
31
+
32
+ jest.mock( '../sync', () => ( {
33
+ getSyncManager: jest.fn(),
34
+ } ) );
35
+
36
+ describe( 'hasUndo/hasRedo', () => {
37
+ afterEach( () => {
38
+ getSyncManager.mockReset();
39
+ } );
40
+
41
+ it( 'reads undo availability from core-data state when a sync undo manager is available', () => {
42
+ const undoManager = {
43
+ hasUndo: jest.fn( () => false ),
44
+ hasRedo: jest.fn( () => false ),
45
+ };
46
+ getSyncManager.mockReturnValue( { undoManager } );
47
+
48
+ const state = deepFreeze( {
49
+ syncUndoManagerState: {
50
+ hasRedo: true,
51
+ hasUndo: true,
52
+ },
53
+ } );
54
+
55
+ expect( hasUndo( state ) ).toBe( true );
56
+ expect( hasRedo( state ) ).toBe( true );
57
+ expect( undoManager.hasUndo ).not.toHaveBeenCalled();
58
+ expect( undoManager.hasRedo ).not.toHaveBeenCalled();
59
+ } );
60
+
61
+ it( 'falls back to the default undo manager when no sync undo manager is available', () => {
62
+ const undoManager = {
63
+ hasUndo: jest.fn( () => true ),
64
+ hasRedo: jest.fn( () => false ),
65
+ };
66
+ getSyncManager.mockReturnValue( undefined );
67
+
68
+ const state = { undoManager };
69
+
70
+ expect( hasUndo( state ) ).toBe( true );
71
+ expect( hasRedo( state ) ).toBe( false );
72
+ expect( undoManager.hasUndo ).toHaveBeenCalled();
73
+ expect( undoManager.hasRedo ).toHaveBeenCalled();
74
+ } );
75
+ } );
28
76
 
29
77
  describe( 'getEntityRecord', () => {
30
78
  describe( 'normalizing Post ID passed as recordKey', () => {
@@ -0,0 +1,34 @@
1
+ /**
2
+ * External dependencies
3
+ */
4
+ import fastDeepEqual from 'fast-deep-equal/es6/index.js';
5
+
6
+ /**
7
+ * Returns a copy of `edits` where any value equal to its persisted counterpart
8
+ * is set to `undefined`. The edits reducer treats `undefined` as a signal to
9
+ * drop the edit, so the property is no longer considered dirty.
10
+ *
11
+ * @param edits Edits keyed by property name.
12
+ * @param persistedRecord Persisted entity record to compare against.
13
+ *
14
+ * @return Edits with unchanged properties set to `undefined`.
15
+ */
16
+ export default function clearUnchangedEdits(
17
+ edits: Record< string, unknown >,
18
+ persistedRecord: Record< string, any > | undefined
19
+ ): Record< string, unknown > {
20
+ if ( ! persistedRecord ) {
21
+ return edits;
22
+ }
23
+
24
+ return Object.fromEntries(
25
+ Object.entries( edits ).map( ( [ key, value ] ) => {
26
+ const persisted =
27
+ persistedRecord[ key ]?.raw ?? persistedRecord[ key ];
28
+ return [
29
+ key,
30
+ fastDeepEqual( value, persisted ) ? undefined : value,
31
+ ];
32
+ } )
33
+ );
34
+ }
@@ -69,6 +69,10 @@ export type YBlocks = Y.Array< YBlock >;
69
69
  // Attribute values will be typed as the union of `Y.Text` and `unknown`.
70
70
  export type YBlockAttributes = Y.Map< Y.Text | unknown >;
71
71
 
72
+ interface MergeCrdtBlocksOptions {
73
+ preserveClientIds?: boolean;
74
+ }
75
+
72
76
  /**
73
77
  * Optional description of where a cursor falls.
74
78
  *
@@ -420,11 +424,13 @@ function createNewYBlock( block: Block ): YBlock {
420
424
  * @param attributeCursor When provided, describes a selection cursor falling within a
421
425
  * RichText field associated with a specific block and attribute.
422
426
  * Derived from the changes that produced the blocks.
427
+ * @param options Optional settings for the merge operation.
423
428
  */
424
429
  export function mergeCrdtBlocks(
425
430
  yblocks: YBlocks,
426
431
  incomingBlocks: Block[],
427
- attributeCursor: MergeCursorPosition
432
+ attributeCursor: MergeCursorPosition,
433
+ options: MergeCrdtBlocksOptions = {}
428
434
  ): void {
429
435
  // Ensure we are working with serializable block data.
430
436
  if ( ! serializableBlocksCache.has( incomingBlocks ) ) {
@@ -594,32 +600,31 @@ export function mergeCrdtBlocks(
594
600
  mergeCrdtBlocks(
595
601
  yInnerBlocks,
596
602
  incomingBlockPropertyValue ?? [],
597
- attributeCursor
603
+ attributeCursor,
604
+ options
598
605
  );
599
606
  break;
600
607
  }
601
608
 
602
609
  case 'clientId': {
603
- // Never overwrite the local block's clientId with the
604
- // incoming one. Some callers (e.g. the Code Editor flow
605
- // that parses raw HTML into blocks on every keystroke)
606
- // produce randomized clientIds for blocks whose content
607
- // has changed on every sync. Without this case the default
608
- // branch would replace the stable Y.Doc clientId with
609
- // a new one, causing remote peers to remount the block
610
- // and flash the block's content on reload.
611
- //
612
- // This mirrors the clientId exclusion in `areBlocksEqual`.
613
- // Convergence is preserved. Because we're not writing
614
- // to the clientId, Yjs doesn't send an update to peers
615
- // telling them to change the clientId, so everyone
616
- // sees the same clientId per block.
617
- // Inserts still use a new clientId via createNewYBlock,
618
- // and the duplicate-clientId sweep below catches any
619
- // edge cases. The clientId is anchored to the
620
- // slot in the array rather than to specific content,
621
- // which is consistent with areBlocksEqual ignoring
622
- // clientId when diffing.
610
+ // Code Editor changes reparse raw HTML on every
611
+ // keystroke and regenerate fresh clientIds. Keep Y.Doc
612
+ // clientIds stable for the code editor so peers do not
613
+ // remount unchanged blocks on every edit.
614
+ if ( options.preserveClientIds ) {
615
+ break;
616
+ }
617
+
618
+ // Otherwise, accept new clientIds from updates
619
+ if (
620
+ incomingBlockPropertyValue !==
621
+ localYBlock.get( incomingBlockProperty )
622
+ ) {
623
+ localYBlock.set(
624
+ incomingBlockProperty,
625
+ incomingBlockPropertyValue
626
+ );
627
+ }
623
628
  break;
624
629
  }
625
630
 
package/src/utils/crdt.ts CHANGED
@@ -317,7 +317,8 @@ function mergeContentWithoutBlocks(
317
317
  mergeCrdtBlocks(
318
318
  currentBlocks,
319
319
  parse( rawContent ) as Block[],
320
- cursorPosition
320
+ cursorPosition,
321
+ { preserveClientIds: true }
321
322
  );
322
323
  }
323
324
 
@@ -1,3 +1,4 @@
1
+ export { default as clearUnchangedEdits } from './clear-unchanged-edits';
1
2
  export { default as conservativeMapItem } from './conservative-map-item';
2
3
  export { default as getNormalizedCommaSeparable } from './get-normalized-comma-separable';
3
4
  export { default as ifMatchingAction } from './if-matching-action';
@@ -14,3 +15,4 @@ export {
14
15
  } from './user-permissions';
15
16
  export { RECEIVE_INTERMEDIATE_RESULTS } from './receive-intermediate-results';
16
17
  export { default as normalizeQueryForResolution } from './normalize-query-for-resolution';
18
+ export { saveCRDTDoc } from './save-crdt-doc';