diff --git a/packages/core-data/CHANGELOG.md b/packages/core-data/CHANGELOG.md index 7a148befa7491e..0e63b71d95b93b 100644 --- a/packages/core-data/CHANGELOG.md +++ b/packages/core-data/CHANGELOG.md @@ -8,6 +8,7 @@ ### Bug Fixes +- Prevent no-op synced entity updates from marking records as dirty and triggering unsaved-changes warnings. - Footnotes: Treat unreadable `footnotes` post meta as no footnotes instead of throwing. Malformed JSON, or valid JSON that is not an array, threw inside a store subscriber where no error boundary catches it, so the edit was dropped and the post silently stopped saving ([#81201](https://github.com/WordPress/gutenberg/pull/81201)). - Ensure revision resolvers finish after fetched revisions are stored. diff --git a/packages/core-data/src/resolvers.js b/packages/core-data/src/resolvers.js index d69c5b8b8dfebe..3547c53f8dcba9 100644 --- a/packages/core-data/src/resolvers.js +++ b/packages/core-data/src/resolvers.js @@ -1,18 +1,7 @@ -/** - * External dependencies - */ import { camelCase } from 'change-case'; - -/** - * WordPress dependencies - */ import { addQueryArgs } from '@wordpress/url'; import { decodeEntities } from '@wordpress/html-entities'; import apiFetch from '@wordpress/api-fetch'; - -/** - * Internal dependencies - */ import { STORE_NAME } from './name'; import { additionalEntityConfigLoaders, DEFAULT_ENTITY_KEY } from './entities'; import { getSyncManager } from './sync'; @@ -23,6 +12,7 @@ import { getUserPermissionsFromAllowHeader, ALLOWED_RESOURCE_ACTIONS, RECEIVE_INTERMEDIATE_RESULTS, + clearUnchangedEdits, isNumericID, normalizeQueryForResolution, saveCRDTDoc, @@ -212,12 +202,36 @@ export const getEntityRecord = return; } + const normalizedEdits = clearUnchangedEdits( + edits, + select.getRawEntityRecord( kind, name, key ) + ); + const currentEdits = + select.getEntityRecordEdits( + kind, + name, + key + ) ?? {}; + // An undefined normalized value is still meaningful when it + // clears an existing local edit. + const hasChangesToApply = Object.entries( + normalizedEdits + ).some( + ( [ property, value ] ) => + undefined !== value || + property in currentEdits + ); + + if ( ! hasChangesToApply ) { + return; + } + dispatch( { type: 'EDIT_ENTITY_RECORD', kind, name, recordId: key, - edits, + edits: normalizedEdits, meta: { undo: undefined, }, diff --git a/packages/core-data/src/test/resolvers.js b/packages/core-data/src/test/resolvers.js index 037a7df505488b..61b1415074db7c 100644 --- a/packages/core-data/src/test/resolvers.js +++ b/packages/core-data/src/test/resolvers.js @@ -1,22 +1,5 @@ -/** - * WordPress dependencies - */ import triggerFetch from '@wordpress/api-fetch'; - -/** - * Internal dependencies - */ import { getSyncManager } from '../sync'; - -jest.mock( '@wordpress/api-fetch' ); -jest.mock( '../sync', () => ( { - getSyncManager: jest.fn(), - LOCAL_UNDO_IGNORED_ORIGIN: 'local-undo-ignored', -} ) ); - -/** - * Internal dependencies - */ import { getEntityRecord, getEntityRecords, @@ -27,6 +10,12 @@ import { } from '../resolvers'; import { RECEIVE_INTERMEDIATE_RESULTS } from '../utils'; +jest.mock( '@wordpress/api-fetch' ); +jest.mock( '../sync', () => ( { + getSyncManager: jest.fn(), + LOCAL_UNDO_IGNORED_ORIGIN: 'local-undo-ignored', +} ) ); + describe( 'getEntityRecord', () => { const POST_TYPE = { slug: 'post' }; const POST_TYPE_RESPONSE = { json: () => Promise.resolve( POST_TYPE ) }; @@ -62,6 +51,44 @@ describe( 'getEntityRecord', () => { getSyncManager.mockImplementation( () => syncManager ); } ); + const loadSyncedPost = async ( postRecord, currentEdits = {} ) => { + const postResponse = { + json: () => Promise.resolve( postRecord ), + }; + const entitiesWithSync = [ + { + name: 'post', + kind: 'postType', + baseURL: '/wp/v2/posts', + baseURLParams: { context: 'edit' }, + syncConfig: {}, + }, + ]; + const select = { + getEntityRecordEdits: jest.fn( () => currentEdits ), + getRawEntityRecord: jest.fn( () => postRecord ), + }; + const resolveSelectWithSync = { + getEntitiesConfig: jest.fn( () => entitiesWithSync ), + getEditedEntityRecord: jest.fn(), + }; + + triggerFetch.mockImplementation( () => postResponse ); + + await getEntityRecord( + 'postType', + 'post', + 1 + )( { + select, + dispatch, + registry, + resolveSelect: resolveSelectWithSync, + } ); + + return syncManager.load.mock.calls[ 0 ][ 4 ]; + }; + it( 'yields with requested post type', async () => { // Provide response triggerFetch.mockImplementation( () => POST_TYPE_RESPONSE ); @@ -183,6 +210,52 @@ describe( 'getEntityRecord', () => { ); } ); + it( 'does not mark an entity as edited when a synced update matches the persisted record', async () => { + const POST_RECORD = { + id: 1, + meta: { nested: [ 'value' ] }, + title: 'Test Post', + }; + const handlers = await loadSyncedPost( POST_RECORD ); + handlers.editRecord( { meta: { nested: [ 'value' ] } } ); + + expect( dispatch ).not.toHaveBeenCalled(); + } ); + + it( 'applies a synced update when it differs from the persisted record', async () => { + const POST_RECORD = { id: 1, title: 'Test Post' }; + const handlers = await loadSyncedPost( POST_RECORD ); + handlers.editRecord( { title: 'Synced title' } ); + + expect( dispatch ).toHaveBeenCalledWith( { + type: 'EDIT_ENTITY_RECORD', + kind: 'postType', + name: 'post', + recordId: 1, + edits: { title: 'Synced title' }, + meta: { undo: undefined }, + options: {}, + } ); + } ); + + it( 'clears a local edit when a synced update restores the persisted value', async () => { + const POST_RECORD = { id: 1, title: 'Test Post' }; + const handlers = await loadSyncedPost( POST_RECORD, { + title: 'Local title', + } ); + handlers.editRecord( { title: 'Test Post' } ); + + expect( dispatch ).toHaveBeenCalledWith( { + type: 'EDIT_ENTITY_RECORD', + kind: 'postType', + name: 'post', + recordId: 1, + edits: { title: undefined }, + meta: { undo: undefined }, + options: {}, + } ); + } ); + it( 'does not load entity with sync manager when collaboration is unsupported', async () => { const POST_RECORD = { id: 1, title: 'Test Post' }; const POST_RESPONSE = {