@wordpress/core-data 7.47.0 → 7.48.1

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 (99) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/build/awareness/block-lookup.cjs +14 -26
  3. package/build/awareness/block-lookup.cjs.map +2 -2
  4. package/build/awareness/post-editor-awareness.cjs +4 -3
  5. package/build/awareness/post-editor-awareness.cjs.map +2 -2
  6. package/build/entities.cjs +4 -2
  7. package/build/entities.cjs.map +2 -2
  8. package/build/hooks/use-post-editor-awareness-state.cjs +8 -2
  9. package/build/hooks/use-post-editor-awareness-state.cjs.map +2 -2
  10. package/build/private-actions.cjs +8 -0
  11. package/build/private-actions.cjs.map +2 -2
  12. package/build/private-selectors.cjs.map +2 -2
  13. package/build/reducer.cjs +13 -0
  14. package/build/reducer.cjs.map +2 -2
  15. package/build/resolvers.cjs +13 -8
  16. package/build/resolvers.cjs.map +2 -2
  17. package/build/selectors.cjs +7 -0
  18. package/build/selectors.cjs.map +2 -2
  19. package/build/utils/crdt-blocks.cjs +12 -2
  20. package/build/utils/crdt-blocks.cjs.map +2 -2
  21. package/build/utils/crdt.cjs +2 -1
  22. package/build/utils/crdt.cjs.map +2 -2
  23. package/build/utils/index.cjs +3 -0
  24. package/build/utils/index.cjs.map +2 -2
  25. package/build/utils/save-crdt-doc.cjs +75 -0
  26. package/build/utils/save-crdt-doc.cjs.map +7 -0
  27. package/build-module/awareness/block-lookup.mjs +13 -26
  28. package/build-module/awareness/block-lookup.mjs.map +2 -2
  29. package/build-module/awareness/post-editor-awareness.mjs +4 -3
  30. package/build-module/awareness/post-editor-awareness.mjs.map +2 -2
  31. package/build-module/entities.mjs +4 -2
  32. package/build-module/entities.mjs.map +2 -2
  33. package/build-module/hooks/use-post-editor-awareness-state.mjs +9 -3
  34. package/build-module/hooks/use-post-editor-awareness-state.mjs.map +2 -2
  35. package/build-module/private-actions.mjs +7 -0
  36. package/build-module/private-actions.mjs.map +2 -2
  37. package/build-module/private-selectors.mjs.map +2 -2
  38. package/build-module/reducer.mjs +12 -0
  39. package/build-module/reducer.mjs.map +2 -2
  40. package/build-module/resolvers.mjs +15 -9
  41. package/build-module/resolvers.mjs.map +2 -2
  42. package/build-module/selectors.mjs +7 -0
  43. package/build-module/selectors.mjs.map +2 -2
  44. package/build-module/utils/crdt-blocks.mjs +12 -2
  45. package/build-module/utils/crdt-blocks.mjs.map +2 -2
  46. package/build-module/utils/crdt.mjs +2 -1
  47. package/build-module/utils/crdt.mjs.map +2 -2
  48. package/build-module/utils/index.mjs +2 -0
  49. package/build-module/utils/index.mjs.map +2 -2
  50. package/build-module/utils/save-crdt-doc.mjs +40 -0
  51. package/build-module/utils/save-crdt-doc.mjs.map +7 -0
  52. package/build-types/awareness/block-lookup.d.ts +27 -7
  53. package/build-types/awareness/block-lookup.d.ts.map +1 -1
  54. package/build-types/awareness/post-editor-awareness.d.ts +3 -1
  55. package/build-types/awareness/post-editor-awareness.d.ts.map +1 -1
  56. package/build-types/entities.d.ts.map +1 -1
  57. package/build-types/hooks/use-post-editor-awareness-state.d.ts.map +1 -1
  58. package/build-types/private-actions.d.ts +15 -0
  59. package/build-types/private-actions.d.ts.map +1 -1
  60. package/build-types/private-selectors.d.ts +0 -12
  61. package/build-types/private-selectors.d.ts.map +1 -1
  62. package/build-types/reducer.d.ts +15 -0
  63. package/build-types/reducer.d.ts.map +1 -1
  64. package/build-types/resolvers.d.ts.map +1 -1
  65. package/build-types/selectors.d.ts +4 -0
  66. package/build-types/selectors.d.ts.map +1 -1
  67. package/build-types/utils/crdt-blocks.d.ts +5 -1
  68. package/build-types/utils/crdt-blocks.d.ts.map +1 -1
  69. package/build-types/utils/crdt.d.ts.map +1 -1
  70. package/build-types/utils/index.d.ts +1 -0
  71. package/build-types/utils/index.d.ts.map +1 -1
  72. package/build-types/utils/on-sub-key.d.ts +4 -0
  73. package/build-types/utils/on-sub-key.d.ts.map +1 -0
  74. package/build-types/utils/save-crdt-doc.d.ts +8 -0
  75. package/build-types/utils/save-crdt-doc.d.ts.map +1 -0
  76. package/package.json +24 -22
  77. package/src/awareness/block-lookup.ts +21 -62
  78. package/src/awareness/post-editor-awareness.ts +8 -3
  79. package/src/awareness/test/block-lookup.ts +98 -94
  80. package/src/awareness/test/post-editor-awareness.ts +177 -180
  81. package/src/entities.js +9 -3
  82. package/src/hooks/test/use-post-editor-awareness-state.ts +10 -2
  83. package/src/hooks/use-post-editor-awareness-state.ts +20 -7
  84. package/src/private-actions.js +18 -0
  85. package/src/private-selectors.ts +0 -12
  86. package/src/reducer.js +17 -0
  87. package/src/resolvers.js +20 -13
  88. package/src/selectors.ts +11 -0
  89. package/src/test/private-selectors.js +66 -0
  90. package/src/test/reducer.js +44 -0
  91. package/src/test/resolvers.js +121 -113
  92. package/src/test/selectors.js +48 -0
  93. package/src/utils/crdt-blocks.ts +27 -22
  94. package/src/utils/crdt.ts +2 -1
  95. package/src/utils/index.js +1 -0
  96. package/src/utils/save-crdt-doc.js +64 -0
  97. package/src/utils/test/crdt-blocks.ts +57 -2
  98. package/src/utils/test/rtc-rich-text-cursor-scope.test.js +2 -2
  99. package/src/utils/test/save-crdt-doc.js +185 -0
@@ -21,18 +21,6 @@ const EMPTY_OBJECT = {};
21
21
  * Returns the previous edit from the current undo offset
22
22
  * for the entity records edits history, if any.
23
23
  *
24
- * Known Issue: Every-time state.undoManager changes, the getUndoManager
25
- * private selector is called (if used within useSelect and things like that)
26
- * which ensures the UI is always properly reactive. But, it's not the case with
27
- * the custom "sync" undo manager.
28
- *
29
- * Assumption: When an undo/redo is created, other parts of the core-data state
30
- * are likely changing simultaneously, which will trigger the selectors again.
31
- *
32
- * This issue is acceptable based on the assumption above.
33
- *
34
- * @see https://github.com/WordPress/gutenberg/pull/72407/files#r2580214235 for more details.
35
- *
36
24
  * @param state State tree.
37
25
  *
38
26
  * @return The undo manager.
package/src/reducer.js CHANGED
@@ -460,6 +460,22 @@ export function undoManager( state = createUndoManager() ) {
460
460
  return state;
461
461
  }
462
462
 
463
+ // Stores a snapshot of the sync undo manager's undo/redo availability so
464
+ // core-data selectors can react to undo stack changes.
465
+ export function syncUndoManagerState(
466
+ state = { hasRedo: false, hasUndo: false },
467
+ action
468
+ ) {
469
+ switch ( action.type ) {
470
+ case 'SYNC_UNDO_MANAGER_CHANGE':
471
+ return {
472
+ hasRedo: action.hasRedo,
473
+ hasUndo: action.hasUndo,
474
+ };
475
+ }
476
+ return state;
477
+ }
478
+
463
479
  export function editsReference( state = {}, action ) {
464
480
  switch ( action.type ) {
465
481
  case 'EDIT_ENTITY_RECORD':
@@ -750,6 +766,7 @@ export default combineReducers( {
750
766
  themeGlobalStyleRevisions,
751
767
  entities,
752
768
  editsReference,
769
+ syncUndoManagerState,
753
770
  undoManager,
754
771
  embedPreviews,
755
772
  userPermissions,
package/src/resolvers.js CHANGED
@@ -25,6 +25,7 @@ import {
25
25
  RECEIVE_INTERMEDIATE_RESULTS,
26
26
  isNumericID,
27
27
  normalizeQueryForResolution,
28
+ saveCRDTDoc,
28
29
  } from './utils';
29
30
  import { fetchBlockPatterns } from './fetch';
30
31
  import { restoreSelection, getSelectionHistory } from './utils/crdt-selection';
@@ -250,9 +251,15 @@ export const getEntityRecord =
250
251
  // persistence. As we add support for syncing additional entity,
251
252
  // we'll need to revisit where persisted CRDT documents are stored.
252
253
  persistCRDTDoc: () => {
253
- resolveSelect
254
+ if (
255
+ ! entityConfig.syncConfig?.supportsPersistence
256
+ ) {
257
+ return;
258
+ }
259
+
260
+ return resolveSelect
254
261
  .getEditedEntityRecord( kind, name, key )
255
- .then( ( editedRecord ) => {
262
+ .then( async ( editedRecord ) => {
256
263
  // Don't persist the CRDT document if the record is still an
257
264
  // auto-draft or if the entity does not support meta.
258
265
  const { meta, status } = editedRecord;
@@ -260,19 +267,14 @@ export const getEntityRecord =
260
267
  return;
261
268
  }
262
269
 
263
- // Trigger a minimal save to persist the CRDT document. The
264
- // entity's pre-persist hooks will create the persisted CRDT
265
- // document and apply it to the record's meta.
266
270
  const entityIdKey =
267
271
  entityConfig.key || DEFAULT_ENTITY_KEY;
268
- dispatch.saveEntityRecord(
269
- kind,
270
- name,
271
- {
272
- [ entityIdKey ]:
273
- editedRecord[ entityIdKey ],
274
- },
275
- { __unstableSkipSyncUpdate: true }
272
+ const entityId =
273
+ editedRecord[ entityIdKey ];
274
+
275
+ await saveCRDTDoc(
276
+ `${ kind }/${ name }`,
277
+ entityId
276
278
  );
277
279
  } );
278
280
  },
@@ -287,6 +289,11 @@ export const getEntityRecord =
287
289
  );
288
290
  }
289
291
  },
292
+ onUndoStackChange: ( undoState ) => {
293
+ dispatch.__unstableNotifySyncUndoManagerChange(
294
+ undoState
295
+ );
296
+ },
290
297
  restoreUndoMeta: ( ydoc, meta ) => {
291
298
  const selectionHistory =
292
299
  meta.get( 'selectionHistory' );
package/src/selectors.ts CHANGED
@@ -24,6 +24,7 @@ import {
24
24
  isNumericID,
25
25
  getUserPermissionCacheKey,
26
26
  } from './utils';
27
+ import { getSyncManager } from './sync';
27
28
  import type * as ET from './entity-types';
28
29
  import logEntityDeprecation from './utils/log-entity-deprecation';
29
30
 
@@ -44,6 +45,10 @@ export interface State {
44
45
  themeGlobalStyleVariations: Record< string, string >;
45
46
  themeGlobalStyleRevisions: Record< number, Object >;
46
47
  undoManager: UndoManager;
48
+ syncUndoManagerState: {
49
+ hasRedo: boolean;
50
+ hasUndo: boolean;
51
+ };
47
52
  userPermissions: Record< string, boolean >;
48
53
  users: UserState;
49
54
  navigationFallbackId: EntityRecordKey;
@@ -1148,6 +1153,9 @@ export function getRedoEdit( state: State ): Optional< any > {
1148
1153
  * @return Whether there is a previous edit or not.
1149
1154
  */
1150
1155
  export function hasUndo( state: State ): boolean {
1156
+ if ( getSyncManager()?.undoManager ) {
1157
+ return state.syncUndoManagerState.hasUndo;
1158
+ }
1151
1159
  return getUndoManager( state ).hasUndo();
1152
1160
  }
1153
1161
 
@@ -1160,6 +1168,9 @@ export function hasUndo( state: State ): boolean {
1160
1168
  * @return Whether there is a next edit or not.
1161
1169
  */
1162
1170
  export function hasRedo( state: State ): boolean {
1171
+ if ( getSyncManager()?.undoManager ) {
1172
+ return state.syncUndoManagerState.hasRedo;
1173
+ }
1163
1174
  return getUndoManager( state ).hasRedo();
1164
1175
  }
1165
1176
 
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Internal dependencies
3
+ */
4
+ import { getUndoManager } from '../private-selectors';
5
+ import { getSyncManager } from '../sync';
6
+
7
+ jest.mock( '../sync', () => ( {
8
+ getSyncManager: jest.fn(),
9
+ } ) );
10
+
11
+ describe( 'getUndoManager', () => {
12
+ afterEach( () => {
13
+ getSyncManager.mockReset();
14
+ } );
15
+
16
+ it( 'returns the sync undo manager when one is available', () => {
17
+ const syncUndoManager = {
18
+ addRecord: jest.fn(),
19
+ hasRedo: jest.fn(),
20
+ hasUndo: jest.fn(),
21
+ redo: jest.fn(),
22
+ undo: jest.fn(),
23
+ };
24
+ const fallbackUndoManager = {
25
+ addRecord: jest.fn(),
26
+ hasRedo: jest.fn(),
27
+ hasUndo: jest.fn(),
28
+ redo: jest.fn(),
29
+ undo: jest.fn(),
30
+ };
31
+ getSyncManager.mockReturnValue( {
32
+ undoManager: syncUndoManager,
33
+ } );
34
+
35
+ const state = {
36
+ undoManager: fallbackUndoManager,
37
+ syncUndoManagerState: {
38
+ hasRedo: false,
39
+ hasUndo: false,
40
+ },
41
+ };
42
+
43
+ expect( getUndoManager( state ) ).toBe( syncUndoManager );
44
+ } );
45
+
46
+ it( 'returns the default undo manager when there is no sync undo manager', () => {
47
+ const fallbackUndoManager = {
48
+ addRecord: jest.fn(),
49
+ hasRedo: jest.fn(),
50
+ hasUndo: jest.fn(),
51
+ redo: jest.fn(),
52
+ undo: jest.fn(),
53
+ };
54
+ getSyncManager.mockReturnValue( undefined );
55
+
56
+ expect(
57
+ getUndoManager( {
58
+ undoManager: fallbackUndoManager,
59
+ syncUndoManagerState: {
60
+ hasRedo: false,
61
+ hasUndo: false,
62
+ },
63
+ } )
64
+ ).toBe( fallbackUndoManager );
65
+ } );
66
+ } );
@@ -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 ),