@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
@@ -127,6 +127,63 @@ describe( 'useResourcePermissions', () => {
127
127
  );
128
128
  } );
129
129
 
130
+ it( 'normalizes id-less entity resources before resolving permissions', async () => {
131
+ let data;
132
+ triggerFetch.mockImplementation( ( options ) => {
133
+ if ( options.path === '/wp/v2/types?context=view' ) {
134
+ return {
135
+ wp_navigation: {
136
+ name: 'Navigation Menus',
137
+ slug: 'wp_navigation',
138
+ rest_base: 'navigation',
139
+ rest_namespace: 'wp/v2',
140
+ },
141
+ };
142
+ }
143
+ if (
144
+ options.path === '/wp/v2/navigation' &&
145
+ options.method === 'OPTIONS'
146
+ ) {
147
+ return {
148
+ headers: new Headers( { allow: 'GET, POST' } ),
149
+ };
150
+ }
151
+ throw new Error(
152
+ `Unexpected request: ${ JSON.stringify( options ) }`
153
+ );
154
+ } );
155
+
156
+ const TestComponent = () => {
157
+ data = useResourcePermissions( {
158
+ kind: 'postType',
159
+ name: 'wp_navigation',
160
+ id: undefined,
161
+ } );
162
+ return <div />;
163
+ };
164
+ render(
165
+ <RegistryProvider value={ registry }>
166
+ <TestComponent />
167
+ </RegistryProvider>
168
+ );
169
+
170
+ await waitFor( () =>
171
+ expect( data ).toEqual( {
172
+ status: 'SUCCESS',
173
+ isResolving: false,
174
+ hasResolved: true,
175
+ canCreate: true,
176
+ canRead: true,
177
+ } )
178
+ );
179
+
180
+ expect(
181
+ triggerFetch.mock.calls.filter(
182
+ ( [ options ] ) => options.path === '/wp/v2/navigation'
183
+ )
184
+ ).toHaveLength( 1 );
185
+ } );
186
+
130
187
  it( 'retrieves the relevant permissions for an entity', async () => {
131
188
  let data;
132
189
  const TestComponent = () => {
@@ -11,9 +11,9 @@ import { parse, __unstableSerializeAndClean } from '@wordpress/blocks';
11
11
  import { STORE_NAME } from '../name';
12
12
  import useEntityId from './use-entity-id';
13
13
  import { updateFootnotesFromMeta } from '../footnotes';
14
+ import { parsedBlocksCache, getCacheKey } from '../parsed-blocks-cache';
14
15
 
15
16
  const EMPTY_ARRAY = [];
16
- const parsedBlocksCache = new Map();
17
17
 
18
18
  /**
19
19
  * Hook that returns block content getters and setters for
@@ -69,7 +69,7 @@ export default function useEntityBlockEditor( kind, name, { id: _id } = {} ) {
69
69
 
70
70
  // Cache parsed blocks by entity identity. Store the content
71
71
  // alongside the blocks so we can validate it hasn't changed.
72
- const cacheKey = `${ kind }:${ name }:${ id }`;
72
+ const cacheKey = getCacheKey( kind, name, id );
73
73
  const cached = parsedBlocksCache.get( cacheKey );
74
74
  let _blocks;
75
75
 
@@ -27,6 +27,7 @@ interface AwarenessState {
27
27
  const defaultResolvedSelection: ResolvedSelection = {
28
28
  richTextOffset: null,
29
29
  localClientId: null,
30
+ attributeKey: null,
30
31
  };
31
32
 
32
33
  const defaultState: AwarenessState = {
@@ -145,15 +145,13 @@ function useResourcePermissions< IdType = void >(
145
145
  ( resolve ) => {
146
146
  const hasId = isEntity ? !! resource.id : !! id;
147
147
  const { canUser } = resolve( coreStore );
148
- const create = canUser(
149
- 'create',
150
- isEntity
151
- ? { kind: resource.kind, name: resource.name }
152
- : resource
153
- );
148
+ const collectionResource = isEntity
149
+ ? { kind: resource.kind, name: resource.name }
150
+ : resource;
151
+ const create = canUser( 'create', collectionResource );
154
152
 
155
153
  if ( ! hasId ) {
156
- const read = canUser( 'read', resource );
154
+ const read = canUser( 'read', collectionResource );
157
155
 
158
156
  const isResolving = create.isResolving || read.isResolving;
159
157
  const hasResolved = create.hasResolved && read.hasResolved;
package/src/index.js CHANGED
@@ -133,13 +133,6 @@ unlock( store ).registerPrivateSelectors( privateSelectors );
133
133
  unlock( store ).registerPrivateActions( privateActions );
134
134
  register( store ); // Register store after unlocking private selectors to allow resolvers to use them.
135
135
 
136
- /**
137
- * Enums cannot be exported private without losing the ability to narrow types
138
- * based on their values (they blur to string type).
139
- */
140
- export { SelectionType } from './utils/crdt-user-selections';
141
- export { SelectionDirection } from './types';
142
-
143
136
  export { default as EntityProvider } from './entity-provider';
144
137
  export * from './entity-provider';
145
138
  export * from './entity-types';
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Shared cache of blocks parsed from an entity's `content` string, keyed by
3
+ * `kind:name:id`. Populated both eagerly by the `getEntityRecord` resolver
4
+ * (when the sync manager parses content for transient edits) and lazily by
5
+ * `useEntityBlockEditor`. The stored `content` string acts as a validator so
6
+ * stale entries are discarded when the underlying record changes.
7
+ */
8
+ export const parsedBlocksCache = new Map();
9
+
10
+ export function getCacheKey( kind, name, id ) {
11
+ return `${ kind }:${ name }:${ id }`;
12
+ }
@@ -7,6 +7,7 @@ import apiFetch from '@wordpress/api-fetch';
7
7
  * Internal dependencies
8
8
  */
9
9
  import { STORE_NAME } from './name';
10
+ import { getSyncManager, hasSyncManager } from './sync';
10
11
 
11
12
  /**
12
13
  * Returns an action object used in signalling that the registered post meta
@@ -163,6 +164,7 @@ export function receiveEditorAssets( assets ) {
163
164
 
164
165
  /**
165
166
  * Returns an action object used to set whether collaboration is supported.
167
+ * When set to false, also disconnects all sync entities.
166
168
  *
167
169
  * @param {boolean} supported Whether collaboration is supported.
168
170
  *
@@ -172,6 +174,9 @@ export const setCollaborationSupported =
172
174
  ( supported ) =>
173
175
  ( { dispatch } ) => {
174
176
  dispatch( { type: 'SET_COLLABORATION_SUPPORTED', supported } );
177
+ if ( ! supported && hasSyncManager() ) {
178
+ getSyncManager().unloadAll();
179
+ }
175
180
  };
176
181
 
177
182
  /**
@@ -191,3 +196,32 @@ export function receiveViewConfig( kind, name, config ) {
191
196
  config,
192
197
  };
193
198
  }
199
+
200
+ /**
201
+ * Returns an action object used to set the sync connection status for an entity or collection.
202
+ *
203
+ * @param {string} kind Kind of the entity.
204
+ * @param {string} name Name of the entity.
205
+ * @param {number|string|null} key The entity key, or null for collections.
206
+ * @param {Object|null} status The connection state object or null on unload.
207
+ *
208
+ * @return {Object} Action object.
209
+ */
210
+ export function setSyncConnectionStatus( kind, name, key, status ) {
211
+ if ( ! status ) {
212
+ return {
213
+ type: 'CLEAR_SYNC_CONNECTION_STATUS',
214
+ kind,
215
+ name,
216
+ key,
217
+ };
218
+ }
219
+
220
+ return {
221
+ type: 'SET_SYNC_CONNECTION_STATUS',
222
+ kind,
223
+ name,
224
+ key,
225
+ status,
226
+ };
227
+ }
@@ -12,9 +12,12 @@ import {
12
12
  } from './hooks/use-post-editor-awareness-state';
13
13
  import { lock } from './lock-unlock';
14
14
  import { retrySyncConnection } from './sync';
15
+ import {
16
+ SelectionType,
17
+ SelectionDirection,
18
+ } from './utils/crdt-user-selections';
15
19
 
16
- export const privateApis = {};
17
- lock( privateApis, {
20
+ const lockedApis = {
18
21
  useEntityRecordsWithPermissions,
19
22
  RECEIVE_INTERMEDIATE_RESULTS,
20
23
  retrySyncConnection,
@@ -23,4 +26,11 @@ lock( privateApis, {
23
26
  useOnCollaboratorJoin,
24
27
  useOnCollaboratorLeave,
25
28
  useOnPostSave,
26
- } );
29
+ SelectionType,
30
+ SelectionDirection,
31
+ };
32
+
33
+ export type CoreDataPrivateApis = typeof lockedApis;
34
+
35
+ export const privateApis = {};
36
+ lock( privateApis, lockedApis );
@@ -2,6 +2,7 @@
2
2
  * WordPress dependencies
3
3
  */
4
4
  import { createSelector, createRegistrySelector } from '@wordpress/data';
5
+ import type { ConnectionStatus } from '@wordpress/sync';
5
6
 
6
7
  /**
7
8
  * Internal dependencies
@@ -347,3 +348,35 @@ export function getViewConfig(
347
348
  }
348
349
  );
349
350
  }
351
+
352
+ /**
353
+ * Returns the current sync connection status across all entities. Prioritizes
354
+ * disconnected states, then connecting, then connected.
355
+ *
356
+ * @param state Data state.
357
+ *
358
+ * @return The current sync connection state, prioritized by importance.
359
+ */
360
+ export function getSyncConnectionStatus(
361
+ state: State
362
+ ): ConnectionStatus | undefined {
363
+ if ( ! state.syncConnectionStatuses ) {
364
+ return undefined;
365
+ }
366
+
367
+ const PRIORITIZED_STATUSES = [ 'disconnected', 'connecting', 'connected' ];
368
+
369
+ let coalesced: ConnectionStatus | undefined;
370
+
371
+ for ( const status of Object.values( state.syncConnectionStatuses ) ) {
372
+ if (
373
+ ! coalesced ||
374
+ PRIORITIZED_STATUSES.indexOf( status.status ) <
375
+ PRIORITIZED_STATUSES.indexOf( coalesced.status )
376
+ ) {
377
+ coalesced = status;
378
+ }
379
+ }
380
+
381
+ return coalesced;
382
+ }
package/src/resolvers.js CHANGED
@@ -28,6 +28,7 @@ import {
28
28
  } from './utils';
29
29
  import { fetchBlockPatterns } from './fetch';
30
30
  import { restoreSelection, getSelectionHistory } from './utils/crdt-selection';
31
+ import { parsedBlocksCache, getCacheKey } from './parsed-blocks-cache';
31
32
 
32
33
  /**
33
34
  * Requests authors from the REST API.
@@ -180,6 +181,18 @@ export const getEntityRecord =
180
181
  transientConfig.read( recordWithTransients );
181
182
  } );
182
183
 
184
+ // Share the parsed blocks with `useEntityBlockEditor` so the
185
+ // editor doesn't re-parse the same `content` string.
186
+ if (
187
+ recordWithTransients.blocks &&
188
+ typeof recordWithTransients.content?.raw === 'string'
189
+ ) {
190
+ parsedBlocksCache.set( getCacheKey( kind, name, key ), {
191
+ content: recordWithTransients.content.raw,
192
+ blocks: recordWithTransients.blocks,
193
+ } );
194
+ }
195
+
183
196
  // Load the entity record for syncing. Do not await promise.
184
197
  void getSyncManager()?.load(
185
198
  entityConfig.syncConfig,
@@ -247,13 +260,18 @@ export const getEntityRecord =
247
260
  return;
248
261
  }
249
262
 
250
- // Trigger a save to persist the CRDT document. The entity's
251
- // pre-persist hooks will create the persisted CRDT document
252
- // and apply it to the record's meta.
263
+ // Trigger a minimal save to persist the CRDT document. The
264
+ // entity's pre-persist hooks will create the persisted CRDT
265
+ // document and apply it to the record's meta.
266
+ const entityIdKey =
267
+ entityConfig.key || DEFAULT_ENTITY_KEY;
253
268
  dispatch.saveEntityRecord(
254
269
  kind,
255
270
  name,
256
- editedRecord,
271
+ {
272
+ [ entityIdKey ]:
273
+ editedRecord[ entityIdKey ],
274
+ },
257
275
  { __unstableSkipSyncUpdate: true }
258
276
  );
259
277
  } );
@@ -1014,10 +1032,14 @@ export const getDefaultTemplateId =
1014
1032
  };
1015
1033
 
1016
1034
  getDefaultTemplateId.shouldInvalidate = ( action ) => {
1035
+ // Only invalidate on real saves; `persistedEdits` is absent on
1036
+ // initial fetches so the kickoff's own site read doesn't wipe
1037
+ // the just-resolved template id.
1017
1038
  return (
1018
1039
  action.type === 'RECEIVE_ITEMS' &&
1019
1040
  action.kind === 'root' &&
1020
- action.name === 'site'
1041
+ action.name === 'site' &&
1042
+ !! action.persistedEdits
1021
1043
  );
1022
1044
  };
1023
1045
 
package/src/selectors.ts CHANGED
@@ -1661,35 +1661,3 @@ export const getRevision = createSelector(
1661
1661
  ];
1662
1662
  }
1663
1663
  );
1664
-
1665
- /**
1666
- * Returns the current sync connection status across all entities. Prioritizes
1667
- * disconnected states, then connecting, then connected.
1668
- *
1669
- * @param state Data state.
1670
- *
1671
- * @return The current sync connection state, prioritized by importance.
1672
- */
1673
- export function getSyncConnectionStatus(
1674
- state: State
1675
- ): ConnectionStatus | undefined {
1676
- if ( ! state.syncConnectionStatuses ) {
1677
- return undefined;
1678
- }
1679
-
1680
- const PRIORITIZED_STATUSES = [ 'disconnected', 'connecting', 'connected' ];
1681
-
1682
- let coalesced: ConnectionStatus | undefined;
1683
-
1684
- for ( const status of Object.values( state.syncConnectionStatuses ) ) {
1685
- if (
1686
- ! coalesced ||
1687
- PRIORITIZED_STATUSES.indexOf( status.status ) <
1688
- PRIORITIZED_STATUSES.indexOf( coalesced.status )
1689
- ) {
1690
- coalesced = status;
1691
- }
1692
- }
1693
-
1694
- return coalesced;
1695
- }
package/src/sync.ts CHANGED
@@ -43,3 +43,12 @@ export function getSyncManager(): SyncManager | undefined {
43
43
 
44
44
  return syncManager;
45
45
  }
46
+
47
+ /**
48
+ * Return whether a sync manager has already been created. Use this when you
49
+ * only want to interact with an existing sync manager (e.g. to tear it down),
50
+ * without `getSyncManager()` bootstrapping one if none exists.
51
+ */
52
+ export function hasSyncManager(): boolean {
53
+ return Boolean( syncManager );
54
+ }
@@ -234,9 +234,14 @@ describe( 'getEntityRecord', () => {
234
234
  expect( dispatch.saveEntityRecord ).not.toHaveBeenCalled();
235
235
  } );
236
236
 
237
- it( 'persistCRDTDoc fetches edited record and saves full entity record', async () => {
237
+ it( 'persistCRDTDoc saves only the entity ID and omits REST-invalid fields', async () => {
238
238
  const POST_RECORD = { id: 1, title: 'Test Post', meta: {} };
239
- const EDITED_RECORD = { id: 1, title: 'Edited Post', meta: {} };
239
+ const EDITED_RECORD = {
240
+ id: 1,
241
+ title: 'Edited Post',
242
+ ping_status: '',
243
+ meta: { _crdt_document: 'doc2' },
244
+ };
240
245
  const POST_RESPONSE = {
241
246
  json: () => Promise.resolve( POST_RECORD ),
242
247
  };
@@ -283,11 +288,12 @@ describe( 'getEntityRecord', () => {
283
288
  resolveSelectWithSync.getEditedEntityRecord
284
289
  ).toHaveBeenCalledWith( 'postType', 'post', 1 );
285
290
 
286
- // Should have called saveEntityRecord (not saveEditedEntityRecord).
291
+ // Should only send the entity ID. The pre-persist hook creates the
292
+ // persisted CRDT meta without round-tripping the full edited record.
287
293
  expect( dispatch.saveEntityRecord ).toHaveBeenCalledWith(
288
294
  'postType',
289
295
  'post',
290
- EDITED_RECORD,
296
+ { id: 1 },
291
297
  { __unstableSkipSyncUpdate: true }
292
298
  );
293
299
  } );
@@ -335,11 +341,11 @@ describe( 'getEntityRecord', () => {
335
341
  handlers.persistCRDTDoc();
336
342
  await resolveSelectWithSync.getEditedEntityRecord();
337
343
 
338
- // Should save the record even with no edits (the whole point of the fix).
344
+ // Should save only the entity ID even with no edits.
339
345
  expect( dispatch.saveEntityRecord ).toHaveBeenCalledWith(
340
346
  'postType',
341
347
  'post',
342
- POST_RECORD,
348
+ { id: 1 },
343
349
  { __unstableSkipSyncUpdate: true }
344
350
  );
345
351
  } );
@@ -437,7 +443,7 @@ describe( 'getEntityRecord', () => {
437
443
  expect( dispatch.saveEntityRecord ).toHaveBeenCalledWith(
438
444
  'postType',
439
445
  'post',
440
- EDITED_RECORD,
446
+ { id: 1 },
441
447
  { __unstableSkipSyncUpdate: true }
442
448
  );
443
449
  expect( syncManager.update ).toHaveBeenCalledWith(
package/src/types.ts CHANGED
@@ -6,7 +6,10 @@ import type { ConnectionStatusDisconnected, Y } from '@wordpress/sync';
6
6
  /**
7
7
  * Internal dependencies
8
8
  */
9
- import type { SelectionType } from './utils/crdt-user-selections';
9
+ import type {
10
+ SelectionType,
11
+ SelectionDirection,
12
+ } from './utils/crdt-user-selections';
10
13
 
11
14
  export type { ConnectionStatus } from '@wordpress/sync';
12
15
 
@@ -127,17 +130,11 @@ export type CursorPosition = {
127
130
  // character. With both of these values as editor state, a change in perceived
128
131
  // position will always result in a redraw.
129
132
  absoluteOffset: number;
130
- };
131
133
 
132
- /**
133
- * The direction of a text selection, indicating where the caret sits.
134
- */
135
- export enum SelectionDirection {
136
- /** The caret is at the end of the selection (default / left-to-right). */
137
- Forward = 'f',
138
- /** The caret is at the start of the selection (right-to-left). */
139
- Backward = 'b',
140
- }
134
+ // The sender's `WPBlockSelection.attributeKey` (e.g. `content` or
135
+ // `body.0.cells.0.content`).
136
+ attributeKey?: string;
137
+ };
141
138
 
142
139
  export type SelectionNone = {
143
140
  // The user has not made a selection.
@@ -192,4 +189,12 @@ export type SelectionState =
192
189
  export interface ResolvedSelection {
193
190
  richTextOffset: number | null;
194
191
  localClientId: string | null;
192
+
193
+ // Identifier of the RichText attribute within the block, e.g.:
194
+ // - `content` on a core/paragraph block
195
+ // - `citation` on a quote block
196
+ // - a dot path into a nested attribute like `body.0.cells.0.content` for a
197
+ // core/table cell.
198
+ // Set to `null` for WholeBlock selections.
199
+ attributeKey: string | null;
195
200
  }
@@ -12,6 +12,7 @@ import { Y } from '@wordpress/sync';
12
12
  import {
13
13
  asRichTextOffset,
14
14
  findBlockByClientIdInDoc,
15
+ getYTextByAttributeKey,
15
16
  richTextOffsetToHtmlIndex,
16
17
  } from './crdt-utils';
17
18
  import type { WPBlockSelection, WPSelection } from '../types';
@@ -147,14 +148,16 @@ function convertWPBlockSelectionToSelection(
147
148
  const attributes = block?.get( 'attributes' );
148
149
  const attributeKey = selection.attributeKey;
149
150
 
150
- const changedYText = attributeKey
151
- ? attributes?.get( attributeKey )
152
- : undefined;
153
-
154
- const isYText = changedYText instanceof Y.Text;
155
- const isFullyDefinedSelection = attributeKey && clientId;
151
+ let changedYText: Y.Text | null = null;
152
+ if ( attributeKey && attributes ) {
153
+ changedYText = getYTextByAttributeKey( attributes, attributeKey );
154
+ }
156
155
 
157
- if ( ! isYText || ! isFullyDefinedSelection ) {
156
+ if (
157
+ ! ( changedYText instanceof Y.Text ) ||
158
+ ! attributeKey ||
159
+ ! clientId
160
+ ) {
158
161
  // We either don't have a valid YText (it's been deleted) or we've
159
162
  // been passed a selection that's just a block clientId.
160
163
  // Store as BlockSelection.
@@ -599,6 +599,30 @@ export function mergeCrdtBlocks(
599
599
  break;
600
600
  }
601
601
 
602
+ case 'clientId': {
603
+ // Never overwrite the local block's clientId with the
604
+ // incoming one. Some callers (e.g. the Code Editor flow
605
+ // that parses raw HTML into blocks on every keystroke)
606
+ // produce randomized clientIds for blocks whose content
607
+ // has changed on every sync. Without this case the default
608
+ // branch would replace the stable Y.Doc clientId with
609
+ // a new one, causing remote peers to remount the block
610
+ // and flash the block's content on reload.
611
+ //
612
+ // This mirrors the clientId exclusion in `areBlocksEqual`.
613
+ // Convergence is preserved. Because we're not writing
614
+ // to the clientId, Yjs doesn't send an update to peers
615
+ // telling them to change the clientId, so everyone
616
+ // sees the same clientId per block.
617
+ // Inserts still use a new clientId via createNewYBlock,
618
+ // and the duplicate-clientId sweep below catches any
619
+ // edge cases. The clientId is anchored to the
620
+ // slot in the array rather than to specific content,
621
+ // which is consistent with areBlocksEqual ignoring
622
+ // clientId when diffing.
623
+ break;
624
+ }
625
+
602
626
  default:
603
627
  if (
604
628
  ! fastDeepEqual(
@@ -15,6 +15,7 @@ import type { YBlock, YBlocks } from './crdt-blocks';
15
15
  import {
16
16
  asRichTextOffset,
17
17
  getRootMap,
18
+ getYTextByAttributeKey,
18
19
  richTextOffsetToHtmlIndex,
19
20
  } from './crdt-utils';
20
21
  import type {
@@ -26,7 +27,6 @@ import type {
26
27
  SelectionInOneBlock,
27
28
  SelectionInMultipleBlocks,
28
29
  SelectionWholeBlock,
29
- SelectionDirection,
30
30
  CursorPosition,
31
31
  } from '../types';
32
32
 
@@ -41,6 +41,16 @@ export enum SelectionType {
41
41
  WholeBlock = 'whole-block',
42
42
  }
43
43
 
44
+ /**
45
+ * The direction of a text selection, indicating where the caret sits.
46
+ */
47
+ export enum SelectionDirection {
48
+ /** The caret is at the end of the selection (default / left-to-right). */
49
+ Forward = 'f',
50
+ /** The caret is at the start of the selection (right-to-left). */
51
+ Backward = 'b',
52
+ }
53
+
44
54
  /**
45
55
  * Converts WordPress block editor selection to a SelectionState.
46
56
  *
@@ -173,7 +183,9 @@ function getCursorPosition(
173
183
  }
174
184
 
175
185
  const attributes = block.get( 'attributes' );
176
- const currentYText = attributes?.get( selection.attributeKey );
186
+ const currentYText = attributes
187
+ ? getYTextByAttributeKey( attributes, selection.attributeKey )
188
+ : null;
177
189
 
178
190
  // If the attribute is not a Y.Text, return null.
179
191
  if ( ! ( currentYText instanceof Y.Text ) ) {
@@ -191,6 +203,7 @@ function getCursorPosition(
191
203
  return {
192
204
  relativePosition,
193
205
  absoluteOffset: selection.offset,
206
+ attributeKey: selection.attributeKey,
194
207
  };
195
208
  }
196
209
 
@@ -125,6 +125,47 @@ export function asHtmlStringIndex( index: number ): HtmlStringIndex {
125
125
  return index as HtmlStringIndex;
126
126
  }
127
127
 
128
+ /**
129
+ * Resolve a selection attribute key to a Y.Text value.
130
+ *
131
+ * RichText identifiers are normally top-level block attribute keys, but nested
132
+ * rich-text fields can provide a dot path such as `body.0.cells.0.content`.
133
+ *
134
+ * @param attributes The block attributes map.
135
+ * @param attributeKey The top-level attribute key or nested attribute path.
136
+ * @return The matching Y.Text, or null if the path is not a rich-text field.
137
+ */
138
+ export function getYTextByAttributeKey(
139
+ attributes: Y.Map< unknown >,
140
+ attributeKey: string
141
+ ): Y.Text | null {
142
+ const directValue = attributes.get( attributeKey );
143
+ if ( directValue instanceof Y.Text ) {
144
+ return directValue;
145
+ }
146
+
147
+ let value: unknown = attributes;
148
+ for ( const pathPart of attributeKey.split( '.' ) ) {
149
+ if ( value instanceof Y.Map ) {
150
+ value = value.get( pathPart );
151
+ } else if ( value instanceof Y.Array ) {
152
+ const index = Number.parseInt( pathPart, 10 );
153
+ if (
154
+ ! Number.isSafeInteger( index ) ||
155
+ index < 0 ||
156
+ index.toString() !== pathPart
157
+ ) {
158
+ return null;
159
+ }
160
+ value = value.get( index );
161
+ } else {
162
+ return null;
163
+ }
164
+ }
165
+
166
+ return value instanceof Y.Text ? value : null;
167
+ }
168
+
128
169
  /**
129
170
  * Given a block ID and a Y.Doc, find the block in the document.
130
171
  *