@wordpress/core-data 7.46.0 → 7.47.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 +2 -0
  2. package/README.md +0 -27
  3. package/build/actions.cjs +0 -19
  4. package/build/actions.cjs.map +2 -2
  5. package/build/awareness/block-lookup.cjs +13 -0
  6. package/build/awareness/block-lookup.cjs.map +2 -2
  7. package/build/awareness/post-editor-awareness.cjs +21 -9
  8. package/build/awareness/post-editor-awareness.cjs.map +2 -2
  9. package/build/hooks/use-entity-block-editor.cjs +4 -4
  10. package/build/hooks/use-entity-block-editor.cjs.map +2 -2
  11. package/build/hooks/use-post-editor-awareness-state.cjs +2 -1
  12. package/build/hooks/use-post-editor-awareness-state.cjs.map +2 -2
  13. package/build/hooks/use-resource-permissions.cjs +3 -5
  14. package/build/hooks/use-resource-permissions.cjs.map +2 -2
  15. package/build/index.cjs +0 -6
  16. package/build/index.cjs.map +2 -2
  17. package/build/parsed-blocks-cache.cjs +36 -0
  18. package/build/parsed-blocks-cache.cjs.map +7 -0
  19. package/build/private-actions.cjs +25 -2
  20. package/build/private-actions.cjs.map +2 -2
  21. package/build/private-apis.cjs +9 -5
  22. package/build/private-apis.cjs.map +3 -3
  23. package/build/private-selectors.cjs +15 -0
  24. package/build/private-selectors.cjs.map +2 -2
  25. package/build/resolvers.cjs +12 -2
  26. package/build/resolvers.cjs.map +2 -2
  27. package/build/selectors.cjs +0 -15
  28. package/build/selectors.cjs.map +2 -2
  29. package/build/sync.cjs +5 -0
  30. package/build/sync.cjs.map +2 -2
  31. package/build/types.cjs +0 -16
  32. package/build/types.cjs.map +3 -3
  33. package/build/utils/block-selection-history.cjs +5 -4
  34. package/build/utils/block-selection-history.cjs.map +2 -2
  35. package/build/utils/crdt-blocks.cjs +3 -0
  36. package/build/utils/crdt-blocks.cjs.map +2 -2
  37. package/build/utils/crdt-user-selections.cjs +10 -2
  38. package/build/utils/crdt-user-selections.cjs.map +3 -3
  39. package/build/utils/crdt-utils.cjs +23 -0
  40. package/build/utils/crdt-utils.cjs.map +2 -2
  41. package/build/utils/crdt.cjs +28 -4
  42. package/build/utils/crdt.cjs.map +2 -2
  43. package/build-module/actions.mjs +0 -18
  44. package/build-module/actions.mjs.map +2 -2
  45. package/build-module/awareness/block-lookup.mjs +12 -0
  46. package/build-module/awareness/block-lookup.mjs.map +2 -2
  47. package/build-module/awareness/post-editor-awareness.mjs +26 -9
  48. package/build-module/awareness/post-editor-awareness.mjs.map +2 -2
  49. package/build-module/hooks/use-entity-block-editor.mjs +2 -2
  50. package/build-module/hooks/use-entity-block-editor.mjs.map +2 -2
  51. package/build-module/hooks/use-post-editor-awareness-state.mjs +2 -1
  52. package/build-module/hooks/use-post-editor-awareness-state.mjs.map +2 -2
  53. package/build-module/hooks/use-resource-permissions.mjs +3 -5
  54. package/build-module/hooks/use-resource-permissions.mjs.map +2 -2
  55. package/build-module/index.mjs +0 -4
  56. package/build-module/index.mjs.map +2 -2
  57. package/build-module/parsed-blocks-cache.mjs +10 -0
  58. package/build-module/parsed-blocks-cache.mjs.map +7 -0
  59. package/build-module/private-actions.mjs +23 -1
  60. package/build-module/private-actions.mjs.map +2 -2
  61. package/build-module/private-apis.mjs +12 -5
  62. package/build-module/private-apis.mjs.map +3 -3
  63. package/build-module/private-selectors.mjs +14 -0
  64. package/build-module/private-selectors.mjs.map +2 -2
  65. package/build-module/resolvers.mjs +12 -2
  66. package/build-module/resolvers.mjs.map +2 -2
  67. package/build-module/selectors.mjs +0 -14
  68. package/build-module/selectors.mjs.map +2 -2
  69. package/build-module/sync.mjs +4 -0
  70. package/build-module/sync.mjs.map +2 -2
  71. package/build-module/types.mjs +0 -9
  72. package/build-module/types.mjs.map +4 -4
  73. package/build-module/utils/block-selection-history.mjs +6 -4
  74. package/build-module/utils/block-selection-history.mjs.map +2 -2
  75. package/build-module/utils/crdt-blocks.mjs +3 -0
  76. package/build-module/utils/crdt-blocks.mjs.map +2 -2
  77. package/build-module/utils/crdt-user-selections.mjs +10 -2
  78. package/build-module/utils/crdt-user-selections.mjs.map +3 -3
  79. package/build-module/utils/crdt-utils.mjs +22 -0
  80. package/build-module/utils/crdt-utils.mjs.map +2 -2
  81. package/build-module/utils/crdt.mjs +32 -5
  82. package/build-module/utils/crdt.mjs.map +2 -2
  83. package/build-types/actions.d.ts +0 -11
  84. package/build-types/actions.d.ts.map +1 -1
  85. package/build-types/awareness/block-lookup.d.ts +12 -0
  86. package/build-types/awareness/block-lookup.d.ts.map +1 -1
  87. package/build-types/awareness/post-editor-awareness.d.ts +2 -5
  88. package/build-types/awareness/post-editor-awareness.d.ts.map +1 -1
  89. package/build-types/hooks/use-post-editor-awareness-state.d.ts.map +1 -1
  90. package/build-types/hooks/use-resource-permissions.d.ts.map +1 -1
  91. package/build-types/index.d.ts +0 -8
  92. package/build-types/index.d.ts.map +1 -1
  93. package/build-types/parsed-blocks-cache.d.ts +10 -0
  94. package/build-types/parsed-blocks-cache.d.ts.map +1 -0
  95. package/build-types/private-actions.d.ts +12 -0
  96. package/build-types/private-actions.d.ts.map +1 -1
  97. package/build-types/private-apis.d.ts +20 -0
  98. package/build-types/private-apis.d.ts.map +1 -1
  99. package/build-types/private-selectors.d.ts +10 -0
  100. package/build-types/private-selectors.d.ts.map +1 -1
  101. package/build-types/queried-data/selectors.d.ts +1 -1
  102. package/build-types/queried-data/selectors.d.ts.map +1 -1
  103. package/build-types/resolvers.d.ts.map +1 -1
  104. package/build-types/selectors.d.ts +0 -9
  105. package/build-types/selectors.d.ts.map +1 -1
  106. package/build-types/sync.d.ts +6 -0
  107. package/build-types/sync.d.ts.map +1 -1
  108. package/build-types/types.d.ts +3 -10
  109. package/build-types/types.d.ts.map +1 -1
  110. package/build-types/utils/block-selection-history.d.ts.map +1 -1
  111. package/build-types/utils/crdt-blocks.d.ts.map +1 -1
  112. package/build-types/utils/crdt-user-selections.d.ts +10 -1
  113. package/build-types/utils/crdt-user-selections.d.ts.map +1 -1
  114. package/build-types/utils/crdt-utils.d.ts +11 -0
  115. package/build-types/utils/crdt-utils.d.ts.map +1 -1
  116. package/build-types/utils/crdt.d.ts +5 -1
  117. package/build-types/utils/crdt.d.ts.map +1 -1
  118. package/package.json +20 -20
  119. package/src/actions.js +0 -29
  120. package/src/awareness/block-lookup.ts +34 -0
  121. package/src/awareness/post-editor-awareness.ts +32 -14
  122. package/src/awareness/test/block-lookup.ts +70 -0
  123. package/src/awareness/test/post-editor-awareness.ts +243 -0
  124. package/src/hooks/test/use-post-editor-awareness-state.ts +3 -0
  125. package/src/hooks/test/use-resource-permissions.js +57 -0
  126. package/src/hooks/use-entity-block-editor.js +2 -2
  127. package/src/hooks/use-post-editor-awareness-state.ts +1 -0
  128. package/src/hooks/use-resource-permissions.ts +5 -7
  129. package/src/index.js +0 -7
  130. package/src/parsed-blocks-cache.js +12 -0
  131. package/src/private-actions.js +34 -0
  132. package/src/{private-apis.js → private-apis.ts} +13 -3
  133. package/src/private-selectors.ts +33 -0
  134. package/src/resolvers.js +27 -5
  135. package/src/selectors.ts +0 -32
  136. package/src/sync.ts +9 -0
  137. package/src/test/resolvers.js +13 -7
  138. package/src/types.ts +16 -11
  139. package/src/utils/block-selection-history.ts +10 -7
  140. package/src/utils/crdt-blocks.ts +24 -0
  141. package/src/utils/crdt-user-selections.ts +15 -2
  142. package/src/utils/crdt-utils.ts +41 -0
  143. package/src/utils/crdt.ts +83 -10
  144. package/src/utils/test/block-selection-history.test.ts +42 -0
  145. package/src/utils/test/crdt-blocks.ts +37 -0
  146. package/src/utils/test/crdt-user-selections.ts +39 -0
  147. package/src/utils/test/crdt-utils.ts +52 -0
  148. package/src/utils/test/crdt.ts +208 -2
package/src/utils/crdt.ts CHANGED
@@ -6,7 +6,11 @@ import fastDeepEqual from 'fast-deep-equal/es6/index.js';
6
6
  /**
7
7
  * WordPress dependencies
8
8
  */
9
- import { __unstableSerializeAndClean } from '@wordpress/blocks';
9
+ import {
10
+ __unstableSerializeAndClean,
11
+ parse,
12
+ type Block as WPBlock,
13
+ } from '@wordpress/blocks';
10
14
  import {
11
15
  type CRDTDoc,
12
16
  type ObjectData,
@@ -46,10 +50,16 @@ import {
46
50
  type YMapWrap,
47
51
  } from './crdt-utils';
48
52
 
53
+ // A function that derives content from blocks. Two callers produce this:
54
+ // `useEntityBlockEditor` reads blocks from its argument (so the optional arg
55
+ // lets it accept whatever caller is invoked with), and the receiver-side
56
+ // injection in this file captures blocks in a closure and ignores the arg.
57
+ type ContentFromBlocksFn = ( args?: { blocks: Block[] } ) => string;
58
+
49
59
  // Changes that can be applied to a post entity record.
50
60
  export type PostChanges = Partial< Post > & {
51
61
  blocks?: Block[];
52
- content?: Post[ 'content' ] | string;
62
+ content?: Post[ 'content' ] | string | ContentFromBlocksFn;
53
63
  excerpt?: Post[ 'excerpt' ] | string;
54
64
  selection?: WPSelection;
55
65
  title?: Post[ 'title' ] | string;
@@ -138,15 +148,39 @@ export function applyPostChangesToCRDTDoc(
138
148
 
139
149
  const newValue = changes[ key ];
140
150
 
141
- // Cannot serialize function values, so cannot sync them.
151
+ // Cannot serialize function values, so cannot sync them. `content` is
152
+ // often passed as a lazy serializer by `useEntityBlockEditor`; the
153
+ // receiver re-derives it from the synced blocks (see
154
+ // getPostChangesFromCRDTDoc), so dropping it here is intentional.
142
155
  if ( 'function' === typeof newValue ) {
143
156
  return;
144
157
  }
145
158
 
146
159
  switch ( key ) {
147
160
  case 'blocks': {
161
+ // Block changes from typing are bundled with a 'selection' update.
162
+ // Use the resulting cursor position for block merging.
163
+ const newCursorPosition = parseCursorSelection(
164
+ changes.selection
165
+ );
166
+
148
167
  // Blocks are undefined when they need to be re-parsed from content.
149
- if ( ! newValue ) {
168
+ // When new content is also part of this change (e.g. the Code
169
+ // Editor dispatching `{ content, blocks: undefined }` on every
170
+ // keystroke), derive blocks from content so the merge keeps
171
+ // stable YBlock identities for unchanged blocks.
172
+
173
+ const rawContent = getRawValue( changes.content );
174
+ if ( ! newValue && typeof rawContent === 'string' ) {
175
+ // We have no blocks but an updated content string.
176
+ mergeContentWithoutBlocks(
177
+ ymap,
178
+ rawContent,
179
+ newCursorPosition
180
+ );
181
+ break;
182
+ } else if ( ! newValue ) {
183
+ // We have an update containing empty blocks and content.
150
184
  // Set to undefined instead of deleting the key. This is important
151
185
  // since we iterate over the Y.Map keys in getPostChangesFromCRDTDoc.
152
186
  ymap.set( key, undefined );
@@ -161,12 +195,6 @@ export function applyPostChangesToCRDTDoc(
161
195
  ymap.set( key, currentBlocks );
162
196
  }
163
197
 
164
- // Block changes from typing are bundled with a 'selection' update.
165
- // Pass the resulting cursor position to the mergeCrdtBlocks function.
166
- const newCursorPosition = parseCursorSelection(
167
- changes.selection
168
- );
169
-
170
198
  // Merge blocks does not need `setValue` because it is operating on a
171
199
  // Yjs type that is already in the Y.Doc.
172
200
  mergeCrdtBlocks( currentBlocks, newValue, newCursorPosition );
@@ -263,6 +291,36 @@ export function applyPostChangesToCRDTDoc(
263
291
  }
264
292
  }
265
293
 
294
+ /**
295
+ * Derive blocks from a raw content string and merge them into the post's
296
+ * blocks Y.Array. Used when a caller dispatches a change with `blocks:
297
+ * undefined` alongside new content, most notably the Code Editor's
298
+ * per-keystroke dispatch.
299
+ *
300
+ * @param ymap The post's root Y.Map.
301
+ * @param rawContent The raw HTML content to parse.
302
+ * @param cursorPosition Cursor position derived from the change's selection,
303
+ * used by mergeCrdtBlocks for rich-text cursor hints.
304
+ */
305
+ function mergeContentWithoutBlocks(
306
+ ymap: YMapWrap< YPostRecord >,
307
+ rawContent: string,
308
+ cursorPosition: MergeCursorPosition
309
+ ): void {
310
+ let currentBlocks = ymap.get( 'blocks' );
311
+
312
+ if ( ! ( currentBlocks instanceof Y.Array ) ) {
313
+ currentBlocks = new Y.Array< YBlock >();
314
+ ymap.set( 'blocks', currentBlocks );
315
+ }
316
+
317
+ mergeCrdtBlocks(
318
+ currentBlocks,
319
+ parse( rawContent ) as Block[],
320
+ cursorPosition
321
+ );
322
+ }
323
+
266
324
  /**
267
325
  * Only returns a selection object if it describes a selection within a block, with
268
326
  * a cursor inside a RichText field associated with one of that block’s attributes.
@@ -431,6 +489,21 @@ export function getPostChangesFromCRDTDoc(
431
489
  );
432
490
  }
433
491
 
492
+ // When blocks changed but content didn't (the sender internally used a lazy
493
+ // serializer function), inject a closure that captures the synced blocks
494
+ // and serializes them on demand. Mirrors what useEntityBlockEditor does
495
+ // locally. A fresh function on every persistent edit marks the entity
496
+ // dirty (so the save button reactivates for peers), while serialization
497
+ // stays lazy (only runs when getEditedPostContent reads it). The closure
498
+ // captures `capturedBlocks` so the right content is returned even if the
499
+ // caller later clears `record.blocks` (e.g. the Code Editor re-parsing
500
+ // from content).
501
+ if ( changes.blocks && ! changes.content ) {
502
+ const capturedBlocks = changes.blocks;
503
+ changes.content = () =>
504
+ __unstableSerializeAndClean( capturedBlocks as WPBlock[] );
505
+ }
506
+
434
507
  // Meta changes must be merged with the edited record since not all meta
435
508
  // properties are synced.
436
509
  if ( 'object' === typeof changes.meta ) {
@@ -38,6 +38,15 @@ function createTestDoc() {
38
38
  block2.set( 'clientId', 'block-2' );
39
39
  const block2Attrs = new Y.Map();
40
40
  block2Attrs.set( 'content', new Y.Text( 'Second block' ) );
41
+ const body = new Y.Array();
42
+ const row = new Y.Map();
43
+ const cells = new Y.Array();
44
+ const cell = new Y.Map();
45
+ cell.set( 'content', new Y.Text( 'Cell text' ) );
46
+ cells.push( [ cell ] );
47
+ row.set( 'cells', cells );
48
+ body.push( [ row ] );
49
+ block2Attrs.set( 'body', body );
41
50
  block2.set( 'attributes', block2Attrs );
42
51
  block2.set( 'innerBlocks', new Y.Array() );
43
52
  blocks.push( [ block2 ] );
@@ -210,6 +219,39 @@ describe( 'BlockSelectionHistory', () => {
210
219
  const endPosition = fullSelection.end as YRelativeSelection;
211
220
  expect( endPosition.offset ).toBe( 0 );
212
221
  } );
222
+
223
+ test( 'should convert nested rich-text attribute paths to relative positions', () => {
224
+ const selection = createSelection( {
225
+ clientId: 'block-2',
226
+ attributeKey: 'body.0.cells.0.content',
227
+ offset: 4,
228
+ } );
229
+
230
+ history.updateSelection( selection );
231
+
232
+ const selectionHistory = history.getSelectionHistory();
233
+ expect( selectionHistory.length ).toBe( 1 );
234
+
235
+ const fullSelection = selectionHistory[ 0 ];
236
+ expect( fullSelection.start.type ).toBe(
237
+ YSelectionType.RelativeSelection
238
+ );
239
+ expect( fullSelection.end.type ).toBe(
240
+ YSelectionType.RelativeSelection
241
+ );
242
+
243
+ const startPosition = fullSelection.start as YRelativeSelection;
244
+ const endPosition = fullSelection.end as YRelativeSelection;
245
+
246
+ expect( startPosition.attributeKey ).toBe(
247
+ 'body.0.cells.0.content'
248
+ );
249
+ expect( startPosition.offset ).toBe( 4 );
250
+ expect( startPosition.relativePosition ).toBeDefined();
251
+ expect( endPosition.attributeKey ).toBe( 'body.0.cells.0.content' );
252
+ expect( endPosition.offset ).toBe( 4 );
253
+ expect( endPosition.relativePosition ).toBeDefined();
254
+ } );
213
255
  } );
214
256
 
215
257
  describe( 'updateSelection with block positions', () => {
@@ -196,6 +196,43 @@ 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', () => {
200
+ // Simulates the Code Editor flow: the sender re-parses raw HTML on
201
+ // every keystroke, which mints a fresh clientId for every block.
202
+ // The Y.Doc's clientId should stay stable so remote peers don't
203
+ // remount the block (and any embed iframe within it).
204
+ const initialBlocks: Block[] = [
205
+ {
206
+ name: 'core/paragraph',
207
+ attributes: { content: 'Initial content' },
208
+ innerBlocks: [],
209
+ clientId: 'stable-id',
210
+ },
211
+ ];
212
+
213
+ mergeCrdtBlocks( yblocks, initialBlocks, null );
214
+ expect( yblocks.get( 0 ).get( 'clientId' ) ).toBe( 'stable-id' );
215
+
216
+ const reparsedBlocks: Block[] = [
217
+ {
218
+ name: 'core/paragraph',
219
+ attributes: { content: 'Updated content' },
220
+ innerBlocks: [],
221
+ clientId: 'freshly-parsed-id',
222
+ },
223
+ ];
224
+
225
+ mergeCrdtBlocks( yblocks, reparsedBlocks, null );
226
+
227
+ expect( yblocks.length ).toBe( 1 );
228
+ const block = yblocks.get( 0 );
229
+ expect( block.get( 'clientId' ) ).toBe( 'stable-id' );
230
+ const content = (
231
+ block.get( 'attributes' ) as YBlockAttributes
232
+ ).get( 'content' ) as Y.Text;
233
+ expect( content.toString() ).toBe( 'Updated content' );
234
+ } );
235
+
199
236
  it( 'deletes blocks that are removed', () => {
200
237
  const initialBlocks: Block[] = [
201
238
  {
@@ -396,6 +396,15 @@ function createTestDocWithBlocks() {
396
396
  block2.set( 'clientId', 'block-2' );
397
397
  const block2Attrs = new Y.Map();
398
398
  block2Attrs.set( 'content', new Y.Text( 'Second block content' ) );
399
+ const body = new Y.Array();
400
+ const row = new Y.Map();
401
+ const cells = new Y.Array();
402
+ const cell = new Y.Map();
403
+ cell.set( 'content', new Y.Text( 'Cell text' ) );
404
+ cells.push( [ cell ] );
405
+ row.set( 'cells', cells );
406
+ body.push( [ row ] );
407
+ block2Attrs.set( 'body', body );
399
408
  block2.set( 'attributes', block2Attrs );
400
409
  block2.set( 'innerBlocks', new Y.Array() );
401
410
  blocks.push( [ block2 ] );
@@ -529,6 +538,9 @@ describe( 'getSelectionState', () => {
529
538
  expect(
530
539
  ( result as SelectionCursor ).cursorPosition.absoluteOffset
531
540
  ).toBe( 5 );
541
+ expect(
542
+ ( result as SelectionCursor ).cursorPosition.attributeKey
543
+ ).toBe( 'content' );
532
544
  } );
533
545
 
534
546
  test( 'returns Cursor at start of block (offset 0)', () => {
@@ -555,6 +567,33 @@ describe( 'getSelectionState', () => {
555
567
  ).toBe( 0 );
556
568
  } );
557
569
 
570
+ test( 'returns Cursor for a nested rich-text attribute path', () => {
571
+ const selectionStart: WPBlockSelection = {
572
+ clientId: 'block-2',
573
+ attributeKey: 'body.0.cells.0.content',
574
+ offset: 4,
575
+ };
576
+ const selectionEnd: WPBlockSelection = {
577
+ clientId: 'block-2',
578
+ attributeKey: 'body.0.cells.0.content',
579
+ offset: 4,
580
+ };
581
+
582
+ const result = getSelectionState(
583
+ selectionStart,
584
+ selectionEnd,
585
+ testDoc
586
+ );
587
+
588
+ expect( result.type ).toBe( SelectionType.Cursor );
589
+ expect(
590
+ ( result as SelectionCursor ).cursorPosition.absoluteOffset
591
+ ).toBe( 4 );
592
+ expect(
593
+ ( result as SelectionCursor ).cursorPosition.attributeKey
594
+ ).toBe( 'body.0.cells.0.content' );
595
+ } );
596
+
558
597
  test( 'returns None when block does not exist', () => {
559
598
  const selectionStart: WPBlockSelection = {
560
599
  clientId: 'non-existent-block',
@@ -2,6 +2,7 @@
2
2
  * External dependencies
3
3
  */
4
4
  import { describe, expect, it } from '@jest/globals';
5
+ import { Y } from '@wordpress/sync';
5
6
 
6
7
  /**
7
8
  * Internal dependencies
@@ -9,6 +10,7 @@ import { describe, expect, it } from '@jest/globals';
9
10
  import {
10
11
  asHtmlStringIndex,
11
12
  asRichTextOffset,
13
+ getYTextByAttributeKey,
12
14
  htmlIndexToRichTextOffset as typedHtmlIndexToRichTextOffset,
13
15
  richTextOffsetToHtmlIndex as typedRichTextOffsetToHtmlIndex,
14
16
  } from '../crdt-utils';
@@ -27,6 +29,56 @@ function richTextOffsetToHtmlIndex( html: string, richTextOffset: number ) {
27
29
  );
28
30
  }
29
31
 
32
+ function createAttachedAttributes(): Y.Map< unknown > {
33
+ const ydoc = new Y.Doc();
34
+ const root = ydoc.getMap( 'test' );
35
+ const attributes = new Y.Map< unknown >();
36
+ root.set( 'attributes', attributes );
37
+ return attributes;
38
+ }
39
+
40
+ describe( 'getYTextByAttributeKey', () => {
41
+ it( 'returns a top-level rich-text attribute', () => {
42
+ const attributes = createAttachedAttributes();
43
+ const text = new Y.Text( 'Top level' );
44
+ attributes.set( 'content', text );
45
+
46
+ expect( getYTextByAttributeKey( attributes, 'content' ) ).toBe( text );
47
+ } );
48
+
49
+ it( 'returns a nested rich-text attribute by dot path', () => {
50
+ const attributes = createAttachedAttributes();
51
+ const body = new Y.Array< Y.Map< unknown > >();
52
+ const row = new Y.Map< unknown >();
53
+ const cells = new Y.Array< Y.Map< unknown > >();
54
+ const cell = new Y.Map< unknown >();
55
+ const text = new Y.Text( 'Cell text' );
56
+
57
+ cell.set( 'content', text );
58
+ cells.push( [ cell ] );
59
+ row.set( 'cells', cells );
60
+ body.push( [ row ] );
61
+ attributes.set( 'body', body );
62
+
63
+ expect(
64
+ getYTextByAttributeKey( attributes, 'body.0.cells.0.content' )
65
+ ).toBe( text );
66
+ } );
67
+
68
+ it( 'returns null for invalid array path segments', () => {
69
+ const attributes = createAttachedAttributes();
70
+ const body = new Y.Array< Y.Map< unknown > >();
71
+ attributes.set( 'body', body );
72
+
73
+ expect(
74
+ getYTextByAttributeKey( attributes, 'body.01.cells.0.content' )
75
+ ).toBeNull();
76
+ expect(
77
+ getYTextByAttributeKey( attributes, 'body.-1.cells.0.content' )
78
+ ).toBeNull();
79
+ } );
80
+ } );
81
+
30
82
  describe( 'htmlIndexToRichTextOffset', () => {
31
83
  it( 'returns the index unchanged when there are no tags', () => {
32
84
  expect( htmlIndexToRichTextOffset( 'hello world', 5 ) ).toBe( 5 );
@@ -10,6 +10,9 @@ import { describe, expect, it, jest, beforeEach } from '@jest/globals';
10
10
 
11
11
  /**
12
12
  * Mock getBlockTypes so CRDT merging can identify rich-text attributes.
13
+ * Also stub __unstableSerializeAndClean so we can assert how it's invoked
14
+ * (the real implementation returns "" without registered block types, which
15
+ * isn't useful for asserting closure-capture behavior).
13
16
  */
14
17
  jest.mock( '@wordpress/blocks', () => {
15
18
  const actual = jest.requireActual( '@wordpress/blocks' ) as Record<
@@ -43,12 +46,19 @@ jest.mock( '@wordpress/blocks', () => {
43
46
  },
44
47
  },
45
48
  ],
49
+ // Mocked so tests can control what the Code Editor sync path "parses"
50
+ // from raw content without needing real block-type registration.
51
+ parse: jest.fn( () => [] ),
52
+ __unstableSerializeAndClean: jest.fn(
53
+ ( blocks: unknown[] ) => `serialized:${ blocks?.length ?? 0 }`
54
+ ),
46
55
  };
47
56
  } );
48
57
 
49
58
  /**
50
59
  * WordPress dependencies
51
60
  */
61
+ import { parse } from '@wordpress/blocks';
52
62
  import { RichTextData } from '@wordpress/rich-text';
53
63
 
54
64
  /**
@@ -63,7 +73,7 @@ import {
63
73
  type PostChanges,
64
74
  type YPostRecord,
65
75
  } from '../crdt';
66
- import type { YBlock, YBlockRecord, YBlocks } from '../crdt-blocks';
76
+ import type { Block, YBlock, YBlockRecord, YBlocks } from '../crdt-blocks';
67
77
  import { updateSelectionHistory } from '../crdt-selection';
68
78
  import { createYMap, getRootMap, type YMapWrap } from '../crdt-utils';
69
79
  import type { Post } from '../../entity-types';
@@ -126,9 +136,12 @@ describe( 'crdt', () => {
126
136
  beforeEach( () => {
127
137
  doc = new Y.Doc();
128
138
  jest.clearAllMocks();
139
+ jest.useFakeTimers();
129
140
  } );
130
141
 
131
142
  afterEach( () => {
143
+ jest.runAllTimers();
144
+ jest.useRealTimers();
132
145
  doc.destroy();
133
146
  } );
134
147
 
@@ -286,7 +299,7 @@ describe( 'crdt', () => {
286
299
  expect( blocks ).toBeInstanceOf( Y.Array );
287
300
  } );
288
301
 
289
- it( 'sets blocks to undefined when blocks value is undefined', () => {
302
+ it( 'sets blocks to undefined when blocks value is undefined and no content is provided', () => {
290
303
  // First, set some blocks.
291
304
  map.set( 'blocks', new Y.Array< YBlock >() );
292
305
 
@@ -301,6 +314,86 @@ describe( 'crdt', () => {
301
314
  expect( map.get( 'blocks' ) ).toBeUndefined();
302
315
  } );
303
316
 
317
+ it( 'parses content into blocks when blocks=undefined is paired with new content', () => {
318
+ // Pre-populate the Y.Doc with two stable blocks. Simulates the
319
+ // state after the initial sync: peers share the same blocks Y.Array
320
+ // with stable clientIds on every YBlock.
321
+ applyPostChangesToCRDTDoc(
322
+ doc,
323
+ {
324
+ blocks: [
325
+ {
326
+ name: 'core/paragraph',
327
+ attributes: { content: 'Hello' },
328
+ innerBlocks: [],
329
+ clientId: 'stable-first',
330
+ },
331
+ {
332
+ name: 'core/paragraph',
333
+ attributes: { content: 'World' },
334
+ innerBlocks: [],
335
+ clientId: 'stable-second',
336
+ },
337
+ ],
338
+ } as PostChanges,
339
+ defaultSyncedProperties
340
+ );
341
+
342
+ // The Code Editor flow: dispatch `{ content, blocks: undefined }`
343
+ // when the user types. The new HTML edits the second paragraph
344
+ // only. `parse()` is mocked to return blocks with freshly minted
345
+ // clientIds — the sync layer must not let those overwrite the
346
+ // stable clientIds already in the Y.Array.
347
+ ( parse as jest.Mock ).mockReturnValueOnce( [
348
+ {
349
+ name: 'core/paragraph',
350
+ attributes: { content: 'Hello' },
351
+ innerBlocks: [],
352
+ clientId: 'fresh-first',
353
+ },
354
+ {
355
+ name: 'core/paragraph',
356
+ attributes: { content: 'World!' },
357
+ innerBlocks: [],
358
+ clientId: 'fresh-second',
359
+ },
360
+ ] );
361
+
362
+ applyPostChangesToCRDTDoc(
363
+ doc,
364
+ {
365
+ content:
366
+ '<!-- wp:paragraph --><p>Hello</p><!-- /wp:paragraph -->' +
367
+ '<!-- wp:paragraph --><p>World!</p><!-- /wp:paragraph -->',
368
+ blocks: undefined,
369
+ } as PostChanges,
370
+ defaultSyncedProperties
371
+ );
372
+
373
+ const yblocks = map.get( 'blocks' );
374
+ expect( yblocks ).toBeInstanceOf( Y.Array );
375
+ const blocksArray = yblocks as YBlocks;
376
+ expect( blocksArray.length ).toBe( 2 );
377
+
378
+ // Both clientIds must be preserved: the unchanged first block via
379
+ // the left-right diff sweep, the edited second block via the
380
+ // explicit clientId-skip in the update loop.
381
+ expect( blocksArray.get( 0 ).get( 'clientId' ) ).toBe(
382
+ 'stable-first'
383
+ );
384
+ expect( blocksArray.get( 1 ).get( 'clientId' ) ).toBe(
385
+ 'stable-second'
386
+ );
387
+
388
+ // The second block's content reflects the edit.
389
+ const updatedContent = (
390
+ blocksArray
391
+ .get( 1 )
392
+ .get( 'attributes' ) as unknown as YMapWrap< YBlockRecord >
393
+ ).get( 'content' ) as Y.Text;
394
+ expect( updatedContent.toString() ).toBe( 'World!' );
395
+ } );
396
+
304
397
  it( 'syncs content as Y.Text', () => {
305
398
  const changes = {
306
399
  content: 'Hello, world!',
@@ -439,6 +532,23 @@ describe( 'crdt', () => {
439
532
  expect( metaMap?.get( 'custom_field' ) ).toBe( 'value' );
440
533
  } );
441
534
 
535
+ it( 'skips function-valued content in changes', () => {
536
+ const changes = {
537
+ content: ( {
538
+ blocks: blocksForSerialization = [],
539
+ }: {
540
+ blocks: Block[];
541
+ } ) =>
542
+ blocksForSerialization
543
+ .map( ( b ) => b.attributes.content )
544
+ .join( '' ),
545
+ } as unknown as PostChanges;
546
+
547
+ applyPostChangesToCRDTDoc( doc, changes, defaultSyncedProperties );
548
+
549
+ expect( map.has( 'content' ) ).toBe( false );
550
+ } );
551
+
442
552
  it( 'syncs taxonomy rest_base values included in syncedProperties', () => {
443
553
  const changes = {
444
554
  categories: [ 1, 2, 3 ],
@@ -958,6 +1068,102 @@ describe( 'crdt', () => {
958
1068
  expect( changes.selection ).toBeUndefined();
959
1069
  } );
960
1070
  } );
1071
+
1072
+ it( 'injects a closure-based content function when blocks changed but content did not', () => {
1073
+ addBlockToDoc( map, 'block-1', 'Hello world' );
1074
+
1075
+ const editedRecord = {
1076
+ title: 'CRDT Title',
1077
+ status: 'draft',
1078
+ content: { raw: 'Same content', rendered: 'Same content' },
1079
+ blocks: [],
1080
+ } as unknown as Post;
1081
+
1082
+ const changes = getPostChangesFromCRDTDoc(
1083
+ doc,
1084
+ editedRecord,
1085
+ defaultSyncedProperties
1086
+ );
1087
+
1088
+ // Blocks changed, content didn't, so a lazy content function is injected.
1089
+ expect( changes.blocks ).toBeDefined();
1090
+ expect( typeof changes.content ).toBe( 'function' );
1091
+ } );
1092
+
1093
+ it( 'injected content function captures the synced blocks and ignores its caller-supplied argument', () => {
1094
+ addBlockToDoc( map, 'block-1', 'Hello world' );
1095
+
1096
+ const editedRecord = {
1097
+ title: 'CRDT Title',
1098
+ status: 'draft',
1099
+ content: { raw: 'Same content', rendered: 'Same content' },
1100
+ blocks: [],
1101
+ } as unknown as Post;
1102
+
1103
+ const changes = getPostChangesFromCRDTDoc(
1104
+ doc,
1105
+ editedRecord,
1106
+ defaultSyncedProperties
1107
+ );
1108
+
1109
+ // The injected function takes no parameters and serializes the
1110
+ // captured (synced) blocks. This is what makes getEditedPostContent
1111
+ // keep working after the Code Editor clears `record.blocks` to force
1112
+ // a re-parse: the closure already has the right blocks on hand.
1113
+ //
1114
+ // The mocked __unstableSerializeAndClean returns "serialized:<n>"
1115
+ // where n is the length of the blocks it was called with. The
1116
+ // captured blocks have one entry, so both calls below should yield
1117
+ // "serialized:1" (proving the closure ignores its argument and
1118
+ // uses the captured blocks instead).
1119
+ const contentFn = changes.content as ( args?: {
1120
+ blocks: Block[];
1121
+ } ) => string;
1122
+ expect( contentFn() ).toBe( 'serialized:1' );
1123
+ expect( contentFn( { blocks: [] } ) ).toBe( 'serialized:1' );
1124
+ } );
1125
+
1126
+ it( 'does not inject a content function when content also changed in the doc', () => {
1127
+ addBlockToDoc( map, 'block-1', 'Hello world' );
1128
+ map.set( 'content', new Y.Text( 'New content' ) );
1129
+
1130
+ const editedRecord = {
1131
+ title: 'CRDT Title',
1132
+ status: 'draft',
1133
+ content: { raw: 'Old content', rendered: 'Old content' },
1134
+ blocks: [],
1135
+ } as unknown as Post;
1136
+
1137
+ const changes = getPostChangesFromCRDTDoc(
1138
+ doc,
1139
+ editedRecord,
1140
+ defaultSyncedProperties
1141
+ );
1142
+
1143
+ // Content changed directly, so it should be a string, not a function.
1144
+ expect( changes.blocks ).toBeDefined();
1145
+ expect( typeof changes.content ).toBe( 'string' );
1146
+ expect( changes.content ).toBe( 'New content' );
1147
+ } );
1148
+
1149
+ it( 'does not inject a content function when blocks did not change', () => {
1150
+ map.set( 'content', new Y.Text( 'Same content' ) );
1151
+
1152
+ const editedRecord = {
1153
+ title: 'CRDT Title',
1154
+ status: 'draft',
1155
+ content: { raw: 'Same content', rendered: 'Same content' },
1156
+ } as unknown as Post;
1157
+
1158
+ const changes = getPostChangesFromCRDTDoc(
1159
+ doc,
1160
+ editedRecord,
1161
+ defaultSyncedProperties
1162
+ );
1163
+
1164
+ expect( changes.blocks ).toBeUndefined();
1165
+ expect( changes.content ).toBeUndefined();
1166
+ } );
961
1167
  } );
962
1168
  } );
963
1169