Skip to content

Commit 338747b

Browse files
smithjw1smithjw1chriszaratemaxschmeling
authored
RTC: Auto-register custom taxonomy rest_base values for CRDT sync (#75983)
* RTC: Auto-register custom taxonomy rest_base values for CRDT sync Auto-register all taxonomy rest_base values for CRDT sync when taxonomy entities are loaded from the REST API. This means custom taxonomies with show_in_rest enabled automatically work with real-time collaboration without any plugin-side registration. Taxonomies returned by /wp/v2/taxonomies already have show_in_rest enabled, and the REST API enforces assign_terms capabilities on save. Closes #75875 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Inspect taxonomies for each post type * Apply suggested changes from PR * Conditionally fetch taxonomies --------- Co-authored-by: smithjw1 <smithjw1@git.wordpress.org> Co-authored-by: chriszarate <czarate@git.wordpress.org> Co-authored-by: maxschmeling <maxschmeling@git.wordpress.org>
1 parent f755b5a commit 338747b

4 files changed

Lines changed: 349 additions & 89 deletions

File tree

‎packages/core-data/src/entities.js‎

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -329,15 +329,42 @@ export const prePersistPostType = async (
329329
* @return {Promise} Entities promise
330330
*/
331331
async function loadPostTypeEntities() {
332-
const postTypes = await apiFetch( {
333-
path: '/wp/v2/types?context=view',
334-
} );
332+
const postTypesPromise = apiFetch( { path: '/wp/v2/types?context=view' } );
333+
const taxonomiesPromise = window._wpCollaborationEnabled
334+
? apiFetch( { path: '/wp/v2/taxonomies?context=view' } )
335+
: Promise.resolve( {} );
336+
const [ postTypes, taxonomies ] = await Promise.all( [
337+
postTypesPromise,
338+
taxonomiesPromise,
339+
] );
340+
335341
return Object.entries( postTypes ?? {} ).map( ( [ name, postType ] ) => {
336342
const isTemplate = [ 'wp_template', 'wp_template_part' ].includes(
337343
name
338344
);
339345
const namespace = postType?.rest_namespace ?? 'wp/v2';
340346

347+
const syncedProperties = new Set( [
348+
'author',
349+
'blocks',
350+
'content',
351+
'comment_status',
352+
'date',
353+
'excerpt',
354+
'featured_media',
355+
'format',
356+
'meta',
357+
'ping_status',
358+
'slug',
359+
'status',
360+
'sticky',
361+
'template',
362+
'title',
363+
...( postType.taxonomies
364+
?.map( ( taxonomy ) => taxonomies?.[ taxonomy ]?.rest_base )
365+
?.filter( Boolean ) ?? [] ),
366+
] );
367+
341368
const entity = {
342369
kind: 'postType',
343370
baseURL: `/${ namespace }/${ postType.rest_base }`,
@@ -385,7 +412,7 @@ async function loadPostTypeEntities() {
385412
* @return {void}
386413
*/
387414
applyChangesToCRDTDoc: ( crdtDoc, changes ) =>
388-
applyPostChangesToCRDTDoc( crdtDoc, changes, postType ),
415+
applyPostChangesToCRDTDoc( crdtDoc, changes, syncedProperties ),
389416

390417
/**
391418
* Create the awareness instance for the entity's CRDT document.
@@ -409,7 +436,11 @@ async function loadPostTypeEntities() {
409436
* @return {Partial< import('@wordpress/sync').ObjectData >} Changes to record
410437
*/
411438
getChangesFromCRDTDoc: ( crdtDoc, editedRecord ) =>
412-
getPostChangesFromCRDTDoc( crdtDoc, editedRecord, postType ),
439+
getPostChangesFromCRDTDoc(
440+
crdtDoc,
441+
editedRecord,
442+
syncedProperties
443+
),
413444

414445
/**
415446
* Extract changes from a CRDT document that can be used to update the

‎packages/core-data/src/test/entities.js‎

Lines changed: 185 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ jest.mock( '../sync', () => ( {
88
...jest.requireActual( '../sync' ),
99
getSyncManager: jest.fn(),
1010
} ) );
11+
jest.mock( '../utils/crdt', () => ( {
12+
...jest.requireActual( '../utils/crdt' ),
13+
applyPostChangesToCRDTDoc: jest.fn(),
14+
} ) );
1115

1216
/**
1317
* Internal dependencies
@@ -19,7 +23,10 @@ import {
1923
additionalEntityConfigLoaders,
2024
} from '../entities';
2125
import { getSyncManager } from '../sync';
22-
import { POST_META_KEY_FOR_CRDT_DOC_PERSISTENCE } from '../utils/crdt';
26+
import {
27+
applyPostChangesToCRDTDoc,
28+
POST_META_KEY_FOR_CRDT_DOC_PERSISTENCE,
29+
} from '../utils/crdt';
2330

2431
describe( 'getMethodName', () => {
2532
it( 'should return the right method name for an entity with the root kind', () => {
@@ -127,6 +134,183 @@ describe( 'prePersistPostType', () => {
127134
} );
128135
} );
129136

137+
describe( 'loadPostTypeEntities', () => {
138+
let originalCollaborationEnabled;
139+
140+
beforeEach( () => {
141+
apiFetch.mockReset();
142+
applyPostChangesToCRDTDoc.mockReset();
143+
originalCollaborationEnabled = window._wpCollaborationEnabled;
144+
} );
145+
146+
afterEach( () => {
147+
window._wpCollaborationEnabled = originalCollaborationEnabled;
148+
} );
149+
150+
it( 'should include custom taxonomy rest_bases in synced properties when collaboration is enabled', async () => {
151+
window._wpCollaborationEnabled = true;
152+
153+
const mockPostTypes = {
154+
book: {
155+
name: 'Books',
156+
rest_base: 'books',
157+
rest_namespace: 'wp/v2',
158+
taxonomies: [ 'genre', 'audience' ],
159+
},
160+
};
161+
const mockTaxonomies = {
162+
genre: {
163+
name: 'Genres',
164+
rest_base: 'genres',
165+
rest_namespace: 'wp/v2',
166+
},
167+
audience: {
168+
name: 'Audiences',
169+
rest_base: 'audiences',
170+
rest_namespace: 'wp/v2',
171+
},
172+
};
173+
174+
apiFetch
175+
.mockResolvedValueOnce( mockPostTypes )
176+
.mockResolvedValueOnce( mockTaxonomies );
177+
178+
const postTypeLoader = additionalEntityConfigLoaders.find(
179+
( loader ) => loader.kind === 'postType'
180+
);
181+
const entities = await postTypeLoader.loadEntities();
182+
const bookEntity = entities.find( ( e ) => e.name === 'book' );
183+
184+
bookEntity.syncConfig.applyChangesToCRDTDoc( {}, {} );
185+
186+
expect( applyPostChangesToCRDTDoc ).toHaveBeenCalledWith(
187+
{},
188+
{},
189+
expect.any( Set )
190+
);
191+
192+
const syncedProperties = applyPostChangesToCRDTDoc.mock.calls[ 0 ][ 2 ];
193+
expect( syncedProperties ).toContain( 'genres' );
194+
expect( syncedProperties ).toContain( 'audiences' );
195+
} );
196+
197+
it( 'should not fetch taxonomies when collaboration is disabled', async () => {
198+
window._wpCollaborationEnabled = false;
199+
200+
const mockPostTypes = {
201+
post: {
202+
name: 'Posts',
203+
rest_base: 'posts',
204+
rest_namespace: 'wp/v2',
205+
taxonomies: [ 'category', 'post_tag' ],
206+
},
207+
};
208+
209+
apiFetch.mockResolvedValueOnce( mockPostTypes );
210+
211+
const postTypeLoader = additionalEntityConfigLoaders.find(
212+
( loader ) => loader.kind === 'postType'
213+
);
214+
const entities = await postTypeLoader.loadEntities();
215+
const postEntity = entities.find( ( e ) => e.name === 'post' );
216+
217+
postEntity.syncConfig.applyChangesToCRDTDoc( {}, {} );
218+
219+
// Only one apiFetch call (post types), no taxonomy fetch.
220+
expect( apiFetch ).toHaveBeenCalledTimes( 1 );
221+
222+
const syncedProperties = applyPostChangesToCRDTDoc.mock.calls[ 0 ][ 2 ];
223+
expect( syncedProperties ).not.toContain( 'categories' );
224+
expect( syncedProperties ).not.toContain( 'tags' );
225+
} );
226+
227+
it( 'should skip taxonomy rest_base when taxonomy is not found in fetched taxonomies', async () => {
228+
window._wpCollaborationEnabled = true;
229+
230+
const mockPostTypes = {
231+
book: {
232+
name: 'Books',
233+
rest_base: 'books',
234+
rest_namespace: 'wp/v2',
235+
taxonomies: [ 'genre', 'missing_taxonomy' ],
236+
},
237+
};
238+
const mockTaxonomies = {
239+
genre: {
240+
name: 'Genres',
241+
rest_base: 'genres',
242+
rest_namespace: 'wp/v2',
243+
},
244+
// 'missing_taxonomy' is intentionally absent.
245+
};
246+
247+
apiFetch
248+
.mockResolvedValueOnce( mockPostTypes )
249+
.mockResolvedValueOnce( mockTaxonomies );
250+
251+
const postTypeLoader = additionalEntityConfigLoaders.find(
252+
( loader ) => loader.kind === 'postType'
253+
);
254+
const entities = await postTypeLoader.loadEntities();
255+
const bookEntity = entities.find( ( e ) => e.name === 'book' );
256+
257+
bookEntity.syncConfig.applyChangesToCRDTDoc( {}, {} );
258+
259+
const syncedProperties = applyPostChangesToCRDTDoc.mock.calls[ 0 ][ 2 ];
260+
expect( syncedProperties ).toContain( 'genres' );
261+
// missing_taxonomy has no rest_base entry, so nothing should be added for it.
262+
expect( syncedProperties.size ).toBe( 16 ); // 15 base + 1 taxonomy (genres)
263+
} );
264+
265+
it( 'should include base synced properties regardless of taxonomies', async () => {
266+
window._wpCollaborationEnabled = true;
267+
268+
const mockPostTypes = {
269+
page: {
270+
name: 'Pages',
271+
rest_base: 'pages',
272+
rest_namespace: 'wp/v2',
273+
taxonomies: [],
274+
},
275+
};
276+
277+
apiFetch
278+
.mockResolvedValueOnce( mockPostTypes )
279+
.mockResolvedValueOnce( {} );
280+
281+
const postTypeLoader = additionalEntityConfigLoaders.find(
282+
( loader ) => loader.kind === 'postType'
283+
);
284+
const entities = await postTypeLoader.loadEntities();
285+
const pageEntity = entities.find( ( e ) => e.name === 'page' );
286+
287+
pageEntity.syncConfig.applyChangesToCRDTDoc( {}, {} );
288+
289+
const syncedProperties = applyPostChangesToCRDTDoc.mock.calls[ 0 ][ 2 ];
290+
const expectedBase = [
291+
'author',
292+
'blocks',
293+
'content',
294+
'comment_status',
295+
'date',
296+
'excerpt',
297+
'featured_media',
298+
'format',
299+
'meta',
300+
'ping_status',
301+
'slug',
302+
'status',
303+
'sticky',
304+
'template',
305+
'title',
306+
];
307+
for ( const prop of expectedBase ) {
308+
expect( syncedProperties ).toContain( prop );
309+
}
310+
expect( syncedProperties.size ).toBe( 15 );
311+
} );
312+
} );
313+
130314
describe( 'loadTaxonomyEntities', () => {
131315
beforeEach( () => {
132316
apiFetch.mockReset();

‎packages/core-data/src/utils/crdt.ts‎

Lines changed: 8 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,6 @@ import {
2727
type YBlocks,
2828
} from './crdt-blocks';
2929
import { type Post } from '../entity-types/post';
30-
import { type Type } from '../entity-types';
3130
import { CRDT_DOC_META_PERSISTENCE_KEY, CRDT_RECORD_MAP_KEY } from '../sync';
3231
import type { WPSelection } from '../types';
3332
import {
@@ -76,27 +75,6 @@ export interface YPostRecord extends YMapRecord {
7675

7776
export const POST_META_KEY_FOR_CRDT_DOC_PERSISTENCE = '_crdt_document';
7877

79-
// Properties that are allowed to be synced for a post.
80-
const allowedPostProperties = new Set< string >( [
81-
'author',
82-
'blocks',
83-
'content',
84-
'categories',
85-
'comment_status',
86-
'date',
87-
'excerpt',
88-
'featured_media',
89-
'format',
90-
'meta',
91-
'ping_status',
92-
'slug',
93-
'status',
94-
'sticky',
95-
'tags',
96-
'template',
97-
'title',
98-
] );
99-
10078
// Post meta keys that should *not* be synced.
10179
const disallowedPostMetaKeys = new Set< string >( [
10280
POST_META_KEY_FOR_CRDT_DOC_PERSISTENCE,
@@ -139,18 +117,18 @@ function defaultApplyChangesToCRDTDoc(
139117
*
140118
* @param {CRDTDoc} ydoc
141119
* @param {PostChanges} changes
142-
* @param {Type} _postType
120+
* @param {Set<string>} syncedProperties
143121
* @return {void}
144122
*/
145123
export function applyPostChangesToCRDTDoc(
146124
ydoc: CRDTDoc,
147125
changes: PostChanges,
148-
_postType: Type // eslint-disable-line @typescript-eslint/no-unused-vars
126+
syncedProperties: Set< string >
149127
): void {
150128
const ymap = getRootMap< YPostRecord >( ydoc, CRDT_RECORD_MAP_KEY );
151129

152130
Object.keys( changes ).forEach( ( key ) => {
153-
if ( ! allowedPostProperties.has( key ) ) {
131+
if ( ! syncedProperties.has( key ) ) {
154132
return;
155133
}
156134

@@ -289,23 +267,23 @@ function defaultGetChangesFromCRDTDoc( crdtDoc: CRDTDoc ): ObjectData {
289267
* against the local record and determine if there are changes (edits) we want
290268
* to dispatch.
291269
*
292-
* @param {CRDTDoc} ydoc
293-
* @param {Post} editedRecord
294-
* @param {Type} _postType
270+
* @param {CRDTDoc} ydoc
271+
* @param {Post} editedRecord
272+
* @param {Set<string>} syncedProperties
295273
* @return {Partial<PostChanges>} The changes that should be applied to the local record.
296274
*/
297275
export function getPostChangesFromCRDTDoc(
298276
ydoc: CRDTDoc,
299277
editedRecord: Post,
300-
_postType: Type // eslint-disable-line @typescript-eslint/no-unused-vars
278+
syncedProperties: Set< string >
301279
): PostChanges {
302280
const ymap = getRootMap< YPostRecord >( ydoc, CRDT_RECORD_MAP_KEY );
303281

304282
let allowedMetaChanges: Post[ 'meta' ] = {};
305283

306284
const changes = Object.fromEntries(
307285
Object.entries( ymap.toJSON() ).filter( ( [ key, newValue ] ) => {
308-
if ( ! allowedPostProperties.has( key ) ) {
286+
if ( ! syncedProperties.has( key ) ) {
309287
return false;
310288
}
311289

0 commit comments

Comments
 (0)