Skip to content
Draft
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
1 change: 1 addition & 0 deletions packages/core-data/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
38 changes: 26 additions & 12 deletions packages/core-data/src/resolvers.js
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -23,6 +12,7 @@ import {
getUserPermissionsFromAllowHeader,
ALLOWED_RESOURCE_ACTIONS,
RECEIVE_INTERMEDIATE_RESULTS,
clearUnchangedEdits,
isNumericID,
normalizeQueryForResolution,
saveCRDTDoc,
Expand Down Expand Up @@ -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,
},
Expand Down
107 changes: 90 additions & 17 deletions packages/core-data/src/test/resolvers.js
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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 ) };
Expand Down Expand Up @@ -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 );
Expand Down Expand Up @@ -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 = {
Expand Down
Loading