Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/core-data/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@

- Raise the minimum required Node.js version to `>=22.13.0`, for stable `require(esm)` support ([#84313](https://github.com/WordPress/gutenberg/pull/84313)).

### Bug Fixes

- Keep read-only taxonomy term data out of collaborative edits so stale counts and links do not mark unchanged categories as dirty ([#84838](https://github.com/WordPress/gutenberg/pull/84838)).

## 8.2.0 (2026-10-07)

### Enhancements
Expand Down
4 changes: 2 additions & 2 deletions packages/core-data/src/entities.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ import { PostEditorAwareness } from './awareness/post-editor-awareness';
import {
applyPostChangesToCRDTDoc,
defaultCollectionSyncConfig,
defaultSyncConfig,
getPostChangesFromCRDTDoc,
POST_META_KEY_FOR_CRDT_DOC_PERSISTENCE,
taxonomySyncConfig,
} from './utils/crdt';

export const DEFAULT_ENTITY_KEY = 'id';
Expand Down Expand Up @@ -474,7 +474,7 @@ async function loadTaxonomyEntities() {
};

if ( window.__experimentalEnableRealTimeCollaboration ) {
entity.syncConfig = defaultSyncConfig;
entity.syncConfig = taxonomySyncConfig;
}

return entity;
Expand Down
47 changes: 29 additions & 18 deletions packages/core-data/src/utils/crdt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,34 +83,44 @@ const disallowedPostMetaKeys = new Set< string >( [
POST_META_KEY_FOR_CRDT_DOC_PERSISTENCE,
] );

// Read-only REST fields can differ between requests without a term edit,
// such as the count after publishing a post or links after a URL change.
// Ignore them when writing and reading, including in existing documents.
const unsyncedTaxonomyProperties = new Set( [
'id',
'count',
'link',
'taxonomy',
'_links',
'_embedded',
] );

/**
* Given a set of local changes to a generic entity record, apply those changes
* Given a set of local changes to a taxonomy term, apply those changes
* to the local Y.Doc.
*
* @param {CRDTDoc} ydoc
* @param {Partial< ObjectData >} changes
* @return {void}
*/
function defaultApplyChangesToCRDTDoc(
function applyTaxonomyChangesToCRDTDoc(
ydoc: CRDTDoc,
changes: ObjectData
): void {
const ymap = getRootMap( ydoc, CRDT_RECORD_MAP_KEY );

Object.entries( changes ).forEach( ( [ key, newValue ] ) => {
if ( unsyncedTaxonomyProperties.has( key ) ) {
return;
}

// Cannot serialize function values, so cannot sync them.
if ( 'function' === typeof newValue ) {
return;
}

switch ( key ) {
// Add support for additional data types here.

default: {
const currentValue = ymap.get( key );
updateMapValue( ymap, key, currentValue, newValue );
}
}
const currentValue = ymap.get( key );
updateMapValue( ymap, key, currentValue, newValue );
} );
}

Expand Down Expand Up @@ -334,7 +344,7 @@ function parseCursorSelection( selection?: WPSelection ): MergeCursorPosition {
: null;
}

function defaultGetChangesFromCRDTDoc(
function getTaxonomyChangesFromCRDTDoc(
crdtDoc: CRDTDoc,
editedRecord: ObjectData
): ObjectData {
Expand All @@ -348,8 +358,10 @@ function defaultGetChangesFromCRDTDoc(
* See https://github.com/WordPress/gutenberg/issues/79907.
*/
return Object.fromEntries(
Object.entries( docRecord ).filter( ( [ key, newValue ] ) =>
haveValuesChanged( editedRecord?.[ key ], newValue )
Object.entries( docRecord ).filter(
( [ key, newValue ] ) =>
! unsyncedTaxonomyProperties.has( key ) &&
haveValuesChanged( editedRecord?.[ key ], newValue )
)
);
}
Expand Down Expand Up @@ -537,13 +549,12 @@ export function getPostChangesFromCRDTDoc(
}

/**
* This default sync config can be used for entities that are flat maps of
* primitive values and do not require custom logic to merge changes.
* Syncs editable taxonomy term fields while leaving read-only REST data local.
*/
export const defaultSyncConfig: SyncConfig = {
applyChangesToCRDTDoc: defaultApplyChangesToCRDTDoc,
export const taxonomySyncConfig: SyncConfig = {
applyChangesToCRDTDoc: applyTaxonomyChangesToCRDTDoc,
createAwareness: ( ydoc: CRDTDoc ) => new BaseAwareness( ydoc ),
getChangesFromCRDTDoc: defaultGetChangesFromCRDTDoc,
getChangesFromCRDTDoc: getTaxonomyChangesFromCRDTDoc,
};

/**
Expand Down
84 changes: 78 additions & 6 deletions packages/core-data/src/utils/test/crdt.jsdom.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,9 @@ import { CRDT_RECORD_MAP_KEY } from '../../sync';
import {
applyPostChangesToCRDTDoc,
defaultCollectionSyncConfig,
defaultSyncConfig,
getPostChangesFromCRDTDoc,
POST_META_KEY_FOR_CRDT_DOC_PERSISTENCE,
taxonomySyncConfig,
type PostChanges,
type YPostRecord,
} from '../crdt';
Expand Down Expand Up @@ -83,7 +83,7 @@ const defaultSyncedProperties = new Set< string >( [
'title',
] );

describe( 'defaultSyncConfig', () => {
describe( 'taxonomySyncConfig', () => {
// Regression test for https://github.com/WordPress/gutenberg/issues/79907:
// reporting unchanged properties as changes marks resolved records
// (e.g. taxonomy terms) as dirty whenever a synced update arrives.
Expand All @@ -96,11 +96,11 @@ describe( 'defaultSyncConfig', () => {
meta: [],
_links: { self: [ { href: 'https://example.com' } ] },
};
defaultSyncConfig.applyChangesToCRDTDoc( doc, record );
taxonomySyncConfig.applyChangesToCRDTDoc( doc, record );

// The edited record deep-equals the document contents, but
// `Y.Map.toJSON()` returns fresh object instances.
const changes = defaultSyncConfig.getChangesFromCRDTDoc( doc, {
const changes = taxonomySyncConfig.getChangesFromCRDTDoc( doc, {
...record,
meta: [],
_links: { self: [ { href: 'https://example.com' } ] },
Expand All @@ -112,13 +112,13 @@ describe( 'defaultSyncConfig', () => {

it( 'getChangesFromCRDTDoc returns only the properties that differ from the edited record', () => {
const doc = new Y.Doc();
defaultSyncConfig.applyChangesToCRDTDoc( doc, {
taxonomySyncConfig.applyChangesToCRDTDoc( doc, {
id: 1,
name: 'Renamed category',
slug: 'uncategorized',
} );

const changes = defaultSyncConfig.getChangesFromCRDTDoc( doc, {
const changes = taxonomySyncConfig.getChangesFromCRDTDoc( doc, {
id: 1,
name: 'Uncategorized',
slug: 'uncategorized',
Expand All @@ -127,6 +127,78 @@ describe( 'defaultSyncConfig', () => {
expect( changes ).toEqual( { name: 'Renamed category' } );
doc.destroy();
} );

it( 'does not put read-only term data into shared documents', () => {
const doc = new Y.Doc();
const editableFields = {
name: 'Renamed category',
slug: 'renamed-category',
description: 'Category description',
parent: 2,
meta: { color: 'blue' },
custom_field: 'Plugin-provided value',
};

taxonomySyncConfig.applyChangesToCRDTDoc( doc, {
...editableFields,
id: 1,
count: 3,
link: 'https://example.com/category/renamed-category/',
taxonomy: 'category',
_links: { self: [ { href: 'https://example.com' } ] },
_embedded: { author: [ { id: 1 } ] },
} );

expect( doc.getMap( CRDT_RECORD_MAP_KEY ).toJSON() ).toEqual(
editableFields
);
doc.destroy();
} );

it( 'ignores stale read-only data from existing shared documents while preserving term edits', () => {
const doc = new Y.Doc();
const record = {
id: 1,
count: 3,
name: 'Uncategorized',
slug: 'uncategorized',
description: '',
parent: 0,
meta: { color: 'red' },
custom_field: 'Original value',
link: 'https://example.com/category/uncategorized/',
taxonomy: 'category',
_links: { self: [ { href: 'https://example.com' } ] },
_embedded: { author: [ { id: 1 } ] },
};
const termEdits = {
name: 'Renamed category',
slug: 'renamed-category',
description: 'Category description',
parent: 2,
meta: { color: 'blue' },
custom_field: 'Plugin-provided value',
};

// Seed a document written before read-only fields were excluded.
const sharedRecord = {
...record,
...termEdits,
count: 0,
link: 'https://old.example.com/category/uncategorized/',
_links: { self: [ { href: 'https://old.example.com' } ] },
_embedded: { author: [ { id: 2 } ] },
};
const ymap = doc.getMap( CRDT_RECORD_MAP_KEY );
for ( const [ key, value ] of Object.entries( sharedRecord ) ) {
ymap.set( key, value );
}

expect(
taxonomySyncConfig.getChangesFromCRDTDoc( doc, record )
).toEqual( termEdits );
doc.destroy();
} );
} );

describe( 'defaultCollectionSyncConfig', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ import { test, expect } from '../fixtures';
* Opening the pre-publish panel resolves the default category record, which
* loads the term document into the sync manager. When another peer has the
* same term document loaded, its updates are relayed to this client. The
* default sync config must not report the unchanged record as edits when
* those updates arrive. If it does, the term becomes dirty,
* taxonomy sync config must not report read-only data as edits when those
* updates arrive. If it does, the term becomes dirty,
* `hasNonPostEntityChanges()` returns true, and the Publish button silently
* turns into "Save".
*/
Expand Down Expand Up @@ -47,11 +47,32 @@ test.describe( 'Collaboration - synced taxonomy records', () => {
} ).toPass( { timeout: 20000 } );
};

const waitForCategorySyncExchange = ( target: Page ) =>
target.waitForResponse(
( response ) =>
response.url().includes( 'wp-sync' ) &&
response.status() === 200 &&
( response.request().postData() ?? '' ).includes(
'taxonomy/category'
),
{ timeout: 20000 }
);

// Session 1: open the post and the pre-publish panel. This resolves
// the default category record and loads the term document into the
// sync manager, which starts exchanging it with the server.
await collaborationUtils.openPost( post.id );
await openPrePublishPanel( page );
await waitForCategorySyncExchange( page );

// Publishing another post changes the category's read-only count.
// The second tab receives the new count from REST while the first
// tab and the shared document still hold the previous count.
await requestUtils.createPost( {
title: 'Change the category count',
status: 'publish',
date_gmt: new Date().toISOString(),
} );

// Session 2: a second tab for the same user opens the same post and
// panel. Each tab is a distinct sync client, so the term document
Expand All @@ -63,22 +84,12 @@ test.describe( 'Collaboration - synced taxonomy records', () => {

// Wait until both tabs have exchanged sync messages that include the
// default category room.
const waitForCategorySyncExchange = ( target: Page ) =>
target.waitForResponse(
( response ) =>
response.url().includes( 'wp-sync' ) &&
response.status() === 200 &&
( response.request().postData() ?? '' ).includes(
'taxonomy/category'
),
{ timeout: 20000 }
);
await Promise.all( [
waitForCategorySyncExchange( page ),
waitForCategorySyncExchange( page2 ),
] );

// The relayed updates carry no actual changes, so the term record
// Only the read-only category count differs, so the term record
// must not accumulate edits in either tab. Poll across several sync
// cycles; on regression the record turns dirty within one cycle.
const watchForDirtyCategory = ( target: Page ) =>
Expand Down
Loading