@wordpress/core-data 7.37.1-next.v.0 → 7.38.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 (55) hide show
  1. package/CHANGELOG.md +2 -0
  2. package/build/actions.cjs +14 -2
  3. package/build/actions.cjs.map +2 -2
  4. package/build/entities.cjs +35 -45
  5. package/build/entities.cjs.map +2 -2
  6. package/build/private-selectors.cjs +2 -4
  7. package/build/private-selectors.cjs.map +2 -2
  8. package/build/resolvers.cjs +11 -2
  9. package/build/resolvers.cjs.map +2 -2
  10. package/build/types.cjs.map +1 -1
  11. package/build/utils/crdt-blocks.cjs +63 -41
  12. package/build/utils/crdt-blocks.cjs.map +2 -2
  13. package/build/utils/crdt-utils.cjs +44 -0
  14. package/build/utils/crdt-utils.cjs.map +7 -0
  15. package/build/utils/crdt.cjs +33 -37
  16. package/build/utils/crdt.cjs.map +2 -2
  17. package/build-module/actions.mjs +14 -2
  18. package/build-module/actions.mjs.map +2 -2
  19. package/build-module/entities.mjs +35 -47
  20. package/build-module/entities.mjs.map +2 -2
  21. package/build-module/private-selectors.mjs +2 -4
  22. package/build-module/private-selectors.mjs.map +2 -2
  23. package/build-module/resolvers.mjs +11 -2
  24. package/build-module/resolvers.mjs.map +2 -2
  25. package/build-module/utils/crdt-blocks.mjs +64 -42
  26. package/build-module/utils/crdt-blocks.mjs.map +2 -2
  27. package/build-module/utils/crdt-utils.mjs +17 -0
  28. package/build-module/utils/crdt-utils.mjs.map +7 -0
  29. package/build-module/utils/crdt.mjs +37 -37
  30. package/build-module/utils/crdt.mjs.map +2 -2
  31. package/build-types/actions.d.ts.map +1 -1
  32. package/build-types/entities.d.ts.map +1 -1
  33. package/build-types/index.d.ts.map +1 -1
  34. package/build-types/private-selectors.d.ts.map +1 -1
  35. package/build-types/resolvers.d.ts.map +1 -1
  36. package/build-types/types.d.ts +0 -5
  37. package/build-types/types.d.ts.map +1 -1
  38. package/build-types/utils/crdt-blocks.d.ts +14 -5
  39. package/build-types/utils/crdt-blocks.d.ts.map +1 -1
  40. package/build-types/utils/crdt-utils.d.ts +53 -0
  41. package/build-types/utils/crdt-utils.d.ts.map +1 -0
  42. package/build-types/utils/crdt.d.ts +19 -1
  43. package/build-types/utils/crdt.d.ts.map +1 -1
  44. package/package.json +18 -18
  45. package/src/actions.js +13 -2
  46. package/src/entities.js +38 -54
  47. package/src/private-selectors.ts +3 -4
  48. package/src/resolvers.js +15 -7
  49. package/src/test/entities.js +11 -9
  50. package/src/test/resolvers.js +3 -45
  51. package/src/types.ts +0 -6
  52. package/src/utils/crdt-blocks.ts +101 -99
  53. package/src/utils/crdt-utils.ts +77 -0
  54. package/src/utils/crdt.ts +76 -57
  55. package/src/utils/test/crdt.ts +28 -16
@@ -7,16 +7,15 @@ import fastDeepEqual from 'fast-deep-equal/es6/index.js';
7
7
  /**
8
8
  * WordPress dependencies
9
9
  */
10
- import { RichTextData } from '@wordpress/rich-text';
11
- import { Y } from '@wordpress/sync';
12
-
13
10
  // @ts-expect-error No exported types.
14
11
  import { getBlockTypes } from '@wordpress/blocks';
12
+ import { RichTextData } from '@wordpress/rich-text';
13
+ import { Y, Delta } from '@wordpress/sync';
15
14
 
16
15
  /**
17
16
  * Internal dependencies
18
17
  */
19
- import type { WPBlockSelection } from '../types';
18
+ import { createYMap, type YMapRecord, type YMapWrap } from './crdt-utils';
20
19
 
21
20
  interface BlockAttributes {
22
21
  [ key: string ]: unknown;
@@ -27,34 +26,33 @@ interface BlockType {
27
26
  attributes?: Record< string, { type?: string } >;
28
27
  }
29
28
 
29
+ // A block as represented in Gutenberg's data store.
30
30
  export interface Block {
31
31
  attributes: BlockAttributes;
32
32
  clientId?: string;
33
33
  innerBlocks: Block[];
34
- originalContent?: string; // unserializable
35
- validationIssues?: string[]; // unserializable
34
+ isValid?: boolean;
36
35
  name: string;
36
+ originalContent?: string;
37
+ validationIssues?: string[]; // unserializable
37
38
  }
38
39
 
39
- export type YBlock = Y.Map<
40
- /* name, clientId, and originalContent are strings. */
41
- | string
42
- /* validationIssues? is an array of strings. */
43
- | string[]
44
- /* attributes is a Y.Map< unknown >. */
45
- | YBlockAttributes
46
- /* innerBlocks is a Y.Array< YBlock >. */
47
- | YBlocks
48
- >;
40
+ // A block as represented in the CRDT document (Y.Map).
41
+ interface YBlockRecord extends YMapRecord {
42
+ attributes: YBlockAttributes;
43
+ clientId: string;
44
+ innerBlocks: YBlocks;
45
+ isValid?: boolean;
46
+ originalContent?: string;
47
+ name: string;
48
+ }
49
49
 
50
+ export type YBlock = YMapWrap< YBlockRecord >;
50
51
  export type YBlocks = Y.Array< YBlock >;
51
- export type YBlockAttributes = Y.Map< Y.Text | unknown >;
52
52
 
53
- // The Y.Map type is not easy to work with. The generic type it accepts represents
54
- // the possible values of the map, which are varied in our case. This type is
55
- // accurate, but will require aggressive type narrowing when the map values are
56
- // accessed -- or type casting with `as`.
57
- // export type YBlock = Y.Map< Block[ keyof Block ] >;
53
+ // Block attribute schema cannot be known at compile time, so we use Y.Map.
54
+ // Attribute values will be typed as the union of `Y.Text` and `unknown`.
55
+ export type YBlockAttributes = Y.Map< Y.Text | unknown >;
58
56
 
59
57
  const serializableBlocksCache = new WeakMap< WeakKey, Block[] >();
60
58
 
@@ -70,13 +68,10 @@ function makeBlockAttributesSerializable(
70
68
  return newAttributes;
71
69
  }
72
70
 
73
- function makeBlocksSerializable( blocks: Block[] | YBlocks ): Block[] {
74
- return blocks.map( ( block: Block | YBlock ) => {
75
- const blockAsJson = block instanceof Y.Map ? block.toJSON() : block;
76
- const { name, innerBlocks, attributes, ...rest } = blockAsJson;
71
+ function makeBlocksSerializable( blocks: Block[] ): Block[] {
72
+ return blocks.map( ( block: Block ) => {
73
+ const { name, innerBlocks, attributes, ...rest } = block;
77
74
  delete rest.validationIssues;
78
- delete rest.originalContent;
79
- // delete rest.isValid
80
75
  return {
81
76
  ...rest,
82
77
  name,
@@ -104,10 +99,10 @@ function areBlocksEqual( gblock: Block, yblock: YBlock ): boolean {
104
99
  Object.assign( {}, yblockAsJson, overwrites )
105
100
  );
106
101
  const inners = gblock.innerBlocks || [];
107
- const yinners = yblock.get( 'innerBlocks' ) as YBlocks;
102
+ const yinners = yblock.get( 'innerBlocks' );
108
103
  return (
109
104
  res &&
110
- inners.length === yinners.length &&
105
+ inners.length === yinners?.length &&
111
106
  inners.every( ( block: Block, i: number ) =>
112
107
  areBlocksEqual( block, yinners.get( i ) )
113
108
  )
@@ -149,35 +144,40 @@ function createNewYAttributeValue(
149
144
  }
150
145
 
151
146
  function createNewYBlock( block: Block ): YBlock {
152
- return new Y.Map(
153
- Object.entries( block ).map( ( [ key, value ] ) => {
154
- switch ( key ) {
155
- case 'attributes': {
156
- return [ key, createNewYAttributeMap( block.name, value ) ];
157
- }
147
+ return createYMap< YBlockRecord >(
148
+ Object.fromEntries(
149
+ Object.entries( block ).map( ( [ key, value ] ) => {
150
+ switch ( key ) {
151
+ case 'attributes': {
152
+ return [
153
+ key,
154
+ createNewYAttributeMap( block.name, value ),
155
+ ];
156
+ }
158
157
 
159
- case 'innerBlocks': {
160
- const innerBlocks = new Y.Array();
158
+ case 'innerBlocks': {
159
+ const innerBlocks = new Y.Array();
160
+
161
+ // If not an array, set to empty Y.Array.
162
+ if ( ! Array.isArray( value ) ) {
163
+ return [ key, innerBlocks ];
164
+ }
165
+
166
+ innerBlocks.insert(
167
+ 0,
168
+ value.map( ( innerBlock: Block ) =>
169
+ createNewYBlock( innerBlock )
170
+ )
171
+ );
161
172
 
162
- // If not an array, set to empty Y.Array.
163
- if ( ! Array.isArray( value ) ) {
164
173
  return [ key, innerBlocks ];
165
174
  }
166
175
 
167
- innerBlocks.insert(
168
- 0,
169
- value.map( ( innerBlock: Block ) =>
170
- createNewYBlock( innerBlock )
171
- )
172
- );
173
-
174
- return [ key, innerBlocks ];
176
+ default:
177
+ return [ key, value ];
175
178
  }
176
-
177
- default:
178
- return [ key, value ];
179
- }
180
- } )
179
+ } )
180
+ )
181
181
  );
182
182
  }
183
183
 
@@ -186,13 +186,13 @@ function createNewYBlock( block: Block ): YBlock {
186
186
  * This function is called to sync local block changes to a shared Y.Doc.
187
187
  *
188
188
  * @param yblocks The blocks in the local Y.Doc.
189
- * @param incomingBlocks Gutenberg blocks being synced, either from a peer or from the local editor.
190
- * @param lastSelection The last cursor position, used for hinting the diff algorithm.
189
+ * @param incomingBlocks Gutenberg blocks being synced.
190
+ * @param cursorPosition The position of the cursor after the change occurs.
191
191
  */
192
192
  export function mergeCrdtBlocks(
193
193
  yblocks: YBlocks,
194
194
  incomingBlocks: Block[],
195
- lastSelection: WPBlockSelection | null
195
+ cursorPosition: number | null
196
196
  ): void {
197
197
  // Ensure we are working with serializable block data.
198
198
  if ( ! serializableBlocksCache.has( incomingBlocks ) ) {
@@ -269,9 +269,7 @@ export function mergeCrdtBlocks(
269
269
  Object.entries( block ).forEach( ( [ key, value ] ) => {
270
270
  switch ( key ) {
271
271
  case 'attributes': {
272
- const currentAttributes = yblock.get(
273
- key
274
- ) as YBlockAttributes;
272
+ const currentAttributes = yblock.get( key );
275
273
 
276
274
  // If attributes are not set on the yblock, use the new values.
277
275
  if ( ! currentAttributes ) {
@@ -293,6 +291,8 @@ export function mergeCrdtBlocks(
293
291
  return;
294
292
  }
295
293
 
294
+ const currentAttribute =
295
+ currentAttributes.get( attributeName );
296
296
  const isRichText = isRichTextAttribute(
297
297
  block.name,
298
298
  attributeName
@@ -302,18 +302,14 @@ export function mergeCrdtBlocks(
302
302
  isRichText &&
303
303
  'string' === typeof attributeValue &&
304
304
  currentAttributes.has( attributeName ) &&
305
- currentAttributes.get(
306
- attributeName
307
- ) instanceof Y.Text
305
+ currentAttribute instanceof Y.Text
308
306
  ) {
309
307
  // Rich text values are stored as persistent Y.Text instances.
310
308
  // Update the value with a delta in place.
311
309
  mergeRichTextUpdate(
312
- currentAttributes.get(
313
- attributeName
314
- ) as Y.Text,
310
+ currentAttribute,
315
311
  attributeValue,
316
- lastSelection
312
+ cursorPosition
317
313
  );
318
314
  } else {
319
315
  currentAttributes.set(
@@ -342,8 +338,18 @@ export function mergeCrdtBlocks(
342
338
 
343
339
  case 'innerBlocks': {
344
340
  // Recursively merge innerBlocks
345
- const yInnerBlocks = yblock.get( key ) as Y.Array< YBlock >;
346
- mergeCrdtBlocks( yInnerBlocks, value ?? [], lastSelection );
341
+ let yInnerBlocks = yblock.get( key );
342
+
343
+ if ( ! ( yInnerBlocks instanceof Y.Array ) ) {
344
+ yInnerBlocks = new Y.Array< YBlock >();
345
+ yblock.set( key, yInnerBlocks );
346
+ }
347
+
348
+ mergeCrdtBlocks(
349
+ yInnerBlocks,
350
+ value ?? [],
351
+ cursorPosition
352
+ );
347
353
  break;
348
354
  }
349
355
 
@@ -375,7 +381,11 @@ export function mergeCrdtBlocks(
375
381
  for ( let j = 0; j < yblocks.length; j++ ) {
376
382
  const yblock: YBlock = yblocks.get( j );
377
383
 
378
- let clientId: string = yblock.get( 'clientId' ) as string;
384
+ let clientId = yblock.get( 'clientId' );
385
+
386
+ if ( ! clientId ) {
387
+ continue;
388
+ }
379
389
 
380
390
  if ( knownClientIds.has( clientId ) ) {
381
391
  clientId = uuidv4();
@@ -452,22 +462,21 @@ function isRichTextAttribute(
452
462
  );
453
463
  }
454
464
 
465
+ let localDoc: Y.Doc;
466
+
455
467
  /**
456
468
  * Given a Y.Text object and an updated string value, diff the new value and
457
469
  * apply the delta to the Y.Text.
458
470
  *
459
- * @param blockYText The Y.Text to update.
460
- * @param updatedValue The updated value.
461
- * @param lastSelection The last cursor position before this update, used to hint the diff algorithm.
471
+ * @param blockYText The Y.Text to update.
472
+ * @param updatedValue The updated value.
473
+ * @param cursorPosition The position of the cursor after the change occurs.
462
474
  */
463
475
  function mergeRichTextUpdate(
464
476
  blockYText: Y.Text,
465
477
  updatedValue: string,
466
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
467
- lastSelection: WPBlockSelection | null
478
+ cursorPosition: number | null
468
479
  ): void {
469
- // TODO
470
- // ====
471
480
  // Gutenberg does not use Yjs shared types natively, so we can only subscribe
472
481
  // to changes from store and apply them to Yjs types that we create and
473
482
  // manage. Crucially, for rich-text attributes, we do not receive granular
@@ -475,31 +484,24 @@ function mergeRichTextUpdate(
475
484
  // only a single character changed.
476
485
  //
477
486
  // The code below allows us to compute a delta between the current and new
478
- // value, then apply it to the Y.Text. However, it relies on a library
479
- // (quill-delta) with a licensing issue that we are working to resolve.
480
- //
481
- // For now, we simply replace the full text content on each change.
482
- //
483
- // if ( ! localDoc ) {
484
- // // Y.Text must be attached to a Y.Doc to be able to do operations on it.
485
- // // Create a temporary Y.Text attached to a local Y.Doc for delta computation.
486
- // localDoc = new Y.Doc();
487
- // }
488
-
489
- // const localYText = localDoc.getText( 'temporary-text' );
490
- // localYText.delete( 0, localYText.length );
491
- // localYText.insert( 0, updatedValue );
487
+ // value, then apply it to the Y.Text.
492
488
 
493
- // const currentValueAsDelta = new Delta( blockYText.toDelta() );
494
- // const updatedValueAsDelta = new Delta( localYText.toDelta() );
489
+ if ( ! localDoc ) {
490
+ // Y.Text must be attached to a Y.Doc to be able to do operations on it.
491
+ // Create a temporary Y.Text attached to a local Y.Doc for delta computation.
492
+ localDoc = new Y.Doc();
493
+ }
495
494
 
496
- // const deltaDiff = currentValueAsDelta.diff(
497
- // updatedValueAsDelta,
498
- // lastSelection?.offset
499
- // );
495
+ const localYText = localDoc.getText( 'temporary-text' );
496
+ localYText.delete( 0, localYText.length );
497
+ localYText.insert( 0, updatedValue );
500
498
 
501
- // blockYText.applyDelta( deltaDiff.ops );
499
+ const currentValueAsDelta = new Delta( blockYText.toDelta() );
500
+ const updatedValueAsDelta = new Delta( localYText.toDelta() );
501
+ const deltaDiff = currentValueAsDelta.diffWithCursor(
502
+ updatedValueAsDelta,
503
+ cursorPosition
504
+ );
502
505
 
503
- blockYText.delete( 0, blockYText.toString().length );
504
- blockYText.insert( 0, updatedValue );
506
+ blockYText.applyDelta( deltaDiff.ops );
505
507
  }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * WordPress dependencies
3
+ */
4
+ import { Y } from '@wordpress/sync';
5
+
6
+ /**
7
+ * A YMapRecord represents the shape of the data stored in a Y.Map.
8
+ */
9
+ export type YMapRecord = Record< string, unknown >;
10
+
11
+ /**
12
+ * A wrapper around Y.Map to provide type safety. The generic type accepted by
13
+ * Y.Map represents the union of possible values of the map, which are varied in
14
+ * many cases. This type is accurate, but its non-specificity requires aggressive
15
+ * type narrowing or type casting / destruction with `as`.
16
+ *
17
+ * This type provides type enhancements so that the correct value type can be
18
+ * inferred based on the provided key. It is just a type wrap / overlay, and
19
+ * does not change the runtime behavior of Y.Map.
20
+ *
21
+ * This interface cannot extend Y.Map directly due to the limitations of
22
+ * TypeScript's structural typing. One negative consequence of this is that
23
+ * `instanceof` checks against Y.Map continue to work at runtime but will blur
24
+ * the type at compile time. To navigate this, use the `isYMap` function below.
25
+ */
26
+ export interface YMapWrap< T extends YMapRecord > extends Y.AbstractType< T > {
27
+ delete: < K extends keyof T >( key: K ) => void;
28
+ forEach: (
29
+ callback: (
30
+ value: T[ keyof T ],
31
+ key: keyof T,
32
+ map: YMapWrap< T >
33
+ ) => void
34
+ ) => void;
35
+ has: < K extends keyof T >( key: K ) => boolean;
36
+ get: < K extends keyof T >( key: K ) => T[ K ] | undefined;
37
+ set: < K extends keyof T >( key: K, value: T[ K ] ) => void;
38
+ toJSON: () => T;
39
+ // add types for other Y.Map methods as needed
40
+ }
41
+
42
+ /**
43
+ * Get or create a root-level Map for the given Y.Doc. Use this instead of
44
+ * doc.getMap() for additional type safety.
45
+ *
46
+ * @param doc Y.Doc
47
+ * @param key Map key
48
+ */
49
+ export function getRootMap< T extends YMapRecord >(
50
+ doc: Y.Doc,
51
+ key: string
52
+ ): YMapWrap< T > {
53
+ return doc.getMap< T >( key ) as unknown as YMapWrap< T >;
54
+ }
55
+
56
+ /**
57
+ * Create a new Y.Map (provided with YMapWrap type), optionally initialized with
58
+ * data. Use this instead of `new Y.Map()` for additional type safety.
59
+ *
60
+ * @param partial Partial data to initialize the map with.
61
+ */
62
+ export function createYMap< T extends YMapRecord >(
63
+ partial: Partial< T > = {}
64
+ ): YMapWrap< T > {
65
+ return new Y.Map( Object.entries( partial ) ) as unknown as YMapWrap< T >;
66
+ }
67
+
68
+ /**
69
+ * Type guard to check if a value is a Y.Map without losing type information.
70
+ *
71
+ * @param value Value to check.
72
+ */
73
+ export function isYMap< T extends YMapRecord >(
74
+ value: YMapWrap< T > | undefined
75
+ ): value is YMapWrap< T > {
76
+ return value instanceof Y.Map;
77
+ }
package/src/utils/crdt.ts CHANGED
@@ -26,8 +26,16 @@ import {
26
26
  CRDT_RECORD_MAP_KEY,
27
27
  WORDPRESS_META_KEY_FOR_CRDT_DOC_PERSISTENCE,
28
28
  } from '../sync';
29
- import type { WPBlockSelection, WPSelection } from '../types';
30
-
29
+ import type { WPSelection } from '../types';
30
+ import {
31
+ createYMap,
32
+ getRootMap,
33
+ isYMap,
34
+ type YMapRecord,
35
+ type YMapWrap,
36
+ } from './crdt-utils';
37
+
38
+ // Changes that can be applied to a post entity record.
31
39
  export type PostChanges = Partial< Post > & {
32
40
  blocks?: Block[];
33
41
  excerpt?: Post[ 'excerpt' ] | string;
@@ -35,8 +43,24 @@ export type PostChanges = Partial< Post > & {
35
43
  title?: Post[ 'title' ] | string;
36
44
  };
37
45
 
38
- // Hold a reference to the last known selection to help compute Y.Text deltas.
39
- let lastSelection: WPBlockSelection | null = null;
46
+ // A post record as represented in the CRDT document (Y.Map).
47
+ export interface YPostRecord extends YMapRecord {
48
+ author: number;
49
+ blocks: YBlocks;
50
+ comment_status: string;
51
+ date: string | null;
52
+ excerpt: string;
53
+ featured_media: number;
54
+ format: string;
55
+ meta: YMapWrap< YMapRecord >;
56
+ ping_status: string;
57
+ slug: string;
58
+ status: string;
59
+ sticky: boolean;
60
+ tags: number[];
61
+ template: string;
62
+ title: string;
63
+ }
40
64
 
41
65
  // Properties that are allowed to be synced for a post.
42
66
  const allowedPostProperties = new Set< string >( [
@@ -47,8 +71,8 @@ const allowedPostProperties = new Set< string >( [
47
71
  'excerpt',
48
72
  'featured_media',
49
73
  'format',
50
- 'ping_status',
51
74
  'meta',
75
+ 'ping_status',
52
76
  'slug',
53
77
  'status',
54
78
  'sticky',
@@ -74,7 +98,7 @@ export function defaultApplyChangesToCRDTDoc(
74
98
  ydoc: CRDTDoc,
75
99
  changes: ObjectData
76
100
  ): void {
77
- const ymap = ydoc.getMap( CRDT_RECORD_MAP_KEY );
101
+ const ymap = getRootMap( ydoc, CRDT_RECORD_MAP_KEY );
78
102
 
79
103
  Object.entries( changes ).forEach( ( [ key, newValue ] ) => {
80
104
  // Cannot serialize function values, so cannot sync them.
@@ -82,17 +106,12 @@ export function defaultApplyChangesToCRDTDoc(
82
106
  return;
83
107
  }
84
108
 
85
- // Set the value in the root document.
86
- function setValue< T = unknown >( updatedValue: T ): void {
87
- ymap.set( key, updatedValue );
88
- }
89
-
90
109
  switch ( key ) {
91
110
  // Add support for additional data types here.
92
111
 
93
112
  default: {
94
113
  const currentValue = ymap.get( key );
95
- mergeValue( currentValue, newValue, setValue );
114
+ updateMapValue( ymap, key, currentValue, newValue );
96
115
  }
97
116
  }
98
117
  } );
@@ -112,60 +131,60 @@ export function applyPostChangesToCRDTDoc(
112
131
  changes: PostChanges,
113
132
  _postType: Type // eslint-disable-line @typescript-eslint/no-unused-vars
114
133
  ): void {
115
- const ymap = ydoc.getMap( CRDT_RECORD_MAP_KEY );
134
+ const ymap = getRootMap< YPostRecord >( ydoc, CRDT_RECORD_MAP_KEY );
116
135
 
117
- Object.entries( changes ).forEach( ( [ key, newValue ] ) => {
136
+ Object.keys( changes ).forEach( ( key ) => {
118
137
  if ( ! allowedPostProperties.has( key ) ) {
119
138
  return;
120
139
  }
121
140
 
141
+ const newValue = changes[ key ];
142
+
122
143
  // Cannot serialize function values, so cannot sync them.
123
144
  if ( 'function' === typeof newValue ) {
124
145
  return;
125
146
  }
126
147
 
127
- // Set the value in the root document.
128
- function setValue< T = unknown >( updatedValue: T ): void {
129
- ymap.set( key, updatedValue );
130
- }
131
-
132
148
  switch ( key ) {
133
149
  case 'blocks': {
134
- let currentBlocks = ymap.get( 'blocks' ) as YBlocks;
150
+ let currentBlocks = ymap.get( key );
135
151
 
136
152
  // Initialize.
137
153
  if ( ! ( currentBlocks instanceof Y.Array ) ) {
138
154
  currentBlocks = new Y.Array< YBlock >();
139
- setValue( currentBlocks );
155
+ ymap.set( key, currentBlocks );
140
156
  }
141
157
 
142
158
  // Block[] from local changes.
143
159
  const newBlocks = ( newValue as PostChanges[ 'blocks' ] ) ?? [];
144
160
 
161
+ // Block changes from typing are bundled with a 'selection' update.
162
+ // Pass the resulting cursor position to the mergeCrdtBlocks function.
163
+ const cursorPosition =
164
+ changes.selection?.selectionStart?.offset ?? null;
165
+
145
166
  // Merge blocks does not need `setValue` because it is operating on a
146
167
  // Yjs type that is already in the Y.Doc.
147
- mergeCrdtBlocks( currentBlocks, newBlocks, lastSelection );
168
+ mergeCrdtBlocks( currentBlocks, newBlocks, cursorPosition );
148
169
  break;
149
170
  }
150
171
 
151
172
  case 'excerpt': {
152
- const currentValue = ymap.get( 'excerpt' ) as
153
- | string
154
- | undefined;
173
+ const currentValue = ymap.get( 'excerpt' );
155
174
  const rawNewValue = getRawValue( newValue );
156
175
 
157
- mergeValue( currentValue, rawNewValue, setValue );
176
+ updateMapValue( ymap, key, currentValue, rawNewValue );
158
177
  break;
159
178
  }
160
179
 
161
180
  // "Meta" is overloaded term; here, it refers to post meta.
162
181
  case 'meta': {
163
- let metaMap = ymap.get( 'meta' ) as Y.Map< unknown >;
182
+ let metaMap = ymap.get( 'meta' );
164
183
 
165
184
  // Initialize.
166
- if ( ! ( metaMap instanceof Y.Map ) ) {
167
- metaMap = new Y.Map();
168
- setValue( metaMap );
185
+ if ( ! isYMap( metaMap ) ) {
186
+ metaMap = createYMap< YMapRecord >();
187
+ ymap.set( 'meta', metaMap );
169
188
  }
170
189
 
171
190
  // Iterate over each meta property in the new value and merge it if it
@@ -176,12 +195,11 @@ export function applyPostChangesToCRDTDoc(
176
195
  return;
177
196
  }
178
197
 
179
- mergeValue(
198
+ updateMapValue(
199
+ metaMap,
200
+ metaKey,
180
201
  metaMap.get( metaKey ), // current value in CRDT
181
- metaValue, // new value from changes
182
- ( updatedMetaValue: unknown ): void => {
183
- metaMap.set( metaKey, updatedMetaValue );
184
- }
202
+ metaValue // new value from changes
185
203
  );
186
204
  }
187
205
  );
@@ -195,13 +213,13 @@ export function applyPostChangesToCRDTDoc(
195
213
  break;
196
214
  }
197
215
 
198
- const currentValue = ymap.get( 'slug' ) as string;
199
- mergeValue( currentValue, newValue, setValue );
216
+ const currentValue = ymap.get( key );
217
+ updateMapValue( ymap, key, currentValue, newValue );
200
218
  break;
201
219
  }
202
220
 
203
221
  case 'title': {
204
- const currentValue = ymap.get( 'title' ) as string | undefined;
222
+ const currentValue = ymap.get( key );
205
223
 
206
224
  // Copy logic from prePersistPostType to ensure that the "Auto
207
225
  // Draft" template title is not synced.
@@ -210,27 +228,22 @@ export function applyPostChangesToCRDTDoc(
210
228
  rawNewValue = '';
211
229
  }
212
230
 
213
- mergeValue( currentValue, rawNewValue, setValue );
231
+ updateMapValue( ymap, key, currentValue, rawNewValue );
214
232
  break;
215
233
  }
216
234
 
217
- // Add support for additional data types here.
235
+ // Add support for additional properties here.
218
236
 
219
237
  default: {
220
238
  const currentValue = ymap.get( key );
221
- mergeValue( currentValue, newValue, setValue );
239
+ updateMapValue( ymap, key, currentValue, newValue );
222
240
  }
223
241
  }
224
242
  } );
225
-
226
- // Update the lastSelection for use in computing Y.Text deltas.
227
- if ( 'selection' in changes ) {
228
- lastSelection = changes.selection?.selectionStart ?? null;
229
- }
230
243
  }
231
244
 
232
245
  export function defaultGetChangesFromCRDTDoc( crdtDoc: CRDTDoc ): ObjectData {
233
- return crdtDoc.getMap( CRDT_RECORD_MAP_KEY ).toJSON();
246
+ return getRootMap( crdtDoc, CRDT_RECORD_MAP_KEY ).toJSON();
234
247
  }
235
248
 
236
249
  /**
@@ -248,7 +261,7 @@ export function getPostChangesFromCRDTDoc(
248
261
  editedRecord: Post,
249
262
  _postType: Type // eslint-disable-line @typescript-eslint/no-unused-vars
250
263
  ): PostChanges {
251
- const ymap = ydoc.getMap( CRDT_RECORD_MAP_KEY );
264
+ const ymap = getRootMap< YPostRecord >( ydoc, CRDT_RECORD_MAP_KEY );
252
265
 
253
266
  let allowedMetaChanges: Post[ 'meta' ] = {};
254
267
 
@@ -394,19 +407,25 @@ function getRawValue( value?: unknown ): string | undefined {
394
407
  return undefined;
395
408
  }
396
409
 
397
- function haveValuesChanged< ValueType = any >(
398
- currentValue: ValueType,
399
- newValue: ValueType
410
+ function haveValuesChanged< ValueType >(
411
+ currentValue: ValueType | undefined,
412
+ newValue: ValueType | undefined
400
413
  ): boolean {
401
414
  return ! fastDeepEqual( currentValue, newValue );
402
415
  }
403
416
 
404
- function mergeValue< ValueType = any >(
405
- currentValue: ValueType,
406
- newValue: ValueType,
407
- setValue: ( value: ValueType ) => void
417
+ function updateMapValue< T extends YMapRecord, K extends keyof T >(
418
+ map: YMapWrap< T >,
419
+ key: K,
420
+ currentValue: T[ K ] | undefined,
421
+ newValue: T[ K ] | undefined
408
422
  ): void {
409
- if ( haveValuesChanged< ValueType >( currentValue, newValue ) ) {
410
- setValue( newValue );
423
+ if ( undefined === newValue ) {
424
+ map.delete( key );
425
+ return;
426
+ }
427
+
428
+ if ( haveValuesChanged< T[ K ] >( currentValue, newValue ) ) {
429
+ map.set( key, newValue );
411
430
  }
412
431
  }