Skip to content

Commit a1350dd

Browse files
alecgeatchesalecgeatches
authored andcommitted
Real-time collaboration: Expand mergeCrdtBlocks() automated testing (#75923)
* Add tests for mergeCrdtBlocks() edge cases * Separate type-specific attribute logic into updateYBlockAttribute(), fix test Co-authored-by: alecgeatches <alecgeatches@git.wordpress.org>
1 parent 17eeb51 commit a1350dd

File tree

2 files changed

+703
-46
lines changed

2 files changed

+703
-46
lines changed

packages/core-data/src/utils/crdt-blocks.ts

Lines changed: 112 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -283,43 +283,29 @@ export function mergeCrdtBlocks(
283283

284284
Object.entries( value ).forEach(
285285
( [ attributeName, attributeValue ] ) => {
286-
if (
287-
fastDeepEqual(
288-
currentAttributes?.get( attributeName ),
289-
attributeValue
290-
)
291-
) {
292-
return;
293-
}
294-
295286
const currentAttribute =
296-
currentAttributes.get( attributeName );
297-
const isRichText = isRichTextAttribute(
287+
currentAttributes?.get( attributeName );
288+
289+
const isExpectedType = isExpectedAttributeType(
298290
block.name,
299-
attributeName
291+
attributeName,
292+
currentAttribute
300293
);
301294

302-
if (
303-
isRichText &&
304-
'string' === typeof attributeValue &&
305-
currentAttributes.has( attributeName ) &&
306-
currentAttribute instanceof Y.Text
307-
) {
308-
// Rich text values are stored as persistent Y.Text instances.
309-
// Update the value with a delta in place.
310-
mergeRichTextUpdate(
295+
const isAttributeChanged =
296+
! isExpectedType ||
297+
! fastDeepEqual(
311298
currentAttribute,
312-
attributeValue,
313-
cursorPosition
299+
attributeValue
314300
);
315-
} else {
316-
currentAttributes.set(
301+
302+
if ( isAttributeChanged ) {
303+
updateYBlockAttribute(
304+
block.name,
317305
attributeName,
318-
createNewYAttributeValue(
319-
block.name,
320-
attributeName,
321-
attributeValue
322-
)
306+
attributeValue,
307+
currentAttributes,
308+
cursorPosition
323309
);
324310
}
325311
}
@@ -422,45 +408,125 @@ function shouldBlockBeSynced( block: Block ): boolean {
422408
return true;
423409
}
424410

425-
// Cache rich-text attributes for all block types.
426-
let cachedRichTextAttributes: Map< string, Map< string, true > >;
411+
/**
412+
* Update a single attribute on a Yjs block attributes map (currentAttributes).
413+
*
414+
* For rich-text attributes that already exist as Y.Text instances, the update
415+
* is applied as a delta merge so that concurrent edits are preserved. All
416+
* other attributes are replaced wholesale via `createNewYAttributeValue`.
417+
*
418+
* @param blockName The block type name, e.g. 'core/paragraph'.
419+
* @param attributeName The name of the attribute to update, e.g. 'content'.
420+
* @param attributeValue The new value for the attribute.
421+
* @param currentAttributes The Y.Map holding the block's current attributes.
422+
* @param cursorPosition The local cursor position, used when merging rich-text deltas.
423+
*/
424+
function updateYBlockAttribute(
425+
blockName: string,
426+
attributeName: string,
427+
attributeValue: unknown,
428+
currentAttributes: YBlockAttributes,
429+
cursorPosition: number | null
430+
): void {
431+
const isRichText = isRichTextAttribute( blockName, attributeName );
432+
const currentAttribute = currentAttributes.get( attributeName );
433+
434+
if (
435+
isRichText &&
436+
'string' === typeof attributeValue &&
437+
currentAttributes.has( attributeName ) &&
438+
currentAttribute instanceof Y.Text
439+
) {
440+
// Rich text values are stored as persistent Y.Text instances.
441+
// Update the value with a delta in place.
442+
mergeRichTextUpdate( currentAttribute, attributeValue, cursorPosition );
443+
} else {
444+
currentAttributes.set(
445+
attributeName,
446+
createNewYAttributeValue( blockName, attributeName, attributeValue )
447+
);
448+
}
449+
}
450+
451+
// Cached types for block attributes.
452+
let cachedBlockAttributeTypes: Map< string, Map< string, string > >;
427453

428454
/**
429-
* Given a block name and attribute key, return true if the attribute is rich-text typed.
455+
* Get the defined attribute type for a block attribute.
430456
*
431457
* @param blockName The name of the block, e.g. 'core/paragraph'.
432-
* @param attributeName The name of the attribute to check, e.g. 'content'.
433-
* @return True if the attribute is rich-text typed, false otherwise.
458+
* @param attributeName The name of the attribute, e.g. 'content'.
459+
* @return The type of the attribute, e.g. 'rich-text' or 'string'.
434460
*/
435-
function isRichTextAttribute(
461+
function getBlockAttributeType(
436462
blockName: string,
437463
attributeName: string
438-
): boolean {
439-
if ( ! cachedRichTextAttributes ) {
464+
): string | undefined {
465+
if ( ! cachedBlockAttributeTypes ) {
440466
// Parse the attributes for all blocks once.
441-
cachedRichTextAttributes = new Map< string, Map< string, true > >();
467+
cachedBlockAttributeTypes = new Map< string, Map< string, string > >();
442468

443469
for ( const blockType of getBlockTypes() as BlockType[] ) {
444-
const richTextAttributeMap = new Map< string, true >();
470+
const blockAttributeTypeMap = new Map< string, string >();
445471

446472
for ( const [ name, definition ] of Object.entries(
447473
blockType.attributes ?? {}
448474
) ) {
449-
if ( 'rich-text' === definition.type ) {
450-
richTextAttributeMap.set( name, true );
475+
if ( definition.type ) {
476+
blockAttributeTypeMap.set( name, definition.type );
451477
}
452478
}
453479

454-
cachedRichTextAttributes.set(
480+
cachedBlockAttributeTypes.set(
455481
blockType.name,
456-
richTextAttributeMap
482+
blockAttributeTypeMap
457483
);
458484
}
459485
}
460486

461-
return (
462-
cachedRichTextAttributes.get( blockName )?.has( attributeName ) ?? false
487+
return cachedBlockAttributeTypes.get( blockName )?.get( attributeName );
488+
}
489+
490+
/**
491+
* Check if an attribute value is the expected type.
492+
*
493+
* @param blockName The name of the block, e.g. 'core/paragraph'.
494+
* @param attributeName The name of the attribute, e.g. 'content'.
495+
* @param attributeValue The current attribute value.
496+
* @return True if the attribute type is expected, false otherwise.
497+
*/
498+
function isExpectedAttributeType(
499+
blockName: string,
500+
attributeName: string,
501+
attributeValue: unknown
502+
): boolean {
503+
const expectedAttributeType = getBlockAttributeType(
504+
blockName,
505+
attributeName
463506
);
507+
508+
if ( expectedAttributeType === 'rich-text' ) {
509+
return attributeValue instanceof Y.Text;
510+
} else if ( expectedAttributeType === 'string' ) {
511+
return typeof attributeValue === 'string';
512+
}
513+
514+
// No other types comparisons use special logic.
515+
return true;
516+
}
517+
518+
/**
519+
* Given a block name and attribute key, return true if the attribute is rich-text typed.
520+
*
521+
* @param blockName The name of the block, e.g. 'core/paragraph'.
522+
* @param attributeName The name of the attribute to check, e.g. 'content'.
523+
* @return True if the attribute is rich-text typed, false otherwise.
524+
*/
525+
function isRichTextAttribute(
526+
blockName: string,
527+
attributeName: string
528+
): boolean {
529+
return 'rich-text' === getBlockAttributeType( blockName, attributeName );
464530
}
465531

466532
let localDoc: Y.Doc;

0 commit comments

Comments
 (0)