diff --git a/packages/core-data/src/actions.js b/packages/core-data/src/actions.js index 561bb8cb8c676b..ee681e27e88fd0 100644 --- a/packages/core-data/src/actions.js +++ b/packages/core-data/src/actions.js @@ -1,6 +1,7 @@ /** * External dependencies */ +import fastDeepEqual from 'fast-deep-equal/es6/index.js'; import { v4 as uuid } from 'uuid'; /** @@ -24,11 +25,71 @@ import { getSyncManager, } from './sync'; import logEntityDeprecation from './utils/log-entity-deprecation'; +import { + getRawValue, + POST_META_KEY_FOR_CRDT_DOC_PERSISTENCE, +} from './utils/crdt'; function addTitleToAutoDraft( record ) { return record.status === 'auto-draft' ? { ...record, title: '' } : record; } +// Post meta is applied to the CRDT one subkey at a time, so compare the save +// response at the same granularity to avoid carrying stale sibling values. +function getServerMutatedMetaFields( updatedMeta, persistedMeta, syncedMeta ) { + const baseline = { ...persistedMeta, ...syncedMeta }; + + return Object.fromEntries( + Object.entries( updatedMeta ?? {} ).filter( ( [ key, value ] ) => { + if ( key === POST_META_KEY_FOR_CRDT_DOC_PERSISTENCE ) { + // The persisted CRDT snapshot may change on save and is + // intentionally excluded from CRDT meta synchronization, so it + // is not a server mutation. + return false; + } + + return ! fastDeepEqual( value, baseline[ key ] ); + } ) + ); +} + +function getServerMutatedFields( + updatedRecord, + persistedRecord, + syncedChanges +) { + return Object.fromEntries( + Object.entries( updatedRecord ).flatMap( ( [ key, value ] ) => { + if ( key === 'meta' ) { + const serverMutatedMeta = getServerMutatedMetaFields( + value, + persistedRecord.meta, + syncedChanges.meta + ); + + return Object.keys( serverMutatedMeta ).length + ? [ [ key, serverMutatedMeta ] ] + : []; + } + + const baseline = + key in syncedChanges + ? syncedChanges[ key ] + : persistedRecord[ key ]; + + // The save response nests raw attributes as `{ raw, rendered }` + // while the baseline holds raw strings; compare raw values so the + // shape difference does not read as a server mutation. + const wasServerMutated = ! fastDeepEqual( + getRawValue( value ) ?? value, + getRawValue( baseline ) ?? baseline + ); + + return wasServerMutated ? [ [ key, value ] ] : []; + } ) + ); +} + /** * Returns an action object used in signalling that authors have been received. * Ignored from documentation as it's internal to the data store. @@ -759,6 +820,23 @@ export const saveEntityRecord = ); } } else { + // `saveEntityRecord` can be called directly, bypassing + // `editEntityRecord`, so make sure its changes enter the + // CRDT before the persisted document is created below. + if ( + entityConfig.syncConfig && + ! __unstableSkipSyncUpdate && + ! isNewRecord && + persistedRecord + ) { + getSyncManager()?.update( + `${ kind }/${ name }`, + recordId, + record, + LOCAL_UNDO_IGNORED_ORIGIN + ); + } + let edits = record; if ( entityConfig.__unstablePrePersist ) { edits = { @@ -783,12 +861,25 @@ export const saveEntityRecord = edits ); if ( entityConfig.syncConfig ) { + let syncChanges; + if ( __unstableSkipSyncUpdate ) { + syncChanges = {}; + } else if ( isNewRecord || ! persistedRecord ) { + syncChanges = updatedRecord; + } else { + syncChanges = getServerMutatedFields( + updatedRecord, + persistedRecord, + record + ); + } + // Use an untracked origin so that the save // response does not create undo levels. getSyncManager()?.update( `${ kind }/${ name }`, recordId, - __unstableSkipSyncUpdate ? {} : updatedRecord, + syncChanges, LOCAL_UNDO_IGNORED_ORIGIN, { isSave: true } ); diff --git a/packages/core-data/src/test/actions.js b/packages/core-data/src/test/actions.js index 3e63dfa13dee02..f00c509142343a 100644 --- a/packages/core-data/src/test/actions.js +++ b/packages/core-data/src/test/actions.js @@ -973,6 +973,7 @@ describe( 'saveEntityRecord', () => { 'local-undo-ignored', { isSave: true } ); + expect( syncManager.update ).toHaveBeenCalledTimes( 1 ); expect( liveSyncState ).toEqual( { isSaved: true, title: 'synced title', @@ -980,6 +981,592 @@ describe( 'saveEntityRecord', () => { expect( result ).toBe( staleSaveResponse ); } ); + it( 'only passes server-mutated fields to SyncManager#update after saving', async () => { + const persistedRecord = { + id: 10, + title: 'Initial title', + content: 'Initial content', + template: 'single', + modified: '2026-07-01T00:00:00', + }; + const edits = { + id: 10, + content: 'Updated content', + }; + const configs = [ + { + name: 'post', + kind: 'postType', + baseURL: '/wp/v2/posts', + syncConfig: {}, + }, + ]; + const syncManager = { + update: jest.fn(), + }; + const select = { + getRawEntityRecord: () => persistedRecord, + }; + const resolveSelect = { getEntitiesConfig: jest.fn( () => configs ) }; + const updatedRecord = { + ...persistedRecord, + content: edits.content, + modified: '2026-07-02T00:00:00', + }; + apiFetch.mockImplementation( () => updatedRecord ); + getSyncManager.mockReturnValue( syncManager ); + + const result = await saveEntityRecord( + 'postType', + 'post', + edits + )( { select, dispatch, resolveSelect } ); + + expect( syncManager.update ).toHaveBeenNthCalledWith( + 1, + 'postType/post', + 10, + edits, + 'local-undo-ignored' + ); + expect( syncManager.update ).toHaveBeenCalledWith( + 'postType/post', + 10, + { + modified: '2026-07-02T00:00:00', + }, + 'local-undo-ignored', + { isSave: true } + ); + expect( syncManager.update ).toHaveBeenCalledTimes( 2 ); + expect( result ).toBe( updatedRecord ); + } ); + + it( 'does not pass unchanged meta fields to SyncManager#update after saving', async () => { + const persistedRecord = { + id: 10, + content: 'Initial content', + meta: { + plugin_value: 'persisted', + peer_value: 'persisted', + _crdt_document: 'old-doc', + }, + }; + const edits = { + id: 10, + content: 'Updated content', + }; + const configs = [ + { + name: 'post', + kind: 'postType', + baseURL: '/wp/v2/posts', + syncConfig: {}, + __unstablePrePersist: async () => ( { + meta: { _crdt_document: 'new-doc' }, + } ), + }, + ]; + const syncManager = { + update: jest.fn(), + }; + const select = { + getRawEntityRecord: () => persistedRecord, + }; + const resolveSelect = { getEntitiesConfig: jest.fn( () => configs ) }; + const updatedRecord = { + ...persistedRecord, + content: edits.content, + meta: { + plugin_value: 'persisted', + peer_value: 'persisted', + _crdt_document: 'new-doc', + }, + }; + apiFetch.mockImplementation( () => updatedRecord ); + getSyncManager.mockReturnValue( syncManager ); + + const result = await saveEntityRecord( + 'postType', + 'post', + edits + )( { select, dispatch, resolveSelect } ); + + expect( syncManager.update ).toHaveBeenNthCalledWith( + 2, + 'postType/post', + 10, + {}, + 'local-undo-ignored', + { isSave: true } + ); + expect( syncManager.update ).toHaveBeenCalledTimes( 2 ); + expect( result ).toBe( updatedRecord ); + } ); + + it( 'passes only server-mutated meta fields to SyncManager#update after saving', async () => { + const persistedRecord = { + id: 10, + meta: { + unchanged: 'persisted', + peer_value: 'persisted', + edited: 'initial', + server_mutated: 'before', + settings: { color: 'blue', size: 'small' }, + }, + }; + const edits = { + id: 10, + meta: { + edited: 'local', + server_mutated: 'Needs Normalizing', + }, + }; + const configs = [ + { + name: 'post', + kind: 'postType', + baseURL: '/wp/v2/posts', + syncConfig: {}, + __unstablePrePersist: async () => ( { + meta: { + ...edits.meta, + _crdt_document: 'new-doc', + }, + } ), + }, + ]; + const syncManager = { + update: jest.fn(), + }; + const select = { + getRawEntityRecord: () => persistedRecord, + }; + const resolveSelect = { getEntitiesConfig: jest.fn( () => configs ) }; + const updatedRecord = { + id: 10, + meta: { + unchanged: 'persisted', + peer_value: 'persisted', + edited: 'local', + server_mutated: 'needs-normalizing', + settings: { color: 'red', size: 'small' }, + _crdt_document: 'new-doc', + }, + }; + apiFetch.mockImplementation( () => updatedRecord ); + getSyncManager.mockReturnValue( syncManager ); + + const result = await saveEntityRecord( + 'postType', + 'post', + edits + )( { select, dispatch, resolveSelect } ); + + expect( syncManager.update ).toHaveBeenNthCalledWith( + 1, + 'postType/post', + 10, + edits, + 'local-undo-ignored' + ); + expect( syncManager.update ).toHaveBeenNthCalledWith( + 2, + 'postType/post', + 10, + { + meta: { + server_mutated: 'needs-normalizing', + settings: { color: 'red', size: 'small' }, + }, + }, + 'local-undo-ignored', + { isSave: true } + ); + expect( syncManager.update ).toHaveBeenCalledTimes( 2 ); + expect( result ).toBe( updatedRecord ); + } ); + + it( 'syncs direct save changes before pre-persisting the record', async () => { + const persistedRecord = { + id: 10, + status: 'auto-draft', + template: '', + }; + const edits = { + id: 10, + template: 'page-no-title', + }; + const syncManager = { + update: jest.fn(), + }; + const prePersist = jest.fn( async () => { + expect( syncManager.update ).toHaveBeenCalledTimes( 1 ); + expect( syncManager.update ).toHaveBeenLastCalledWith( + 'postType/page', + 10, + edits, + 'local-undo-ignored' + ); + + return { status: 'draft' }; + } ); + const configs = [ + { + name: 'page', + kind: 'postType', + baseURL: '/wp/v2/pages', + syncConfig: {}, + __unstablePrePersist: prePersist, + }, + ]; + const select = { + getRawEntityRecord: () => persistedRecord, + }; + const resolveSelect = { getEntitiesConfig: jest.fn( () => configs ) }; + const updatedRecord = { + ...persistedRecord, + ...edits, + status: 'draft', + }; + apiFetch.mockImplementation( () => updatedRecord ); + getSyncManager.mockReturnValue( syncManager ); + + const result = await saveEntityRecord( + 'postType', + 'page', + edits + )( { select, dispatch, resolveSelect } ); + + expect( prePersist ).toHaveBeenCalledWith( persistedRecord, edits ); + expect( apiFetch ).toHaveBeenCalledWith( { + path: '/wp/v2/pages/10', + method: 'PUT', + data: { ...edits, status: 'draft' }, + } ); + expect( syncManager.update ).toHaveBeenNthCalledWith( + 2, + 'postType/page', + 10, + { status: 'draft' }, + 'local-undo-ignored', + { isSave: true } + ); + expect( syncManager.update ).toHaveBeenCalledTimes( 2 ); + expect( result ).toBe( updatedRecord ); + } ); + + it( 'does not mark pre-synced direct save changes as saved when the request fails', async () => { + const persistedRecord = { + id: 10, + template: '', + }; + const edits = { + id: 10, + template: 'page-no-title', + }; + const configs = [ + { + name: 'page', + kind: 'postType', + baseURL: '/wp/v2/pages', + syncConfig: {}, + }, + ]; + const syncManager = { + update: jest.fn(), + }; + const select = { + getRawEntityRecord: () => persistedRecord, + }; + const resolveSelect = { getEntitiesConfig: jest.fn( () => configs ) }; + const error = new Error( 'API error' ); + apiFetch.mockRejectedValue( error ); + getSyncManager.mockReturnValue( syncManager ); + + await expect( + saveEntityRecord( 'postType', 'page', edits, { + throwOnError: true, + } )( { select, dispatch, resolveSelect } ) + ).rejects.toBe( error ); + + expect( syncManager.update ).toHaveBeenCalledTimes( 1 ); + expect( syncManager.update ).toHaveBeenCalledWith( + 'postType/page', + 10, + edits, + 'local-undo-ignored' + ); + } ); + + it( 'passes server-normalized edited fields to SyncManager#update after saving', async () => { + const persistedRecord = { + id: 10, + slug: 'initial-slug', + }; + const edits = { + id: 10, + slug: 'Needs Normalizing', + }; + const configs = [ + { + name: 'post', + kind: 'postType', + baseURL: '/wp/v2/posts', + syncConfig: {}, + }, + ]; + const syncManager = { + update: jest.fn(), + }; + const select = { + getRawEntityRecord: () => persistedRecord, + }; + const resolveSelect = { getEntitiesConfig: jest.fn( () => configs ) }; + const updatedRecord = { + id: 10, + slug: 'needs-normalizing', + }; + apiFetch.mockImplementation( () => updatedRecord ); + getSyncManager.mockReturnValue( syncManager ); + + const result = await saveEntityRecord( + 'postType', + 'post', + edits + )( { select, dispatch, resolveSelect } ); + + expect( syncManager.update ).toHaveBeenCalledWith( + 'postType/post', + 10, + { + slug: 'needs-normalizing', + }, + 'local-undo-ignored', + { isSave: true } + ); + expect( syncManager.update ).toHaveBeenCalledTimes( 2 ); + expect( result ).toBe( updatedRecord ); + } ); + + it( 'does not pass unchanged raw-attribute fields to SyncManager#update after saving', async () => { + // The raw entity record holds raw strings while the save response + // nests them as `{ raw, rendered }`; the comparison must not treat + // that shape difference as a server mutation. + const persistedRecord = { + id: 10, + title: 'Initial title', + content: '

Initial content

', + excerpt: 'Initial excerpt', + slug: 'initial-slug', + modified: '2026-07-01T00:00:00', + }; + const edits = { + id: 10, + slug: 'updated-slug', + }; + const configs = [ + { + name: 'post', + kind: 'postType', + baseURL: '/wp/v2/posts', + syncConfig: {}, + }, + ]; + const syncManager = { + update: jest.fn(), + }; + const select = { + getRawEntityRecord: () => persistedRecord, + }; + const resolveSelect = { getEntitiesConfig: jest.fn( () => configs ) }; + const updatedRecord = { + id: 10, + title: { raw: 'Initial title', rendered: 'Initial title' }, + content: { + raw: '

Initial content

', + rendered: '

Initial content

', + }, + excerpt: { raw: 'Initial excerpt', rendered: 'Initial excerpt' }, + slug: 'updated-slug', + modified: '2026-07-02T00:00:00', + }; + apiFetch.mockImplementation( () => updatedRecord ); + getSyncManager.mockReturnValue( syncManager ); + + const result = await saveEntityRecord( + 'postType', + 'post', + edits + )( { select, dispatch, resolveSelect } ); + + expect( syncManager.update ).toHaveBeenCalledWith( + 'postType/post', + 10, + { + modified: '2026-07-02T00:00:00', + }, + 'local-undo-ignored', + { isSave: true } + ); + expect( syncManager.update ).toHaveBeenCalledTimes( 2 ); + expect( result ).toBe( updatedRecord ); + } ); + + it( 'does not pass edited raw-attribute fields to SyncManager#update when the server echoes them unchanged', async () => { + const persistedRecord = { + id: 10, + content: '

Initial content

', + modified: '2026-07-01T00:00:00', + }; + const edits = { + id: 10, + content: '

Updated content

', + }; + const configs = [ + { + name: 'post', + kind: 'postType', + baseURL: '/wp/v2/posts', + syncConfig: {}, + }, + ]; + const syncManager = { + update: jest.fn(), + }; + const select = { + getRawEntityRecord: () => persistedRecord, + }; + const resolveSelect = { getEntitiesConfig: jest.fn( () => configs ) }; + const updatedRecord = { + id: 10, + content: { + raw: edits.content, + // The rendered value differs from the raw baseline, but only + // raw values are compared. + rendered: '

Updated content

\n', + }, + modified: '2026-07-02T00:00:00', + }; + apiFetch.mockImplementation( () => updatedRecord ); + getSyncManager.mockReturnValue( syncManager ); + + const result = await saveEntityRecord( + 'postType', + 'post', + edits + )( { select, dispatch, resolveSelect } ); + + expect( syncManager.update ).toHaveBeenCalledWith( + 'postType/post', + 10, + { + modified: '2026-07-02T00:00:00', + }, + 'local-undo-ignored', + { isSave: true } + ); + expect( result ).toBe( updatedRecord ); + } ); + + it( 'passes raw-attribute fields whose raw value the server mutated to SyncManager#update', async () => { + const persistedRecord = { + id: 10, + content: '

Initial content

', + }; + const edits = { + id: 10, + content: '

Content with markup

', + }; + const configs = [ + { + name: 'post', + kind: 'postType', + baseURL: '/wp/v2/posts', + syncConfig: {}, + }, + ]; + const syncManager = { + update: jest.fn(), + }; + const select = { + getRawEntityRecord: () => persistedRecord, + }; + const resolveSelect = { getEntitiesConfig: jest.fn( () => configs ) }; + // The server strips disallowed markup from the sent content. + const updatedRecord = { + id: 10, + content: { + raw: '

Content with bad markup

', + rendered: '

Content with bad markup

', + }, + }; + apiFetch.mockImplementation( () => updatedRecord ); + getSyncManager.mockReturnValue( syncManager ); + + const result = await saveEntityRecord( + 'postType', + 'post', + edits + )( { select, dispatch, resolveSelect } ); + + expect( syncManager.update ).toHaveBeenCalledWith( + 'postType/post', + 10, + { + content: updatedRecord.content, + }, + 'local-undo-ignored', + { isSave: true } + ); + expect( result ).toBe( updatedRecord ); + } ); + + it( 'passes the full save response to SyncManager#update when the persisted record is missing', async () => { + const edits = { + id: 10, + content: 'Updated content', + }; + const configs = [ + { + name: 'post', + kind: 'postType', + baseURL: '/wp/v2/posts', + syncConfig: {}, + }, + ]; + const syncManager = { + update: jest.fn(), + }; + const select = { + getRawEntityRecord: () => undefined, + }; + const resolveSelect = { getEntitiesConfig: jest.fn( () => configs ) }; + const updatedRecord = { + id: 10, + content: 'Updated content', + template: 'single', + }; + apiFetch.mockImplementation( () => updatedRecord ); + getSyncManager.mockReturnValue( syncManager ); + + const result = await saveEntityRecord( + 'postType', + 'post', + edits + )( { select, dispatch, resolveSelect } ); + + expect( syncManager.update ).toHaveBeenCalledWith( + 'postType/post', + 10, + updatedRecord, + 'local-undo-ignored', + { isSave: true } + ); + expect( syncManager.update ).toHaveBeenCalledTimes( 1 ); + expect( result ).toBe( updatedRecord ); + } ); + it( 'triggers a PUT request for an existing record with a custom key', async () => { const postType = { slug: 'page', title: 'Pages' }; const configs = [ diff --git a/packages/core-data/src/utils/crdt.ts b/packages/core-data/src/utils/crdt.ts index b86a49968c773d..5f4633b6124120 100644 --- a/packages/core-data/src/utils/crdt.ts +++ b/packages/core-data/src/utils/crdt.ts @@ -577,7 +577,7 @@ export const defaultCollectionSyncConfig: SyncConfig = { * @param {unknown} value The value to extract from. * @return {string|undefined} The raw string value, or undefined if it could not be determined. */ -function getRawValue( value?: unknown ): string | undefined { +export function getRawValue( value?: unknown ): string | undefined { // Value may be a string property or a nested object with a `raw` property. if ( 'string' === typeof value ) { return value;