Repository navigation
Real-time collaboration: Add more robust mergeCrdtBlocks() testing #74304
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
1e9d100
edb9b8e
9a652b0
cdfe518
b283ed9
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -193,6 +193,26 @@ export function mergeCrdtBlocks( | |
| yblocks: YBlocks, | ||
| incomingBlocks: Block[], | ||
| cursorPosition: number | null | ||
| ): void { | ||
| // Perform the merge | ||
| mergeCrdtBlocksInternal( yblocks, incomingBlocks, cursorPosition ); | ||
|
|
||
| // Remove duplicate clientIds across all nesting levels (at top level) | ||
| removeDuplicateClientIds( yblocks ); | ||
| } | ||
|
|
||
| /** | ||
| * Internal function to merge blocks without any top-level checks. | ||
| * Called recursively for innerBlocks. | ||
| * | ||
| * @param yblocks The blocks in the local Y.Doc. | ||
| * @param incomingBlocks Gutenberg blocks being synced. | ||
| * @param cursorPosition The position of the cursor after the change occurs. | ||
| */ | ||
| function mergeCrdtBlocksInternal( | ||
| yblocks: YBlocks, | ||
| incomingBlocks: Block[], | ||
| cursorPosition: number | null | ||
| ): void { | ||
| // Ensure we are working with serializable block data. | ||
| if ( ! serializableBlocksCache.has( incomingBlocks ) ) { | ||
|
|
@@ -282,43 +302,29 @@ export function mergeCrdtBlocks( | |
|
|
||
| Object.entries( value ).forEach( | ||
| ( [ attributeName, attributeValue ] ) => { | ||
| if ( | ||
| fastDeepEqual( | ||
| currentAttributes?.get( attributeName ), | ||
| attributeValue | ||
| ) | ||
| ) { | ||
| return; | ||
| } | ||
|
|
||
| const currentAttribute = | ||
| currentAttributes.get( attributeName ); | ||
| const isRichText = isRichTextAttribute( | ||
|
|
||
| const isExpectedType = isExpectedAttributeType( | ||
| block.name, | ||
| attributeName | ||
| attributeName, | ||
| currentAttribute | ||
| ); | ||
|
|
||
| if ( | ||
| isRichText && | ||
| 'string' === typeof attributeValue && | ||
| currentAttributes.has( attributeName ) && | ||
| currentAttribute instanceof Y.Text | ||
| ) { | ||
| // Rich text values are stored as persistent Y.Text instances. | ||
| // Update the value with a delta in place. | ||
| mergeRichTextUpdate( | ||
| const isAttributeChanged = | ||
| ! isExpectedType || | ||
| ! fastDeepEqual( | ||
| currentAttribute, | ||
| attributeValue, | ||
| cursorPosition | ||
| attributeValue | ||
| ); | ||
| } else { | ||
| currentAttributes.set( | ||
|
|
||
| if ( isAttributeChanged ) { | ||
| updateYBlockAttribute( | ||
| block.name, | ||
| attributeName, | ||
| createNewYAttributeValue( | ||
| block.name, | ||
| attributeName, | ||
| attributeValue | ||
| ) | ||
| attributeValue, | ||
| currentAttributes, | ||
| cursorPosition | ||
| ); | ||
| } | ||
| } | ||
|
|
@@ -345,7 +351,7 @@ export function mergeCrdtBlocks( | |
| yblock.set( key, yInnerBlocks ); | ||
| } | ||
|
|
||
| mergeCrdtBlocks( | ||
| mergeCrdtBlocksInternal( | ||
| yInnerBlocks, | ||
| value ?? [], | ||
| cursorPosition | ||
|
|
@@ -375,23 +381,36 @@ export function mergeCrdtBlocks( | |
|
|
||
| yblocks.insert( left, newBlock ); | ||
| } | ||
| } | ||
|
|
||
| // remove duplicate clientids | ||
| const knownClientIds = new Set< string >(); | ||
| /** | ||
| * Recursively remove duplicate clientIds from blocks and their innerBlocks. | ||
| * | ||
| * @param yblocks The blocks to check. | ||
| * @param knownClientIds Set of clientIds seen so far. | ||
| */ | ||
| function removeDuplicateClientIds( | ||
| yblocks: YBlocks, | ||
| knownClientIds: Set< string > = new Set() | ||
| ): void { | ||
| for ( let j = 0; j < yblocks.length; j++ ) { | ||
| const yblock: YBlock = yblocks.get( j ); | ||
|
|
||
| let clientId = yblock.get( 'clientId' ); | ||
|
|
||
| if ( ! clientId ) { | ||
| continue; | ||
| if ( clientId ) { | ||
|
Comment on lines
-386
to
+401
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. is this logic inverted for any particular reason?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This just allows the recursion on
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No particular insight. IIRC it was copied over from Kevin's initial implementation.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This bit of code had to change to accommodate client ID deduping of the whole tree, and I think this change makes sense in that context. I can also remove this small overhaul if you want to keep it the same, but it made more sense to me to dedupe across more than one level at a time. |
||
| if ( knownClientIds.has( clientId ) ) { | ||
| clientId = uuidv4(); | ||
| yblock.set( 'clientId', clientId ); | ||
| } | ||
| knownClientIds.add( clientId ); | ||
| } | ||
|
|
||
| if ( knownClientIds.has( clientId ) ) { | ||
| clientId = uuidv4(); | ||
| yblock.set( 'clientId', clientId ); | ||
| // Recursively check innerBlocks | ||
| const yInnerBlocks = yblock.get( 'innerBlocks' ); | ||
| if ( yInnerBlocks instanceof Y.Array ) { | ||
| removeDuplicateClientIds( yInnerBlocks, knownClientIds ); | ||
| } | ||
| knownClientIds.add( clientId ); | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -428,45 +447,112 @@ function shouldBlockBeSynced( block: Block ): boolean { | |
| return true; | ||
| } | ||
|
|
||
| function updateYBlockAttribute( | ||
| blockName: string, | ||
| attributeName: string, | ||
| attributeValue: unknown, | ||
| currentAttributes: YBlockAttributes, | ||
| cursorPosition: number | null | ||
| ): void { | ||
| const isRichText = isRichTextAttribute( blockName, attributeName ); | ||
| const currentAttribute = currentAttributes.get( attributeName ); | ||
|
|
||
| if ( | ||
| isRichText && | ||
| 'string' === typeof attributeValue && | ||
| currentAttributes.has( attributeName ) && | ||
| currentAttribute instanceof Y.Text | ||
| ) { | ||
| // Rich text values are stored as persistent Y.Text instances. | ||
| // Update the value with a delta in place. | ||
| mergeRichTextUpdate( currentAttribute, attributeValue, cursorPosition ); | ||
| } else { | ||
| currentAttributes.set( | ||
| attributeName, | ||
| createNewYAttributeValue( blockName, attributeName, attributeValue ) | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| // Cache rich-text attributes for all block types. | ||
| let cachedRichTextAttributes: Map< string, Map< string, true > >; | ||
| let cachedBlockAttributeTypes: Map< string, Map< string, string > >; | ||
|
|
||
| /** | ||
| * Given a block name and attribute key, return true if the attribute is rich-text typed. | ||
| * Get the defined attribute type for a block attribute. | ||
| * | ||
| * @param blockName The name of the block, e.g. 'core/paragraph'. | ||
| * @param attributeName The name of the attribute to check, e.g. 'content'. | ||
| * @return True if the attribute is rich-text typed, false otherwise. | ||
| * @param attributeName The name of the attribute, e.g. 'content'. | ||
| * @return The type of the attribute, e.g. 'rich-text' or 'string'. | ||
| */ | ||
| function isRichTextAttribute( | ||
| function getBlockAttributeType( | ||
| blockName: string, | ||
| attributeName: string | ||
| ): boolean { | ||
| if ( ! cachedRichTextAttributes ) { | ||
| ): string | undefined { | ||
| if ( ! cachedBlockAttributeTypes ) { | ||
| // Parse the attributes for all blocks once. | ||
| cachedRichTextAttributes = new Map< string, Map< string, true > >(); | ||
| cachedBlockAttributeTypes = new Map< string, Map< string, string > >(); | ||
|
|
||
| for ( const blockType of getBlockTypes() as BlockType[] ) { | ||
| const richTextAttributeMap = new Map< string, true >(); | ||
| const blockAttributeTypeMap = new Map< string, string >(); | ||
|
|
||
| for ( const [ name, definition ] of Object.entries( | ||
| blockType.attributes ?? {} | ||
| ) ) { | ||
| if ( 'rich-text' === definition.type ) { | ||
| richTextAttributeMap.set( name, true ); | ||
| if ( definition.type ) { | ||
| blockAttributeTypeMap.set( name, definition.type ); | ||
| } | ||
| } | ||
|
|
||
| cachedRichTextAttributes.set( | ||
| cachedBlockAttributeTypes.set( | ||
| blockType.name, | ||
| richTextAttributeMap | ||
| blockAttributeTypeMap | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| return ( | ||
| cachedRichTextAttributes.get( blockName )?.has( attributeName ) ?? false | ||
| return cachedBlockAttributeTypes.get( blockName )?.get( attributeName ); | ||
| } | ||
|
|
||
| /** | ||
| * Check if an attribute value is the expected type. | ||
| * | ||
| * @param blockName The name of the block, e.g. 'core/paragraph'. | ||
| * @param attributeName The name of the attribute, e.g. 'content'. | ||
| * @param attributeValue The current attribute value. | ||
| * @return True if the attribute type is expected, false otherwise. | ||
| */ | ||
| function isExpectedAttributeType( | ||
| blockName: string, | ||
| attributeName: string, | ||
| attributeValue: unknown | ||
| ): boolean { | ||
| const expectedAttributeType = getBlockAttributeType( | ||
| blockName, | ||
| attributeName | ||
| ); | ||
|
|
||
| if ( expectedAttributeType === 'rich-text' ) { | ||
| return attributeValue instanceof Y.Text; | ||
| } else if ( expectedAttributeType === 'string' ) { | ||
| return typeof attributeValue === 'string'; | ||
| } | ||
|
|
||
| // No other types comparisons use special logic. | ||
| return true; | ||
| } | ||
|
|
||
| /** | ||
| * Given a block name and attribute key, return true if the attribute is rich-text typed. | ||
| * | ||
| * @param blockName The name of the block, e.g. 'core/paragraph'. | ||
| * @param attributeName The name of the attribute to check, e.g. 'content'. | ||
| * @return True if the attribute is rich-text typed, false otherwise. | ||
| */ | ||
| function isRichTextAttribute( | ||
| blockName: string, | ||
| attributeName: string | ||
| ): boolean { | ||
| return 'rich-text' === getBlockAttributeType( blockName, attributeName ); | ||
| } | ||
|
|
||
| let localDoc: Y.Doc; | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could we conceivably have other attribute type transitions that we need to expect? Is there another more flexible approach that would allow us to add them in the future (and mark the location accordingly)?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Addressed in cdfe518, let me know what you think! This adds
isExpectedAttributeType()which is a less brittle way of doing the same check, and modifies our block attribute cache to store all expected block types, not just those for rich text.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actually, let me add one more change to do the same for the
isRichTextif statement.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Added some more changes in b283ed9 to move type-specific logic into another function.