@wordpress/core-data 7.48.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 +7 -1
  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 +22 -20
  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
@@ -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', () => {
@@ -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
 
@@ -14,3 +14,4 @@ export {
14
14
  } from './user-permissions';
15
15
  export { RECEIVE_INTERMEDIATE_RESULTS } from './receive-intermediate-results';
16
16
  export { default as normalizeQueryForResolution } from './normalize-query-for-resolution';
17
+ export { saveCRDTDoc } from './save-crdt-doc';
@@ -0,0 +1,64 @@
1
+ /**
2
+ * WordPress dependencies
3
+ */
4
+ import apiFetch from '@wordpress/api-fetch';
5
+
6
+ /**
7
+ * Internal dependencies
8
+ */
9
+ import { getSyncManager } from '../sync';
10
+
11
+ const SYNC_SAVE_API_PATH = '/wp-sync/v1/save';
12
+ const saveCRDTDocQueues = new Map();
13
+
14
+ async function serializeAndSaveCRDTDoc( objectType, objectId, room ) {
15
+ const serializedDoc = await getSyncManager()?.createPersistedCRDTDoc(
16
+ objectType,
17
+ objectId
18
+ );
19
+
20
+ if ( ! serializedDoc ) {
21
+ return;
22
+ }
23
+
24
+ await apiFetch( {
25
+ path: SYNC_SAVE_API_PATH,
26
+ method: 'POST',
27
+ data: {
28
+ room,
29
+ doc: serializedDoc,
30
+ },
31
+ } );
32
+ }
33
+
34
+ /**
35
+ * Persist the current CRDT document through the sync /save endpoint.
36
+ *
37
+ * @param {import('@wordpress/sync').ObjectType} objectType Object type.
38
+ * @param {import('@wordpress/sync').ObjectID} objectId Object ID.
39
+ */
40
+ export async function saveCRDTDoc( objectType, objectId ) {
41
+ const room = `${ objectType }:${ objectId }`;
42
+
43
+ // Saves are chained per-room, which forms a queue.
44
+ // Without a queue, two /save calls might fire close together with a risk
45
+ // that the older serialized CRDT snapshot completes after the newer one and
46
+ // overwrites it with stale data.
47
+ // Wait for the prior request chain to complete before firing the next save.
48
+ const previousSave = saveCRDTDocQueues.get( room ) || Promise.resolve();
49
+
50
+ const currentSave = previousSave
51
+ // A failed save should reject its caller, but not block later saves.
52
+ .catch( () => {} )
53
+ .then( () => serializeAndSaveCRDTDoc( objectType, objectId, room ) );
54
+
55
+ saveCRDTDocQueues.set( room, currentSave );
56
+
57
+ try {
58
+ await currentSave;
59
+ } finally {
60
+ if ( saveCRDTDocQueues.get( room ) === currentSave ) {
61
+ saveCRDTDocQueues.delete( room );
62
+ }
63
+ }
64
+ }
@@ -196,7 +196,40 @@ describe( 'crdt-blocks', () => {
196
196
  expect( content.toString() ).toBe( 'Updated content' );
197
197
  } );
198
198
 
199
- it( 'preserves the local clientId when an updated block arrives with a different clientId', () => {
199
+ it( 'updates the clientId when an updated block arrives with a different clientId', () => {
200
+ const initialBlocks: Block[] = [
201
+ {
202
+ name: 'core/paragraph',
203
+ attributes: { content: 'Initial content' },
204
+ innerBlocks: [],
205
+ clientId: 'initial-id',
206
+ },
207
+ ];
208
+
209
+ mergeCrdtBlocks( yblocks, initialBlocks, null );
210
+ expect( yblocks.get( 0 ).get( 'clientId' ) ).toBe( 'initial-id' );
211
+
212
+ const updatedBlocks: Block[] = [
213
+ {
214
+ name: 'core/paragraph',
215
+ attributes: { content: 'Updated content' },
216
+ innerBlocks: [],
217
+ clientId: 'updated-id',
218
+ },
219
+ ];
220
+
221
+ mergeCrdtBlocks( yblocks, updatedBlocks, null );
222
+
223
+ expect( yblocks.length ).toBe( 1 );
224
+ const block = yblocks.get( 0 );
225
+ expect( block.get( 'clientId' ) ).toBe( 'updated-id' );
226
+ const content = (
227
+ block.get( 'attributes' ) as YBlockAttributes
228
+ ).get( 'content' ) as Y.Text;
229
+ expect( content.toString() ).toBe( 'Updated content' );
230
+ } );
231
+
232
+ it( 'preserves the local clientId when requested for reparsed content blocks', () => {
200
233
  // Simulates the Code Editor flow: the sender re-parses raw HTML on
201
234
  // every keystroke, which mints a fresh clientId for every block.
202
235
  // The Y.Doc's clientId should stay stable so remote peers don't
@@ -222,7 +255,9 @@ describe( 'crdt-blocks', () => {
222
255
  },
223
256
  ];
224
257
 
225
- mergeCrdtBlocks( yblocks, reparsedBlocks, null );
258
+ mergeCrdtBlocks( yblocks, reparsedBlocks, null, {
259
+ preserveClientIds: true,
260
+ } );
226
261
 
227
262
  expect( yblocks.length ).toBe( 1 );
228
263
  const block = yblocks.get( 0 );
@@ -2867,6 +2902,26 @@ describe( 'crdt-blocks', () => {
2867
2902
  } );
2868
2903
  } );
2869
2904
 
2905
+ describe( 'mergeRichTextUpdate - rapid typing', () => {
2906
+ it( 'appends repeated text one character at a time with cursor hints', () => {
2907
+ const text =
2908
+ '987654321098765432109876543210987654321098765432109876543210';
2909
+ const yText = doc.getText( 'test' );
2910
+ yText.insert( 0, 'p1' );
2911
+
2912
+ for ( let i = 1; i <= text.length; i++ ) {
2913
+ const value = `p1${ text.slice( 0, i ) }`;
2914
+ mergeRichTextUpdate(
2915
+ yText,
2916
+ value,
2917
+ asHtmlStringIndex( value.length )
2918
+ );
2919
+ }
2920
+
2921
+ expect( yText.toString() ).toBe( `p1${ text }` );
2922
+ } );
2923
+ } );
2924
+
2870
2925
  describe( 'supplementary plane characters (non-emoji)', () => {
2871
2926
  // Characters above U+FFFF are stored as surrogate pairs in UTF-16,
2872
2927
  // so .length === 2 per character. The diff library v8 counts them
@@ -254,8 +254,8 @@ describe( 'RTC rich-text cursor scope bug', () => {
254
254
  },
255
255
  'LOCAL_EDITOR_ORIGIN'
256
256
  );
257
- // SyncManager.update is deferred through yieldToEventLoop.
258
- // Wait one tick so the CRDT write has been applied before inspecting it.
257
+ // Selection history writes are deferred. Wait one tick before
258
+ // inspecting the document.
259
259
  await waitForNextTick();
260
260
  }
261
261
 
@@ -0,0 +1,185 @@
1
+ /**
2
+ * WordPress dependencies
3
+ */
4
+ import apiFetch from '@wordpress/api-fetch';
5
+
6
+ /**
7
+ * Internal dependencies
8
+ */
9
+ import { getSyncManager } from '../../sync';
10
+ import { saveCRDTDoc } from '../save-crdt-doc';
11
+
12
+ jest.mock( '@wordpress/api-fetch' );
13
+ jest.mock( '../../sync', () => ( {
14
+ getSyncManager: jest.fn(),
15
+ } ) );
16
+
17
+ function createDeferred() {
18
+ let resolve;
19
+ let reject;
20
+ const promise = new Promise( ( _resolve, _reject ) => {
21
+ resolve = _resolve;
22
+ reject = _reject;
23
+ } );
24
+
25
+ return { promise, resolve, reject };
26
+ }
27
+
28
+ async function flushPromises() {
29
+ await new Promise( ( resolve ) => setTimeout( resolve, 0 ) );
30
+ }
31
+
32
+ describe( 'saveCRDTDoc', () => {
33
+ let syncManager;
34
+
35
+ beforeEach( () => {
36
+ apiFetch.mockReset();
37
+ syncManager = {
38
+ createPersistedCRDTDoc: jest.fn(),
39
+ };
40
+ getSyncManager.mockReturnValue( syncManager );
41
+ } );
42
+
43
+ it( 'saves the serialized CRDT document through the sync endpoint', async () => {
44
+ const fetch = createDeferred();
45
+ syncManager.createPersistedCRDTDoc.mockResolvedValue( 'doc' );
46
+ apiFetch.mockImplementation( () => fetch.promise );
47
+
48
+ const save = saveCRDTDoc( 'postType/post', 1 );
49
+
50
+ await flushPromises();
51
+
52
+ fetch.resolve( {} );
53
+ await save;
54
+
55
+ expect( apiFetch ).toHaveBeenCalledWith( {
56
+ path: '/wp-sync/v1/save',
57
+ method: 'POST',
58
+ data: {
59
+ room: 'postType/post:1',
60
+ doc: 'doc',
61
+ },
62
+ } );
63
+ } );
64
+
65
+ it( 'does not call the sync endpoint when there is no serialized CRDT document', async () => {
66
+ syncManager.createPersistedCRDTDoc.mockResolvedValue( null );
67
+
68
+ await saveCRDTDoc( 'postType/post', 1 );
69
+
70
+ expect( apiFetch ).not.toHaveBeenCalled();
71
+ } );
72
+
73
+ it( 'serializes save requests for the same room', async () => {
74
+ const firstFetch = createDeferred();
75
+ syncManager.createPersistedCRDTDoc
76
+ .mockResolvedValueOnce( 'doc-1' )
77
+ .mockResolvedValueOnce( 'doc-2' );
78
+ apiFetch
79
+ .mockImplementationOnce( () => firstFetch.promise )
80
+ .mockResolvedValueOnce( {} );
81
+
82
+ const firstSave = saveCRDTDoc( 'postType/post', 1 );
83
+ const secondSave = saveCRDTDoc( 'postType/post', 1 );
84
+
85
+ await flushPromises();
86
+
87
+ expect( syncManager.createPersistedCRDTDoc ).toHaveBeenCalledTimes( 1 );
88
+ expect( apiFetch ).toHaveBeenCalledTimes( 1 );
89
+ expect( apiFetch ).toHaveBeenLastCalledWith( {
90
+ path: '/wp-sync/v1/save',
91
+ method: 'POST',
92
+ data: {
93
+ room: 'postType/post:1',
94
+ doc: 'doc-1',
95
+ },
96
+ } );
97
+
98
+ firstFetch.resolve( {} );
99
+ await firstSave;
100
+ await flushPromises();
101
+
102
+ expect( syncManager.createPersistedCRDTDoc ).toHaveBeenCalledTimes( 2 );
103
+ expect( apiFetch ).toHaveBeenCalledTimes( 2 );
104
+ expect( apiFetch ).toHaveBeenLastCalledWith( {
105
+ path: '/wp-sync/v1/save',
106
+ method: 'POST',
107
+ data: {
108
+ room: 'postType/post:1',
109
+ doc: 'doc-2',
110
+ },
111
+ } );
112
+
113
+ await secondSave;
114
+ } );
115
+
116
+ it( 'does not serialize save requests for different rooms', async () => {
117
+ const firstFetch = createDeferred();
118
+ syncManager.createPersistedCRDTDoc.mockImplementation(
119
+ ( objectType, objectId ) => Promise.resolve( `doc-${ objectId }` )
120
+ );
121
+ apiFetch
122
+ .mockImplementationOnce( () => firstFetch.promise )
123
+ .mockResolvedValueOnce( {} );
124
+
125
+ const firstSave = saveCRDTDoc( 'postType/post', 1 );
126
+ const secondSave = saveCRDTDoc( 'postType/post', 2 );
127
+
128
+ await flushPromises();
129
+
130
+ expect( syncManager.createPersistedCRDTDoc ).toHaveBeenCalledTimes( 2 );
131
+ expect( apiFetch ).toHaveBeenCalledTimes( 2 );
132
+ expect( apiFetch ).toHaveBeenNthCalledWith( 1, {
133
+ path: '/wp-sync/v1/save',
134
+ method: 'POST',
135
+ data: {
136
+ room: 'postType/post:1',
137
+ doc: 'doc-1',
138
+ },
139
+ } );
140
+ expect( apiFetch ).toHaveBeenNthCalledWith( 2, {
141
+ path: '/wp-sync/v1/save',
142
+ method: 'POST',
143
+ data: {
144
+ room: 'postType/post:2',
145
+ doc: 'doc-2',
146
+ },
147
+ } );
148
+
149
+ await secondSave;
150
+ firstFetch.resolve( {} );
151
+ await firstSave;
152
+ } );
153
+
154
+ it( 'continues a same-room queue after a failed save', async () => {
155
+ const firstFetch = createDeferred();
156
+ syncManager.createPersistedCRDTDoc
157
+ .mockResolvedValueOnce( 'doc-1' )
158
+ .mockResolvedValueOnce( 'doc-2' );
159
+ apiFetch
160
+ .mockImplementationOnce( () => firstFetch.promise )
161
+ .mockResolvedValueOnce( {} );
162
+
163
+ const firstSave = saveCRDTDoc( 'postType/post', 1 );
164
+ const secondSave = saveCRDTDoc( 'postType/post', 1 );
165
+
166
+ await flushPromises();
167
+
168
+ firstFetch.reject( new Error( 'save failed' ) );
169
+ await expect( firstSave ).rejects.toThrow( 'save failed' );
170
+ await flushPromises();
171
+
172
+ expect( syncManager.createPersistedCRDTDoc ).toHaveBeenCalledTimes( 2 );
173
+ expect( apiFetch ).toHaveBeenCalledTimes( 2 );
174
+ expect( apiFetch ).toHaveBeenLastCalledWith( {
175
+ path: '/wp-sync/v1/save',
176
+ method: 'POST',
177
+ data: {
178
+ room: 'postType/post:1',
179
+ doc: 'doc-2',
180
+ },
181
+ } );
182
+
183
+ await secondSave;
184
+ } );
185
+ } );