Skip to content
Open
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
289 changes: 281 additions & 8 deletions packages/core-data/src/entities.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { capitalCase, pascalCase } from 'change-case';
import apiFetch from '@wordpress/api-fetch';
import { __unstableSerializeAndClean, parse } from '@wordpress/blocks';
import { __ } from '@wordpress/i18n';
import { addQueryArgs } from '@wordpress/url';

/**
* Internal dependencies
Expand All @@ -25,6 +26,145 @@ import {

export const DEFAULT_ENTITY_KEY = 'id';
const POST_RAW_ATTRIBUTES = [ 'title', 'excerpt', 'content' ];
const POST_TYPES_WITH_STALE_SAVE_PROTECTION = new Set( [ 'post', 'page' ] );

function getRawPostValue( value ) {
return value && typeof value === 'object' && 'raw' in value
? value.raw
: value;
}

function getSerializedBlockValue( block ) {
return __unstableSerializeAndClean( [ block ] ).trim();
}

function getSerializedCRDTBlockContent( crdtRecord ) {
return Array.isArray( crdtRecord?.blocks )
? __unstableSerializeAndClean( crdtRecord.blocks ).trim()
: undefined;
}

function getCRDTRawPostValue( crdtRecord, key ) {
if ( key === 'content' ) {
return (
getSerializedCRDTBlockContent( crdtRecord ) ??
getRawPostValue( crdtRecord?.content )
);
}

return getRawPostValue( crdtRecord?.[ key ] );
}

function areSerializedBlocksEqualAt( blocksA, blocksB, index ) {
return (
blocksA[ index ]?.name === blocksB[ index ]?.name &&
getSerializedBlockValue( blocksA[ index ] ) ===
getSerializedBlockValue( blocksB[ index ] )
);
}

function mergeStaleSerializedBlockContent(
baseContent,
latestContent,
localContent
) {
if (
typeof baseContent !== 'string' ||
typeof latestContent !== 'string' ||
typeof localContent !== 'string'
) {
return;
}

const baseBlocks = parse( baseContent );
const latestBlocks = parse( latestContent );
const localBlocks = parse( localContent );

if (
! baseBlocks.length ||
! latestBlocks.length ||
! localBlocks.length
) {
return;
}

if (
latestBlocks.length > localBlocks.length &&
baseBlocks.length === latestBlocks.length
) {
for ( let index = 0; index < localBlocks.length; index++ ) {
if ( localBlocks[ index ].name !== latestBlocks[ index ].name ) {
return;
}
}

return __unstableSerializeAndClean( [
...localBlocks,
...latestBlocks.slice( localBlocks.length ),
] );
}

if (
baseBlocks.length < latestBlocks.length &&
baseBlocks.length < localBlocks.length
) {
for ( let index = 0; index < baseBlocks.length; index++ ) {
if (
! areSerializedBlocksEqualAt(
baseBlocks,
latestBlocks,
index
) ||
! areSerializedBlocksEqualAt( baseBlocks, localBlocks, index )
) {
return;
}
}

return __unstableSerializeAndClean( [
...localBlocks,
...latestBlocks.slice( baseBlocks.length ),
] );
}

if (
baseBlocks.length !== latestBlocks.length ||
baseBlocks.length !== localBlocks.length
) {
return;
}

const mergedBlocks = [];

for ( let index = 0; index < baseBlocks.length; index++ ) {
const baseBlock = baseBlocks[ index ];
const latestBlock = latestBlocks[ index ];
const localBlock = localBlocks[ index ];

if (
baseBlock.name !== latestBlock.name ||
baseBlock.name !== localBlock.name
) {
return;
}

const baseValue = getSerializedBlockValue( baseBlock );
const latestValue = getSerializedBlockValue( latestBlock );
const localValue = getSerializedBlockValue( localBlock );

if ( localValue === latestValue ) {
mergedBlocks.push( localBlock );
} else if ( localValue === baseValue ) {
mergedBlocks.push( latestBlock );
} else if ( latestValue === baseValue ) {
mergedBlocks.push( localBlock );
} else {
return;
}
}

return __unstableSerializeAndClean( mergedBlocks );
}

const blocksTransientEdits = {
blocks: {
Expand Down Expand Up @@ -279,15 +419,31 @@ export const additionalEntityConfigLoaders = [
* @param {Object} edits Edits.
* @param {string} name Post type name.
* @param {boolean} isTemplate Whether the post type is a template.
* @param {string} baseURL REST base URL for the post type.
* @return {Promise< Object >} Updated edits.
*/
export const prePersistPostType = async (
persistedRecord,
edits,
name,
isTemplate
isTemplate,
baseURL
) => {
const newEdits = {};
const objectType = `postType/${ name }`;
const objectId = persistedRecord?.id;
let syncManager;
let serializedDoc;
let hasSerializedDoc = false;
const editedSavedFields = POST_RAW_ATTRIBUTES.filter(
( key ) => key in edits
);
const locallyChangedSavedFields = editedSavedFields.filter(
( key ) =>
getRawPostValue( edits[ key ] ) !==
getRawPostValue( persistedRecord?.[ key ] )
);
const locallyChangedSavedFieldSet = new Set( locallyChangedSavedFields );

if ( ! isTemplate && persistedRecord?.status === 'auto-draft' ) {
// Saving an auto-draft should create a draft by default.
Expand All @@ -306,14 +462,125 @@ export const prePersistPostType = async (
}
}

if (
window._wpCollaborationEnabled &&
POST_TYPES_WITH_STALE_SAVE_PROTECTION.has( name ) &&
baseURL &&
objectId &&
editedSavedFields.length
) {
try {
syncManager = getSyncManager();
serializedDoc = await syncManager?.createPersistedCRDTDoc(
objectType,
objectId
);
hasSerializedDoc = !! serializedDoc;
const latestRecord = await apiFetch( {
path: addQueryArgs( `${ baseURL }/${ objectId }`, {
context: 'edit',
} ),
} );
const serverChangedSavedFields = editedSavedFields.filter(
( key ) =>
getRawPostValue( latestRecord?.[ key ] ) !==
getRawPostValue( persistedRecord?.[ key ] )
);
for ( const key of serverChangedSavedFields ) {
if (
! locallyChangedSavedFieldSet.has( key ) &&
key in ( latestRecord ?? {} )
) {
newEdits[ key ] = getRawPostValue( latestRecord[ key ] );
}
}

const hasLatestPersistedCRDTDoc = Boolean(
latestRecord?.meta?.[ POST_META_KEY_FOR_CRDT_DOC_PERSISTENCE ]
);
const shouldApplyLatestCRDTDoc =
hasLatestPersistedCRDTDoc || locallyChangedSavedFields.length;
const didApplyLatestCRDTDoc = shouldApplyLatestCRDTDoc
? ( await syncManager?.applyPersistedCRDTDoc?.(
objectType,
objectId,
latestRecord
) ) ?? false
: false;

if (
didApplyLatestCRDTDoc ||
( hasLatestPersistedCRDTDoc && serverChangedSavedFields.length )
) {
serializedDoc = await syncManager?.createPersistedCRDTDoc(
objectType,
objectId
);
hasSerializedDoc = !! serializedDoc;

if (
hasLatestPersistedCRDTDoc &&
locallyChangedSavedFields.length
) {
const crdtRecord = syncManager?.getCRDTRecordData?.(
objectType,
objectId
);

for ( const key of locallyChangedSavedFields ) {
const hasCRDTValue =
key === 'content'
? key in ( crdtRecord ?? {} ) ||
Array.isArray( crdtRecord?.blocks )
: key in ( crdtRecord ?? {} );

if ( hasCRDTValue ) {
const crdtValue = getCRDTRawPostValue(
crdtRecord,
key
);

if (
crdtValue !==
getRawPostValue( latestRecord?.[ key ] )
) {
newEdits[ key ] = crdtValue;
}
}
}
}
}

if (
locallyChangedSavedFieldSet.has( 'content' ) &&
! ( 'content' in newEdits )
) {
const mergedContent = mergeStaleSerializedBlockContent(
getRawPostValue( persistedRecord?.content ),
getRawPostValue( latestRecord?.content ),
getRawPostValue( edits.content )
);

if (
mergedContent !== undefined &&
mergedContent !== getRawPostValue( edits.content )
) {
newEdits.content = mergedContent;
}
}
} catch {
// A failed freshness check should not block saving. The request itself
// will still surface any real save errors to the editor.
}
}

// Add meta for persisted CRDT document.
if ( persistedRecord ) {
const objectType = `postType/${ name }`;
const objectId = persistedRecord.id;
const serializedDoc = await getSyncManager()?.createPersistedCRDTDoc(
objectType,
objectId
);
if ( ! hasSerializedDoc ) {
serializedDoc = await (
syncManager ?? getSyncManager()
)?.createPersistedCRDTDoc( objectType, objectId );
}

if ( serializedDoc ) {
newEdits.meta = {
Expand Down Expand Up @@ -387,7 +654,13 @@ async function loadPostTypeEntities() {
? capitalCase( record.slug ?? '' )
: String( record.id ) ),
__unstablePrePersist: ( persistedRecord, edits ) =>
prePersistPostType( persistedRecord, edits, name, isTemplate ),
prePersistPostType(
persistedRecord,
edits,
name,
isTemplate,
`/${ namespace }/${ postType.rest_base }`
),
__unstable_rest_base: postType.rest_base,
supportsPagination: true,
getRevisionsUrl: ( parentId, revisionId ) =>
Expand Down
12 changes: 7 additions & 5 deletions packages/core-data/src/resolvers.js
Original file line number Diff line number Diff line change
Expand Up @@ -250,11 +250,13 @@ export const getEntityRecord =
// Trigger a save to persist the CRDT document. The entity's
// pre-persist hooks will create the persisted CRDT document
// and apply it to the record's meta.
dispatch.saveEntityRecord(
kind,
name,
editedRecord
);
const entityIdKey =
entityConfig.key || DEFAULT_ENTITY_KEY;
dispatch.saveEntityRecord( kind, name, {
[ entityIdKey ]:
editedRecord[ entityIdKey ] ?? key,
meta,
} );
} );
},
addUndoMeta: ( ydoc, meta ) => {
Expand Down
Loading
Loading