@wordpress/core-data 7.42.0 → 7.43.2-next.v.202604091042.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 (147) hide show
  1. package/CHANGELOG.md +9 -1
  2. package/build/entities.cjs +28 -20
  3. package/build/entities.cjs.map +2 -2
  4. package/build/hooks/index.cjs +3 -3
  5. package/build/hooks/index.cjs.map +1 -1
  6. package/build/hooks/use-entity-record.cjs +4 -4
  7. package/build/hooks/use-entity-record.cjs.map +2 -2
  8. package/build/hooks/use-entity-records.cjs +3 -3
  9. package/build/hooks/use-entity-records.cjs.map +2 -2
  10. package/build/hooks/use-query-select.cjs +2 -2
  11. package/build/hooks/use-query-select.cjs.map +2 -2
  12. package/build/hooks/use-resource-permissions.cjs +4 -4
  13. package/build/hooks/use-resource-permissions.cjs.map +2 -2
  14. package/build/private-actions.cjs +10 -0
  15. package/build/private-actions.cjs.map +2 -2
  16. package/build/private-selectors.cjs +20 -3
  17. package/build/private-selectors.cjs.map +2 -2
  18. package/build/queried-data/get-query-parts.cjs +8 -7
  19. package/build/queried-data/get-query-parts.cjs.map +2 -2
  20. package/build/queried-data/reducer.cjs +15 -9
  21. package/build/queried-data/reducer.cjs.map +2 -2
  22. package/build/queried-data/selectors.cjs +23 -22
  23. package/build/queried-data/selectors.cjs.map +2 -2
  24. package/build/reducer.cjs +16 -3
  25. package/build/reducer.cjs.map +2 -2
  26. package/build/resolvers.cjs +22 -14
  27. package/build/resolvers.cjs.map +2 -2
  28. package/build/selectors.cjs +20 -10
  29. package/build/selectors.cjs.map +2 -2
  30. package/build/utils/crdt-blocks.cjs +170 -31
  31. package/build/utils/crdt-blocks.cjs.map +2 -2
  32. package/build/utils/crdt-text.cjs +52 -0
  33. package/build/utils/crdt-text.cjs.map +7 -0
  34. package/build/utils/crdt.cjs +13 -0
  35. package/build/utils/crdt.cjs.map +2 -2
  36. package/build/utils/index.cjs +0 -3
  37. package/build/utils/index.cjs.map +2 -2
  38. package/build-module/entities.mjs +29 -20
  39. package/build-module/entities.mjs.map +2 -2
  40. package/build-module/hooks/index.mjs +6 -6
  41. package/build-module/hooks/index.mjs.map +2 -2
  42. package/build-module/hooks/use-entity-record.mjs +3 -3
  43. package/build-module/hooks/use-entity-record.mjs.map +2 -2
  44. package/build-module/hooks/use-entity-records.mjs +2 -2
  45. package/build-module/hooks/use-entity-records.mjs.map +2 -2
  46. package/build-module/hooks/use-query-select.mjs +1 -1
  47. package/build-module/hooks/use-query-select.mjs.map +1 -1
  48. package/build-module/hooks/use-resource-permissions.mjs +3 -3
  49. package/build-module/hooks/use-resource-permissions.mjs.map +2 -2
  50. package/build-module/private-actions.mjs +9 -0
  51. package/build-module/private-actions.mjs.map +2 -2
  52. package/build-module/private-selectors.mjs +19 -3
  53. package/build-module/private-selectors.mjs.map +2 -2
  54. package/build-module/queried-data/get-query-parts.mjs +8 -7
  55. package/build-module/queried-data/get-query-parts.mjs.map +2 -2
  56. package/build-module/queried-data/reducer.mjs +15 -9
  57. package/build-module/queried-data/reducer.mjs.map +2 -2
  58. package/build-module/queried-data/selectors.mjs +23 -22
  59. package/build-module/queried-data/selectors.mjs.map +2 -2
  60. package/build-module/reducer.mjs +14 -2
  61. package/build-module/reducer.mjs.map +2 -2
  62. package/build-module/resolvers.mjs +20 -13
  63. package/build-module/resolvers.mjs.map +2 -2
  64. package/build-module/selectors.mjs +20 -11
  65. package/build-module/selectors.mjs.map +2 -2
  66. package/build-module/utils/crdt-blocks.mjs +169 -31
  67. package/build-module/utils/crdt-blocks.mjs.map +2 -2
  68. package/build-module/utils/crdt-text.mjs +26 -0
  69. package/build-module/utils/crdt-text.mjs.map +7 -0
  70. package/build-module/utils/crdt.mjs +13 -0
  71. package/build-module/utils/crdt.mjs.map +2 -2
  72. package/build-module/utils/index.mjs +8 -10
  73. package/build-module/utils/index.mjs.map +2 -2
  74. package/build-types/entities.d.ts +51 -32
  75. package/build-types/entities.d.ts.map +1 -1
  76. package/build-types/hooks/index.d.ts +3 -3
  77. package/build-types/hooks/index.d.ts.map +1 -1
  78. package/build-types/hooks/use-entity-record.d.ts +1 -1
  79. package/build-types/hooks/use-entity-record.d.ts.map +1 -1
  80. package/build-types/hooks/use-entity-records.d.ts +1 -1
  81. package/build-types/hooks/use-entity-records.d.ts.map +1 -1
  82. package/build-types/hooks/use-resource-permissions.d.ts +1 -1
  83. package/build-types/hooks/use-resource-permissions.d.ts.map +1 -1
  84. package/build-types/index.d.ts.map +1 -1
  85. package/build-types/private-actions.d.ts +10 -0
  86. package/build-types/private-actions.d.ts.map +1 -1
  87. package/build-types/private-selectors.d.ts +11 -4
  88. package/build-types/private-selectors.d.ts.map +1 -1
  89. package/build-types/queried-data/get-query-parts.d.ts +11 -19
  90. package/build-types/queried-data/get-query-parts.d.ts.map +1 -1
  91. package/build-types/queried-data/reducer.d.ts +11 -5
  92. package/build-types/queried-data/reducer.d.ts.map +1 -1
  93. package/build-types/queried-data/selectors.d.ts +5 -3
  94. package/build-types/queried-data/selectors.d.ts.map +1 -1
  95. package/build-types/reducer.d.ts +11 -0
  96. package/build-types/reducer.d.ts.map +1 -1
  97. package/build-types/resolvers.d.ts +3 -0
  98. package/build-types/resolvers.d.ts.map +1 -1
  99. package/build-types/selectors.d.ts +1 -0
  100. package/build-types/selectors.d.ts.map +1 -1
  101. package/build-types/utils/crdt-blocks.d.ts +11 -0
  102. package/build-types/utils/crdt-blocks.d.ts.map +1 -1
  103. package/build-types/utils/crdt-text.d.ts +16 -0
  104. package/build-types/utils/crdt-text.d.ts.map +1 -0
  105. package/build-types/utils/crdt.d.ts +7 -0
  106. package/build-types/utils/crdt.d.ts.map +1 -1
  107. package/build-types/utils/index.d.ts +0 -1
  108. package/package.json +18 -18
  109. package/src/entities.js +16 -8
  110. package/src/hooks/index.ts +3 -3
  111. package/src/hooks/test/use-entity-records.js +1 -1
  112. package/src/hooks/use-entity-record.ts +1 -1
  113. package/src/hooks/use-entity-records.ts +1 -1
  114. package/src/hooks/use-query-select.ts +1 -1
  115. package/src/hooks/use-resource-permissions.ts +1 -1
  116. package/src/private-actions.js +18 -0
  117. package/src/private-selectors.ts +37 -4
  118. package/src/queried-data/get-query-parts.js +14 -20
  119. package/src/queried-data/reducer.js +28 -15
  120. package/src/queried-data/selectors.js +34 -38
  121. package/src/queried-data/test/get-query-parts.js +11 -11
  122. package/src/queried-data/test/reducer.js +78 -8
  123. package/src/queried-data/test/selectors.js +52 -30
  124. package/src/reducer.js +20 -0
  125. package/src/resolvers.js +29 -13
  126. package/src/selectors.ts +28 -21
  127. package/src/utils/crdt-blocks.ts +348 -60
  128. package/src/utils/crdt-text.ts +43 -0
  129. package/src/utils/crdt.ts +25 -0
  130. package/src/utils/index.js +0 -1
  131. package/src/utils/test/crdt-blocks.ts +838 -6
  132. package/src/utils/test/crdt.ts +147 -1
  133. package/build/hooks/memoize.cjs +0 -38
  134. package/build/hooks/memoize.cjs.map +0 -7
  135. package/build/utils/is-raw-attribute.cjs +0 -29
  136. package/build/utils/is-raw-attribute.cjs.map +0 -7
  137. package/build-module/hooks/memoize.mjs +0 -7
  138. package/build-module/hooks/memoize.mjs.map +0 -7
  139. package/build-module/utils/is-raw-attribute.mjs +0 -8
  140. package/build-module/utils/is-raw-attribute.mjs.map +0 -7
  141. package/build-types/hooks/memoize.d.ts +0 -3
  142. package/build-types/hooks/memoize.d.ts.map +0 -1
  143. package/build-types/utils/is-raw-attribute.d.ts +0 -10
  144. package/build-types/utils/is-raw-attribute.d.ts.map +0 -1
  145. package/src/hooks/memoize.js +0 -7
  146. package/src/utils/is-raw-attribute.js +0 -11
  147. package/src/utils/test/is-raw-attribute.js +0 -22
package/src/selectors.ts CHANGED
@@ -20,7 +20,6 @@ import { DEFAULT_ENTITY_KEY } from './entities';
20
20
  import { getUndoManager } from './private-selectors';
21
21
  import {
22
22
  getNormalizedCommaSeparable,
23
- isRawAttribute,
24
23
  setNestedValue,
25
24
  isNumericID,
26
25
  getUserPermissionCacheKey,
@@ -55,6 +54,7 @@ export interface State {
55
54
  editorAssets: Record< string, any > | null;
56
55
  syncConnectionStatuses?: Record< string, ConnectionStatus >;
57
56
  collaborationSupported: boolean;
57
+ viewConfigs: Record< string, Record< string, any > >;
58
58
  }
59
59
 
60
60
  type EntityRecordKey = string | number;
@@ -523,25 +523,26 @@ export const getRawEntityRecord = createSelector(
523
523
  name,
524
524
  key
525
525
  );
526
- return (
527
- record &&
528
- Object.keys( record ).reduce( ( accumulator, _key ) => {
529
- if (
530
- isRawAttribute( getEntityConfig( state, kind, name ), _key )
531
- ) {
532
- // Because edits are the "raw" attribute values,
533
- // we return those from record selectors to make rendering,
534
- // comparisons, and joins with edits easier.
535
- accumulator[ _key ] =
536
- record[ _key ]?.raw !== undefined
537
- ? record[ _key ]?.raw
538
- : record[ _key ];
539
- } else {
540
- accumulator[ _key ] = record[ _key ];
526
+ const config = getEntityConfig( state, kind, name );
527
+ if ( ! record || ! config?.rawAttributes?.length ) {
528
+ return record;
529
+ }
530
+
531
+ // Because edits are the "raw" attribute values,
532
+ // we return those from record selectors to make rendering,
533
+ // comparisons, and joins with edits easier.
534
+ return Object.fromEntries(
535
+ Object.keys( record ).map( ( _key ) => {
536
+ if ( config.rawAttributes.includes( _key ) ) {
537
+ const rawValue = record[ _key ]?.raw;
538
+ return [
539
+ _key,
540
+ rawValue !== undefined ? rawValue : record[ _key ],
541
+ ];
541
542
  }
542
- return accumulator;
543
- }, {} as any )
544
- );
543
+ return [ _key, record[ _key ] ];
544
+ } )
545
+ ) as EntityRecord;
545
546
  },
546
547
  (
547
548
  state: State,
@@ -654,7 +655,10 @@ export const getEntityRecords = ( <
654
655
  if ( ! queriedState ) {
655
656
  return null;
656
657
  }
657
- return getQueriedItems( queriedState, query );
658
+ return getQueriedItems( queriedState, query, {
659
+ supportsPagination: !! getEntityConfig( state, kind, name )
660
+ ?.supportsPagination,
661
+ } );
658
662
  } ) as GetEntityRecords;
659
663
 
660
664
  /**
@@ -712,7 +716,10 @@ export const getEntityRecordsTotalPages = (
712
716
  if ( ! queriedState ) {
713
717
  return null;
714
718
  }
715
- if ( query?.per_page === -1 ) {
719
+ if (
720
+ ! getEntityConfig( state, kind, name )?.supportsPagination ||
721
+ query?.per_page === -1
722
+ ) {
716
723
  return 1;
717
724
  }
718
725
  const totalItems = getQueriedTotalItems( queriedState, query );
@@ -16,19 +16,21 @@ import { Y } from '@wordpress/sync';
16
16
  * Internal dependencies
17
17
  */
18
18
  import { createYMap, type YMapRecord, type YMapWrap } from './crdt-utils';
19
+ import { getCachedRichTextData } from './crdt-text';
19
20
  import { Delta } from '../sync';
20
21
 
21
22
  interface BlockAttributes {
22
23
  [ key: string ]: unknown;
23
24
  }
24
25
 
25
- interface BlockAttributeType {
26
+ interface BlockAttributeSchema {
26
27
  role?: string;
27
28
  type?: string;
29
+ query?: Record< string, BlockAttributeSchema >;
28
30
  }
29
31
 
30
32
  interface BlockType {
31
- attributes?: Record< string, BlockAttributeType >;
33
+ attributes?: Record< string, BlockAttributeSchema >;
32
34
  name: string;
33
35
  }
34
36
 
@@ -122,6 +124,85 @@ function makeBlocksSerializable( blocks: Block[] ): Block[] {
122
124
  } );
123
125
  }
124
126
 
127
+ /**
128
+ * Recursively walk an attribute value and convert any strings that correspond
129
+ * to rich-text schema nodes into RichTextData instances. This is the inverse
130
+ * of serializeAttributeValue and handles nested structures like table cells.
131
+ *
132
+ * @param schema The attribute type definition for this value.
133
+ * @param value The attribute value from CRDT (toJSON).
134
+ * @return The value with rich-text strings replaced by RichTextData.
135
+ */
136
+ function deserializeAttributeValue(
137
+ schema: BlockAttributeSchema | undefined,
138
+ value: unknown
139
+ ): unknown {
140
+ if ( schema?.type === 'rich-text' && typeof value === 'string' ) {
141
+ return getCachedRichTextData( value );
142
+ }
143
+
144
+ // e.g. core/table `body`: [ { cells: [ { content: RichTextData } ] } ]
145
+ if ( Array.isArray( value ) ) {
146
+ return value.map( ( item ) =>
147
+ deserializeAttributeValue( schema, item )
148
+ );
149
+ }
150
+
151
+ // e.g. a single row inside core/table `body`: { cells: [ ... ] }
152
+ if ( value && typeof value === 'object' ) {
153
+ const result: Record< string, unknown > = {};
154
+
155
+ for ( const [ key, innerValue ] of Object.entries(
156
+ value as Record< string, unknown >
157
+ ) ) {
158
+ result[ key ] = deserializeAttributeValue(
159
+ schema?.query?.[ key ],
160
+ innerValue
161
+ );
162
+ }
163
+
164
+ return result;
165
+ }
166
+
167
+ return value;
168
+ }
169
+
170
+ /**
171
+ * Convert blocks from their CRDT-serialized form back to the runtime form
172
+ * expected by the block editor. Rich-text attributes are stored as Y.Text in
173
+ * the CRDT document, which serializes to plain strings via toJSON(). This
174
+ * function restores them to RichTextData instances so that block edit
175
+ * components that rely on RichTextData methods (e.g. `.text`) work correctly.
176
+ *
177
+ * @param blocks Blocks as extracted from the CRDT document via toJSON().
178
+ * @return Blocks with rich-text attributes restored to RichTextData.
179
+ */
180
+ export function deserializeBlockAttributes( blocks: Block[] ): Block[] {
181
+ return blocks.map( ( block: Block ) => {
182
+ const { name, innerBlocks, attributes, ...rest } = block;
183
+
184
+ const newAttributes = { ...attributes };
185
+
186
+ for ( const [ key, value ] of Object.entries( attributes ) ) {
187
+ const schema = getBlockAttributeSchema( name, key );
188
+
189
+ if ( schema ) {
190
+ newAttributes[ key ] = deserializeAttributeValue(
191
+ schema,
192
+ value
193
+ );
194
+ }
195
+ }
196
+
197
+ return {
198
+ ...rest,
199
+ name,
200
+ attributes: newAttributes,
201
+ innerBlocks: deserializeBlockAttributes( innerBlocks ?? [] ),
202
+ };
203
+ } );
204
+ }
205
+
125
206
  /**
126
207
  * @param {any} gblock
127
208
  * @param {Y.Map} yblock
@@ -174,14 +255,89 @@ function createNewYAttributeValue(
174
255
  blockName: string,
175
256
  attributeName: string,
176
257
  attributeValue: unknown
177
- ): Y.Text | unknown {
178
- const isRichText = isRichTextAttribute( blockName, attributeName );
258
+ ): Y.Text | Y.Array< unknown > | Y.Map< unknown > | unknown {
259
+ const schema = getBlockAttributeSchema( blockName, attributeName );
260
+ return createYValueFromSchema( schema, attributeValue );
261
+ }
262
+
263
+ /**
264
+ * Recursively create the appropriate Y.js type for a value based on its
265
+ * block-attribute schema.
266
+ *
267
+ * - `rich-text` -> Y.Text
268
+ * - `array` with query -> Y.Array of Y.Maps
269
+ * - `object` with query -> Y.Map
270
+ * - anything else -> plain value (unchanged)
271
+ *
272
+ * @param schema The attribute type definition.
273
+ * @param value The plain JS value to convert.
274
+ * @return A Y.js type or the original value.
275
+ */
276
+ function createYValueFromSchema(
277
+ schema: BlockAttributeSchema | undefined,
278
+ value: unknown
279
+ ): Y.Text | Y.Array< unknown > | Y.Map< unknown > | unknown {
280
+ if ( ! schema ) {
281
+ return value;
282
+ }
283
+
284
+ if ( schema.type === 'rich-text' ) {
285
+ return new Y.Text( value?.toString() ?? '' );
286
+ }
287
+
288
+ if ( schema.type === 'array' && schema.query && Array.isArray( value ) ) {
289
+ const query = schema.query;
290
+ const yArray = new Y.Array< Y.Map< unknown > >();
291
+
292
+ yArray.insert(
293
+ 0,
294
+ value.map( ( item ) => createYMapFromQuery( query, item ) )
295
+ );
296
+
297
+ return yArray;
298
+ }
179
299
 
180
- if ( isRichText ) {
181
- return new Y.Text( attributeValue?.toString() ?? '' );
300
+ if ( schema.type === 'object' && schema.query && isRecord( value ) ) {
301
+ return createYMapFromQuery( schema.query, value );
182
302
  }
183
303
 
184
- return attributeValue;
304
+ return value;
305
+ }
306
+
307
+ /**
308
+ * Type guard that narrows `unknown` to `Record< string, unknown >`.
309
+ *
310
+ * @param value Value to check.
311
+ * @return True if `value` is a non-null, non-array object.
312
+ */
313
+ function isRecord( value: unknown ): value is Record< string, unknown > {
314
+ return !! value && typeof value === 'object' && ! Array.isArray( value );
315
+ }
316
+
317
+ /**
318
+ * Create a Y.Map from a plain object, using a query schema to decide which
319
+ * properties should become nested Y.js types (Y.Text, Y.Array, Y.Map).
320
+ *
321
+ * @param query The query schema defining the properties.
322
+ * @param obj The plain object to convert.
323
+ * @return A Y.Map with typed values.
324
+ */
325
+ function createYMapFromQuery(
326
+ query: Record< string, BlockAttributeSchema >,
327
+ obj: unknown
328
+ ): Y.Map< unknown > {
329
+ if ( ! isRecord( obj ) ) {
330
+ return new Y.Map();
331
+ }
332
+
333
+ const entries: [ string, unknown ][] = Object.entries( obj ).map(
334
+ ( [ key, val ] ): [ string, unknown ] => {
335
+ const subSchema = query[ key ];
336
+ return [ key, createYValueFromSchema( subSchema, val ) ];
337
+ }
338
+ );
339
+
340
+ return new Y.Map( entries );
185
341
  }
186
342
 
187
343
  function createNewYBlock( block: Block ): YBlock {
@@ -327,8 +483,16 @@ export function mergeCrdtBlocks(
327
483
  currentAttribute
328
484
  );
329
485
 
486
+ // Y types (Y.Text, Y.Array, Y.Map) cannot be
487
+ // compared with fastDeepEqual against plain values.
488
+ // Delegate to mergeYValue which handles no-op
489
+ // detection at the edges.
490
+ const isYType =
491
+ currentAttribute instanceof Y.AbstractType;
492
+
330
493
  const isAttributeChanged =
331
494
  ! isExpectedType ||
495
+ isYType ||
332
496
  ! fastDeepEqual(
333
497
  currentAttribute,
334
498
  attributeValue
@@ -418,11 +582,151 @@ export function mergeCrdtBlocks(
418
582
  }
419
583
 
420
584
  /**
421
- * Update a single attribute on a Yjs block attributes map (currentAttributes).
585
+ * Merge an incoming plain array into an existing Y.Array in-place.
586
+ *
587
+ * When the array length is unchanged (stable structure), each element is
588
+ * merged individually via `mergeYMapValues`, preserving concurrent edits to
589
+ * different elements. When the length changes (structural edit such as row
590
+ * insertion/deletion), the Y.Array is rebuilt from scratch.
591
+ *
592
+ * @param yArray The existing Y.Array to update.
593
+ * @param newValue The new plain array to merge into the Y.Array.
594
+ * @param schema The attribute schema (must have `query`).
595
+ * @param cursorPosition The local cursor position for rich-text delta merges.
596
+ */
597
+ function mergeYArray(
598
+ yArray: Y.Array< unknown >,
599
+ newValue: unknown[],
600
+ schema: BlockAttributeSchema,
601
+ cursorPosition: number | null
602
+ ): void {
603
+ if ( ! schema.query ) {
604
+ return;
605
+ }
606
+
607
+ const query = schema.query;
608
+
609
+ if ( yArray.length === newValue.length ) {
610
+ // Same length: update each element in-place.
611
+ for ( let i = 0; i < newValue.length; i++ ) {
612
+ const currentElement = yArray.get( i );
613
+ const newElement = newValue[ i ];
614
+
615
+ if ( currentElement instanceof Y.Map && isRecord( newElement ) ) {
616
+ mergeYMapValues(
617
+ currentElement,
618
+ newElement,
619
+ query,
620
+ cursorPosition
621
+ );
622
+ } else {
623
+ // Element is the wrong type (e.g. partial migration) or the
624
+ // incoming value is not an object. Rebuild the entire array.
625
+ yArray.delete( 0, yArray.length );
626
+ yArray.insert(
627
+ 0,
628
+ newValue.map( ( item ) =>
629
+ createYMapFromQuery( query, item )
630
+ )
631
+ );
632
+ return;
633
+ }
634
+ }
635
+ } else {
636
+ // Structure changed: rebuild the Y.Array.
637
+ yArray.delete( 0, yArray.length );
638
+ yArray.insert(
639
+ 0,
640
+ newValue.map( ( item ) => createYMapFromQuery( query, item ) )
641
+ );
642
+ }
643
+ }
644
+
645
+ /**
646
+ * Merge a single value into a Y.Map entry, using the attribute schema to
647
+ * decide how to merge.
648
+ *
649
+ * If the current value is already a matching Y.js type (Y.Text, Y.Array,
650
+ * Y.Map), the update is merged in-place so concurrent edits are preserved.
651
+ * Otherwise the value is replaced wholesale.
652
+ *
653
+ * @param schema The attribute type definition for this value.
654
+ * @param newVal The new value to merge into the Y.Map entry.
655
+ * @param yMap The Y.Map that owns this entry.
656
+ * @param key The key of this entry in the Y.Map.
657
+ * @param cursorPosition The local cursor position for rich-text delta merges.
658
+ */
659
+ function mergeYValue(
660
+ schema: BlockAttributeSchema | undefined,
661
+ newVal: unknown,
662
+ yMap: Y.Map< unknown >,
663
+ key: string,
664
+ cursorPosition: number | null
665
+ ): void {
666
+ const currentVal = yMap.get( key );
667
+ if (
668
+ schema?.type === 'rich-text' &&
669
+ typeof newVal === 'string' &&
670
+ currentVal instanceof Y.Text
671
+ ) {
672
+ mergeRichTextUpdate( currentVal, newVal, cursorPosition );
673
+ } else if (
674
+ schema?.type === 'array' &&
675
+ schema.query &&
676
+ Array.isArray( newVal ) &&
677
+ currentVal instanceof Y.Array
678
+ ) {
679
+ mergeYArray( currentVal, newVal, schema, cursorPosition );
680
+ } else if (
681
+ schema?.type === 'object' &&
682
+ schema.query &&
683
+ isRecord( newVal ) &&
684
+ currentVal instanceof Y.Map
685
+ ) {
686
+ mergeYMapValues( currentVal, newVal, schema.query, cursorPosition );
687
+ } else {
688
+ const newYValue = createYValueFromSchema( schema, newVal );
689
+
690
+ // If createYValueFromSchema wrapped the value into a Y type, the
691
+ // current value is the wrong type and needs upgrading. Otherwise,
692
+ // only replace if the raw value actually changed.
693
+ if ( newYValue !== newVal || ! fastDeepEqual( currentVal, newVal ) ) {
694
+ yMap.set( key, newYValue );
695
+ }
696
+ }
697
+ }
698
+
699
+ /**
700
+ * Merge an incoming plain object into an existing Y.Map in-place, using
701
+ * the query schema to decide how each property should be merged.
422
702
  *
423
- * For rich-text attributes that already exist as Y.Text instances, the update
424
- * is applied as a delta merge so that concurrent edits are preserved. All
425
- * other attributes are replaced wholesale via `createNewYAttributeValue`.
703
+ * Properties present in the Y.Map but absent from `newObj` are deleted.
704
+ *
705
+ * @param yMap The existing Y.Map to update.
706
+ * @param newObj The new plain object to merge into the Y.Map.
707
+ * @param query The query schema defining property types.
708
+ * @param cursorPosition The local cursor position for rich-text delta merges.
709
+ */
710
+ function mergeYMapValues(
711
+ yMap: Y.Map< unknown >,
712
+ newObj: Record< string, unknown >,
713
+ query: Record< string, BlockAttributeSchema >,
714
+ cursorPosition: number | null
715
+ ): void {
716
+ for ( const [ key, newVal ] of Object.entries( newObj ) ) {
717
+ mergeYValue( query[ key ], newVal, yMap, key, cursorPosition );
718
+ }
719
+
720
+ // Delete properties absent from the incoming object.
721
+ for ( const key of yMap.keys() ) {
722
+ if ( ! Object.hasOwn( newObj, key ) ) {
723
+ yMap.delete( key );
724
+ }
725
+ }
726
+ }
727
+
728
+ /**
729
+ * Update a single attribute on a Yjs block attributes map (currentAttributes).
426
730
  *
427
731
  * @param blockName The block type name, e.g. 'core/paragraph'.
428
732
  * @param attributeName The name of the attribute to update, e.g. 'content'.
@@ -437,28 +741,22 @@ function updateYBlockAttribute(
437
741
  currentAttributes: YBlockAttributes,
438
742
  cursorPosition: number | null
439
743
  ): void {
440
- const isRichText = isRichTextAttribute( blockName, attributeName );
441
- const currentAttribute = currentAttributes.get( attributeName );
744
+ const schema = getBlockAttributeSchema( blockName, attributeName );
442
745
 
443
- if (
444
- isRichText &&
445
- 'string' === typeof attributeValue &&
446
- currentAttributes.has( attributeName ) &&
447
- currentAttribute instanceof Y.Text
448
- ) {
449
- // Rich text values are stored as persistent Y.Text instances.
450
- // Update the value with a delta in place.
451
- mergeRichTextUpdate( currentAttribute, attributeValue, cursorPosition );
452
- } else {
453
- currentAttributes.set(
454
- attributeName,
455
- createNewYAttributeValue( blockName, attributeName, attributeValue )
456
- );
457
- }
746
+ mergeYValue(
747
+ schema,
748
+ attributeValue,
749
+ currentAttributes,
750
+ attributeName,
751
+ cursorPosition
752
+ );
458
753
  }
459
754
 
460
755
  // Cached block attribute types, populated once from getBlockTypes().
461
- let cachedBlockAttributeTypes: Map< string, Map< string, BlockAttributeType > >;
756
+ let cachedBlockAttributeSchemas: Map<
757
+ string,
758
+ Map< string, BlockAttributeSchema >
759
+ >;
462
760
 
463
761
  /**
464
762
  * Get the attribute type definition for a block attribute.
@@ -467,22 +765,22 @@ let cachedBlockAttributeTypes: Map< string, Map< string, BlockAttributeType > >;
467
765
  * @param attributeName The name of the attribute, e.g. 'content'.
468
766
  * @return The type definition of the attribute.
469
767
  */
470
- function getBlockAttributeType(
768
+ function getBlockAttributeSchema(
471
769
  blockName: string,
472
770
  attributeName: string
473
- ): BlockAttributeType | undefined {
474
- if ( ! cachedBlockAttributeTypes ) {
771
+ ): BlockAttributeSchema | undefined {
772
+ if ( ! cachedBlockAttributeSchemas ) {
475
773
  // Parse the attributes for all blocks once.
476
- cachedBlockAttributeTypes = new Map();
774
+ cachedBlockAttributeSchemas = new Map();
477
775
 
478
776
  for ( const blockType of getBlockTypes() as BlockType[] ) {
479
- cachedBlockAttributeTypes.set(
777
+ cachedBlockAttributeSchemas.set(
480
778
  blockType.name,
481
- new Map< string, BlockAttributeType >(
779
+ new Map< string, BlockAttributeSchema >(
482
780
  Object.entries( blockType.attributes ?? {} ).map(
483
781
  ( [ name, definition ] ) => {
484
- const { role, type } = definition;
485
- return [ name, { role, type } ];
782
+ const { role, type, query } = definition;
783
+ return [ name, { role, type, query } ];
486
784
  }
487
785
  )
488
786
  )
@@ -490,7 +788,7 @@ function getBlockAttributeType(
490
788
  }
491
789
  }
492
790
 
493
- return cachedBlockAttributeTypes.get( blockName )?.get( attributeName );
791
+ return cachedBlockAttributeSchemas.get( blockName )?.get( attributeName );
494
792
  }
495
793
 
496
794
  /**
@@ -506,20 +804,24 @@ function isExpectedAttributeType(
506
804
  attributeName: string,
507
805
  attributeValue: unknown
508
806
  ): boolean {
509
- const expectedAttributeType = getBlockAttributeType(
510
- blockName,
511
- attributeName
512
- )?.type;
807
+ const schema = getBlockAttributeSchema( blockName, attributeName );
513
808
 
514
- if ( expectedAttributeType === 'rich-text' ) {
809
+ if ( schema?.type === 'rich-text' ) {
515
810
  return attributeValue instanceof Y.Text;
516
811
  }
517
812
 
518
- if ( expectedAttributeType === 'string' ) {
813
+ if ( schema?.type === 'string' ) {
519
814
  return typeof attributeValue === 'string';
520
815
  }
521
816
 
522
- // No other types comparisons use special logic.
817
+ if ( schema?.type === 'array' && schema.query ) {
818
+ return attributeValue instanceof Y.Array;
819
+ }
820
+
821
+ if ( schema?.type === 'object' && schema.query ) {
822
+ return attributeValue instanceof Y.Map;
823
+ }
824
+
523
825
  return true;
524
826
  }
525
827
 
@@ -532,22 +834,8 @@ function isExpectedAttributeType(
532
834
  * @return True if the attribute is local, false otherwise.
533
835
  */
534
836
  function isLocalAttribute( blockName: string, attributeName: string ): boolean {
535
- return 'local' === getBlockAttributeType( blockName, attributeName )?.role;
536
- }
537
-
538
- /**
539
- * Given a block name and attribute key, return true if the attribute is rich-text typed.
540
- *
541
- * @param blockName The name of the block, e.g. 'core/paragraph'.
542
- * @param attributeName The name of the attribute to check, e.g. 'content'.
543
- * @return True if the attribute is rich-text typed, false otherwise.
544
- */
545
- function isRichTextAttribute(
546
- blockName: string,
547
- attributeName: string
548
- ): boolean {
549
837
  return (
550
- 'rich-text' === getBlockAttributeType( blockName, attributeName )?.type
838
+ 'local' === getBlockAttributeSchema( blockName, attributeName )?.role
551
839
  );
552
840
  }
553
841
 
@@ -0,0 +1,43 @@
1
+ /**
2
+ * WordPress dependencies
3
+ */
4
+ import { RichTextData } from '@wordpress/rich-text';
5
+
6
+ const RICH_TEXT_CACHE_MAX_SIZE = 500;
7
+
8
+ /**
9
+ * Returns a function that converts HTML strings to RichTextData instances,
10
+ * using a FIFO cache bounded by `maxSize` to avoid re-parsing identical
11
+ * strings. Repeated calls with the same string return the cached instance
12
+ * without re-running the HTML parser and DOM traversal.
13
+ *
14
+ * @param maxSize Maximum number of entries to hold in the cache.
15
+ * @return A cached version of RichTextData.fromHTMLString.
16
+ */
17
+ export function createRichTextDataCache(
18
+ maxSize: number
19
+ ): ( value: string ) => RichTextData {
20
+ const cache = new Map< string, RichTextData >();
21
+
22
+ return function ( value: string ): RichTextData {
23
+ const cached = cache.get( value );
24
+
25
+ if ( cached ) {
26
+ return cached;
27
+ }
28
+
29
+ const result = RichTextData.fromHTMLString( value );
30
+
31
+ if ( cache.size >= maxSize ) {
32
+ // Evict the oldest entry (Map preserves insertion order).
33
+ cache.delete( cache.keys().next().value! );
34
+ }
35
+
36
+ cache.set( value, result );
37
+ return result;
38
+ };
39
+ }
40
+
41
+ export const getCachedRichTextData = createRichTextDataCache(
42
+ RICH_TEXT_CACHE_MAX_SIZE
43
+ );
package/src/utils/crdt.ts CHANGED
@@ -11,6 +11,8 @@ import { __unstableSerializeAndClean } from '@wordpress/blocks';
11
11
  import {
12
12
  type CRDTDoc,
13
13
  type ObjectData,
14
+ type ObjectID,
15
+ type ObjectType,
14
16
  type SyncConfig,
15
17
  Y,
16
18
  } from '@wordpress/sync';
@@ -20,6 +22,7 @@ import {
20
22
  */
21
23
  import { BaseAwareness } from '../awareness/base-awareness';
22
24
  import {
25
+ deserializeBlockAttributes,
23
26
  mergeCrdtBlocks,
24
27
  mergeRichTextUpdate,
25
28
  type Block,
@@ -382,6 +385,15 @@ export function getPostChangesFromCRDTDoc(
382
385
  } )
383
386
  );
384
387
 
388
+ // Blocks extracted from the CRDT document have rich-text attributes as
389
+ // plain strings (from Y.Text.toJSON()). Convert them back to RichTextData
390
+ // so block edit components receive the same types as locally-created blocks.
391
+ if ( changes.blocks ) {
392
+ changes.blocks = deserializeBlockAttributes(
393
+ changes.blocks as Block[]
394
+ );
395
+ }
396
+
385
397
  // Meta changes must be merged with the edited record since not all meta
386
398
  // properties are synced.
387
399
  if ( 'object' === typeof changes.meta ) {
@@ -418,6 +430,19 @@ export const defaultSyncConfig: SyncConfig = {
418
430
  getChangesFromCRDTDoc: defaultGetChangesFromCRDTDoc,
419
431
  };
420
432
 
433
+ /**
434
+ * This default collection sync config can be used to sync entity collections
435
+ * (e.g., block comments) where we are not interested in merging changes at the
436
+ * individual record level, but instead want to replace the entire collection
437
+ * when changes are detected.
438
+ */
439
+ export const defaultCollectionSyncConfig: SyncConfig = {
440
+ applyChangesToCRDTDoc: () => {},
441
+ getChangesFromCRDTDoc: () => ( {} ),
442
+ shouldSync: ( _: ObjectType, objectId: ObjectID | null ) =>
443
+ null === objectId,
444
+ };
445
+
421
446
  /**
422
447
  * Extract the raw string value from a property that may be a string or an object
423
448
  * with a `raw` property (`RenderedText`).
@@ -5,7 +5,6 @@ export { default as forwardResolver } from './forward-resolver';
5
5
  export { default as onSubKey } from './on-sub-key';
6
6
  export { default as replaceAction } from './replace-action';
7
7
  export { default as withWeakMapCache } from './with-weak-map-cache';
8
- export { default as isRawAttribute } from './is-raw-attribute';
9
8
  export { default as setNestedValue } from './set-nested-value';
10
9
  export { default as getNestedValue } from './get-nested-value';
11
10
  export { default as isNumericID } from './is-numeric-id';