From 189697e843f9203b2a8d5db63b6be6183538902c Mon Sep 17 00:00:00 2001 From: Alec Geatches Date: Wed, 27 Aug 2025 13:31:53 -0600 Subject: [PATCH 01/12] Change 'mergeBlocks()' to 'mergeCrdtBlocks()' to make it distinct --- packages/core-data/src/utils/crdt-blocks.ts | 2 +- packages/core-data/src/utils/crdt.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/core-data/src/utils/crdt-blocks.ts b/packages/core-data/src/utils/crdt-blocks.ts index 7f7327c2782db7..fd54c0a1659903 100644 --- a/packages/core-data/src/utils/crdt-blocks.ts +++ b/packages/core-data/src/utils/crdt-blocks.ts @@ -90,7 +90,7 @@ function areBlocksEqual( gblock: Block, yblock: YBlock ): boolean { ); } -export function mergeBlocks( +export function mergeCrdtBlocks( yblocks: Y.Array< YBlock >, newValue: Block[] | Y.Array< YBlock >, _origin: string // eslint-disable-line @typescript-eslint/no-unused-vars diff --git a/packages/core-data/src/utils/crdt.ts b/packages/core-data/src/utils/crdt.ts index 5cfb047a16b310..c9e74a532e36d5 100644 --- a/packages/core-data/src/utils/crdt.ts +++ b/packages/core-data/src/utils/crdt.ts @@ -11,7 +11,7 @@ import { type CRDTDoc, Y } from '@wordpress/sync'; /** * Internal dependencies */ -import { mergeBlocks, type Block, type YBlock } from './crdt-blocks'; +import { mergeCrdtBlocks, type Block, type YBlock } from './crdt-blocks'; type PrimitiveValue = string | number | boolean | null | undefined; @@ -52,7 +52,7 @@ export function defaultApplyChangesToCRDTDoc( // Merge blocks does not need `setValue` because it has been // called above and the result can be operated on directly. - mergeBlocks( currentBlocks, newBlocks, origin ); + mergeCrdtBlocks( currentBlocks, newBlocks, origin ); break; } From ec2487294f900b7115dca12c95fd19c551021d31 Mon Sep 17 00:00:00 2001 From: Alec Geatches Date: Wed, 27 Aug 2025 15:59:44 -0600 Subject: [PATCH 02/12] Change mergeCrdtBlocks() to use direct Y types for yblocks. First step to standardizing Y types stored in the ydoc --- packages/core-data/src/utils/crdt-blocks.ts | 77 +++++++++++++++------ packages/core-data/src/utils/crdt.ts | 4 +- 2 files changed, 57 insertions(+), 24 deletions(-) diff --git a/packages/core-data/src/utils/crdt-blocks.ts b/packages/core-data/src/utils/crdt-blocks.ts index fd54c0a1659903..ab10cca89eae7c 100644 --- a/packages/core-data/src/utils/crdt-blocks.ts +++ b/packages/core-data/src/utils/crdt-blocks.ts @@ -24,11 +24,22 @@ export interface Block { name: string; } +export type YBlock = Y.Map< + /* name, clientId, and originalContent are strings. */ + | string + /* validationIssues? is an array of strings. */ + | string[] + /* attributes is a Y.Map< unknown >. */ + | Y.Map< unknown > + /* innerBlocks is a Y.Array< YBlock >. */ + | Y.Array< YBlock > +>; + // The Y.Map type is not easy to work with. The generic type it accepts represents // the possible values of the map, which are varied in our case. This type is // accurate, but will require aggressive type narrowing when the map values are // accessed -- or type casting with `as`. -export type YBlock = Y.Map< Block[ keyof Block ] >; +// export type YBlock = Y.Map< Block[ keyof Block ] >; const serializableBlocksCache = new WeakMap< WeakKey, Block[] >(); @@ -90,22 +101,31 @@ function areBlocksEqual( gblock: Block, yblock: YBlock ): boolean { ); } +/** + * Merge incoming block data into the local Y.Doc. + * This function is called to sync local block changes to a shared Y.Doc. + * + * @param yblocks The blocks in the local Y.Doc. + * @param incomingBlocks Gutenberg blocks being synced. + * @param _origin The origin of the sync, either 'syncProvider.getInitialCRDTDoc' or 'gutenberg'. + */ + export function mergeCrdtBlocks( - yblocks: Y.Array< YBlock >, - newValue: Block[] | Y.Array< YBlock >, + yblocks: Y.Array< YBlock >, // yblocks represent the blocks in the local Y.Doc + incomingBlocks: Block[], // incomingBlocks represent JSON blocks being synced, either from a peer or from the local editor _origin: string // eslint-disable-line @typescript-eslint/no-unused-vars ): void { // Ensure we are working with serializable block data. - if ( ! serializableBlocksCache.has( newValue ) ) { + if ( ! serializableBlocksCache.has( incomingBlocks ) ) { serializableBlocksCache.set( - newValue, - makeBlocksSerializable( newValue ) + incomingBlocks, + makeBlocksSerializable( incomingBlocks ) ); } - const unfilteredBlocks = serializableBlocksCache.get( newValue ) ?? []; + const allBlocks = serializableBlocksCache.get( incomingBlocks ) ?? []; // Ensure we skip blocks that we don't want to sync at the moment - const blocks = unfilteredBlocks.filter( ( block ) => + const blocksToSync = allBlocks.filter( ( block ) => shouldBlockBeSynced( block ) ); @@ -119,8 +139,10 @@ export function mergeCrdtBlocks( // E.g.: // - textual content (using rich-text formatting?) may always be stored under `block.text` // - local information that shouldn't be shared (e.g. clientId or isDragging) is stored under `block.private` - - const numOfCommonEntries = math.min( blocks.length ?? 0, yblocks.length ); + const numOfCommonEntries = math.min( + blocksToSync.length ?? 0, + yblocks.length + ); let left = 0; let right = 0; @@ -129,7 +151,7 @@ export function mergeCrdtBlocks( for ( ; left < numOfCommonEntries && - areBlocksEqual( blocks[ left ], yblocks.get( left ) ); + areBlocksEqual( blocksToSync[ left ], yblocks.get( left ) ); left++ ) { /* nop */ @@ -140,7 +162,7 @@ export function mergeCrdtBlocks( ; right < numOfCommonEntries - left && areBlocksEqual( - blocks[ blocks.length - right - 1 ], + blocksToSync[ blocksToSync.length - right - 1 ], yblocks.get( yblocks.length - right - 1 ) ); right++ @@ -149,15 +171,28 @@ export function mergeCrdtBlocks( } const numOfUpdatesNeeded = numOfCommonEntries - left - right; - const numOfInsertionsNeeded = math.max( 0, blocks.length - yblocks.length ); - const numOfDeletionsNeeded = math.max( 0, yblocks.length - blocks.length ); + const numOfInsertionsNeeded = math.max( + 0, + blocksToSync.length - yblocks.length + ); + const numOfDeletionsNeeded = math.max( + 0, + yblocks.length - blocksToSync.length + ); // updates for ( let i = 0; i < numOfUpdatesNeeded; i++, left++ ) { - const block = blocks[ left ]; + const block = blocksToSync[ left ]; const yblock = yblocks.get( left ); Object.entries( block ).forEach( ( [ k, v ] ) => { if ( ! fun.equalityDeep( block[ k ], yblock.get( k ) ) ) { + if ( k === 'innerBlocks' ) { + // Recursively merge innerBlocks + const yInnerBlocks = yblock.get( k ) as Y.Array< YBlock >; + + mergeCrdtBlocks( yInnerBlocks, v, _origin ); + } + yblock.set( k, v ); } } ); @@ -173,17 +208,17 @@ export function mergeCrdtBlocks( // inserts for ( let i = 0; i < numOfInsertionsNeeded; i++, left++ ) { - yblocks.insert( left, [ - new Y.Map< Block[ keyof Block ] >( - Object.entries( blocks[ left ] ) - ), - ] ); + const newBlock = [ + new Y.Map( Object.entries( blocksToSync[ left ] ) ) as YBlock, + ]; + + yblocks.insert( left, newBlock ); } // remove duplicate clientids const knownClientIds = new Set< string >(); for ( let j = 0; j < yblocks.length; j++ ) { - const yblock: Y.Map< Block[ keyof Block ] > = yblocks.get( j ); + const yblock: YBlock = yblocks.get( j ); let clientId: string = yblock.get( 'clientId' ) as string; diff --git a/packages/core-data/src/utils/crdt.ts b/packages/core-data/src/utils/crdt.ts index c9e74a532e36d5..3ccbd67cafd0ab 100644 --- a/packages/core-data/src/utils/crdt.ts +++ b/packages/core-data/src/utils/crdt.ts @@ -37,9 +37,7 @@ export function defaultApplyChangesToCRDTDoc( switch ( key ) { case 'blocks': { - let currentBlocks = ymap.get( - 'blocks' - ) as PostChanges[ 'blocks' ]; + let currentBlocks = ymap.get( 'blocks' ) as Y.Array< YBlock >; if ( ! ( currentBlocks instanceof Y.Array ) ) { currentBlocks = setValue< Y.Array< YBlock > >( From ec8df1ba05093c0308acce434cfcb6ee7094953f Mon Sep 17 00:00:00 2001 From: Alec Geatches Date: Thu, 28 Aug 2025 11:03:58 -0600 Subject: [PATCH 03/12] Fix CRDT merge object equality check against yBlockAsJson --- packages/core-data/src/utils/crdt-blocks.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core-data/src/utils/crdt-blocks.ts b/packages/core-data/src/utils/crdt-blocks.ts index ab10cca89eae7c..033e6c6432394f 100644 --- a/packages/core-data/src/utils/crdt-blocks.ts +++ b/packages/core-data/src/utils/crdt-blocks.ts @@ -88,7 +88,7 @@ function areBlocksEqual( gblock: Block, yblock: YBlock ): boolean { }; const res = fun.equalityDeep( Object.assign( {}, gblock, overwrites ), - Object.assign( {}, yblock, overwrites ) + Object.assign( {}, yblockAsJson, overwrites ) ); const inners = gblock.innerBlocks || []; const yinners = yblockAsJson.innerBlocks || []; From dd71bdf923458ee85234b1bdd3d3051519ee61ba Mon Sep 17 00:00:00 2001 From: chriszarate Date: Thu, 28 Aug 2025 11:54:58 -0600 Subject: [PATCH 04/12] Add createNewYBlock for recursive insert --- packages/core-data/src/utils/crdt-blocks.ts | 32 +++++++++++++++++++-- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/packages/core-data/src/utils/crdt-blocks.ts b/packages/core-data/src/utils/crdt-blocks.ts index 033e6c6432394f..40176ef06e3828 100644 --- a/packages/core-data/src/utils/crdt-blocks.ts +++ b/packages/core-data/src/utils/crdt-blocks.ts @@ -101,6 +101,34 @@ function areBlocksEqual( gblock: Block, yblock: YBlock ): boolean { ); } +function createNewYBlock( block: Block ): YBlock { + return new Y.Map( + Object.entries( block ).map( ( [ key, value ] ) => { + switch ( key ) { + case 'innerBlocks': { + if ( Array.isArray( value ) ) { + const innerBlocks = new Y.Array(); + + innerBlocks.insert( + 0, + value.map( ( innerBlock: Block ) => + createNewYBlock( innerBlock ) + ) + ); + + return [ key, innerBlocks ]; + } + + return [ key, value ]; + } + + default: + return [ key, value ]; + } + } ) + ); +} + /** * Merge incoming block data into the local Y.Doc. * This function is called to sync local block changes to a shared Y.Doc. @@ -208,9 +236,7 @@ export function mergeCrdtBlocks( // inserts for ( let i = 0; i < numOfInsertionsNeeded; i++, left++ ) { - const newBlock = [ - new Y.Map( Object.entries( blocksToSync[ left ] ) ) as YBlock, - ]; + const newBlock = [ createNewYBlock( blocksToSync[ left ] ) ]; yblocks.insert( left, newBlock ); } From c12aa9be5f6e7673670290e59764527669befcf8 Mon Sep 17 00:00:00 2001 From: chriszarate Date: Thu, 28 Aug 2025 11:58:43 -0600 Subject: [PATCH 05/12] Ensure we always recursively merge innerBlocks --- packages/core-data/src/utils/crdt-blocks.ts | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/packages/core-data/src/utils/crdt-blocks.ts b/packages/core-data/src/utils/crdt-blocks.ts index 40176ef06e3828..3bdeeee5b6edcf 100644 --- a/packages/core-data/src/utils/crdt-blocks.ts +++ b/packages/core-data/src/utils/crdt-blocks.ts @@ -137,7 +137,6 @@ function createNewYBlock( block: Block ): YBlock { * @param incomingBlocks Gutenberg blocks being synced. * @param _origin The origin of the sync, either 'syncProvider.getInitialCRDTDoc' or 'gutenberg'. */ - export function mergeCrdtBlocks( yblocks: Y.Array< YBlock >, // yblocks represent the blocks in the local Y.Doc incomingBlocks: Block[], // incomingBlocks represent JSON blocks being synced, either from a peer or from the local editor @@ -212,16 +211,21 @@ export function mergeCrdtBlocks( for ( let i = 0; i < numOfUpdatesNeeded; i++, left++ ) { const block = blocksToSync[ left ]; const yblock = yblocks.get( left ); - Object.entries( block ).forEach( ( [ k, v ] ) => { - if ( ! fun.equalityDeep( block[ k ], yblock.get( k ) ) ) { - if ( k === 'innerBlocks' ) { + Object.entries( block ).forEach( ( [ key, value ] ) => { + switch ( key ) { + case 'innerBlocks': { // Recursively merge innerBlocks - const yInnerBlocks = yblock.get( k ) as Y.Array< YBlock >; - - mergeCrdtBlocks( yInnerBlocks, v, _origin ); + const yInnerBlocks = yblock.get( key ) as Y.Array< YBlock >; + mergeCrdtBlocks( yInnerBlocks, value ?? [], _origin ); + break; } - yblock.set( k, v ); + default: + if ( + ! fun.equalityDeep( block[ key ], yblock.get( key ) ) + ) { + yblock.set( key, value ); + } } } ); yblock.forEach( ( _v, k ) => { From 131f0e5f0f858d172cb8e8178fdfbc5ab8628372 Mon Sep 17 00:00:00 2001 From: Alec Geatches Date: Thu, 28 Aug 2025 12:14:57 -0600 Subject: [PATCH 06/12] In areBlocksEqual(), ensure YBlock type instead of JSON value for comparison --- packages/core-data/src/utils/crdt-blocks.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core-data/src/utils/crdt-blocks.ts b/packages/core-data/src/utils/crdt-blocks.ts index 3bdeeee5b6edcf..88f1ef87ffed43 100644 --- a/packages/core-data/src/utils/crdt-blocks.ts +++ b/packages/core-data/src/utils/crdt-blocks.ts @@ -91,12 +91,12 @@ function areBlocksEqual( gblock: Block, yblock: YBlock ): boolean { Object.assign( {}, yblockAsJson, overwrites ) ); const inners = gblock.innerBlocks || []; - const yinners = yblockAsJson.innerBlocks || []; + const yinners = yblock.get( 'innerBlocks' ) as Y.Array< YBlock >; return ( res && inners.length === yinners.length && inners.every( ( block: Block, i: number ) => - areBlocksEqual( block, yinners[ i ] ) + areBlocksEqual( block, yinners.get( i ) ) ) ); } From a3720b667b62aebded997c7fd6914fd90404bda8 Mon Sep 17 00:00:00 2001 From: Alec Geatches Date: Thu, 28 Aug 2025 12:25:41 -0600 Subject: [PATCH 07/12] Use Y.Map type for attributes in yblocks --- packages/core-data/src/utils/crdt-blocks.ts | 24 +++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/packages/core-data/src/utils/crdt-blocks.ts b/packages/core-data/src/utils/crdt-blocks.ts index 88f1ef87ffed43..da2897a24c0d9e 100644 --- a/packages/core-data/src/utils/crdt-blocks.ts +++ b/packages/core-data/src/utils/crdt-blocks.ts @@ -122,6 +122,19 @@ function createNewYBlock( block: Block ): YBlock { return [ key, value ]; } + case 'attributes': { + const attributes = new Y.Map( + Object.entries( value ).map( + ( [ attributeKey, attributeValue ] ) => { + // Rich-text logic here + return [ attributeKey, attributeValue ]; + } + ) + ); + + return [ key, attributes ]; + } + default: return [ key, value ]; } @@ -220,6 +233,17 @@ export function mergeCrdtBlocks( break; } + case 'attributes': { + const yAttributes = yblock.get( key ) as Y.Map< unknown >; + Object.entries( value ).forEach( + ( [ attributeKey, attributeValue ] ) => { + // Rich-text logic here + yAttributes.set( attributeKey, attributeValue ); + } + ); + break; + } + default: if ( ! fun.equalityDeep( block[ key ], yblock.get( key ) ) From 3b116040a43367448206682ae399ac8578ecf853 Mon Sep 17 00:00:00 2001 From: chriszarate Date: Thu, 28 Aug 2025 12:32:26 -0600 Subject: [PATCH 08/12] Bugfix: Don't allow non-array values for innerBlocks --- packages/core-data/src/utils/crdt-blocks.ts | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/packages/core-data/src/utils/crdt-blocks.ts b/packages/core-data/src/utils/crdt-blocks.ts index da2897a24c0d9e..5c6841d24d9dac 100644 --- a/packages/core-data/src/utils/crdt-blocks.ts +++ b/packages/core-data/src/utils/crdt-blocks.ts @@ -106,20 +106,21 @@ function createNewYBlock( block: Block ): YBlock { Object.entries( block ).map( ( [ key, value ] ) => { switch ( key ) { case 'innerBlocks': { - if ( Array.isArray( value ) ) { - const innerBlocks = new Y.Array(); - - innerBlocks.insert( - 0, - value.map( ( innerBlock: Block ) => - createNewYBlock( innerBlock ) - ) - ); + const innerBlocks = new Y.Array(); + // If not an array, set to empty Y.Array. + if ( ! Array.isArray( value ) ) { return [ key, innerBlocks ]; } - return [ key, value ]; + innerBlocks.insert( + 0, + value.map( ( innerBlock: Block ) => + createNewYBlock( innerBlock ) + ) + ); + + return [ key, innerBlocks ]; } case 'attributes': { From cf9a1cef8f067c13746bf786f2233cfe54eef5b7 Mon Sep 17 00:00:00 2001 From: Alec Geatches Date: Thu, 28 Aug 2025 12:53:39 -0600 Subject: [PATCH 09/12] Cast rich-text types into Y.Text in the ydoc --- packages/core-data/src/utils/crdt-blocks.ts | 76 ++++++++++++++++++++- 1 file changed, 73 insertions(+), 3 deletions(-) diff --git a/packages/core-data/src/utils/crdt-blocks.ts b/packages/core-data/src/utils/crdt-blocks.ts index 5c6841d24d9dac..1b40ca07c9ed50 100644 --- a/packages/core-data/src/utils/crdt-blocks.ts +++ b/packages/core-data/src/utils/crdt-blocks.ts @@ -11,6 +11,9 @@ import * as fun from 'lib0/function'; import { RichTextData } from '@wordpress/rich-text'; import { Y } from '@wordpress/sync'; +// @ts-expect-error - This is a TypeScript file, and @wordpress/blocks doesn't have a tsconfig.json? +import { getBlockTypes } from '@wordpress/blocks'; + interface BlockAttributes { [ key: string ]: unknown; } @@ -127,7 +130,18 @@ function createNewYBlock( block: Block ): YBlock { const attributes = new Y.Map( Object.entries( value ).map( ( [ attributeKey, attributeValue ] ) => { - // Rich-text logic here + const isRichText = isRichTextAttribute( + block.name, + attributeKey + ); + + if ( isRichText ) { + return [ + attributeKey, + new Y.Text( attributeValue as string ), + ]; + } + return [ attributeKey, attributeValue ]; } ) @@ -238,8 +252,19 @@ export function mergeCrdtBlocks( const yAttributes = yblock.get( key ) as Y.Map< unknown >; Object.entries( value ).forEach( ( [ attributeKey, attributeValue ] ) => { - // Rich-text logic here - yAttributes.set( attributeKey, attributeValue ); + const isRichText = isRichTextAttribute( + block.name, + attributeKey + ); + + if ( isRichText ) { + const ytext = new Y.Text( + attributeValue as string + ); + yAttributes.set( attributeKey, ytext ); + } else { + yAttributes.set( attributeKey, attributeValue ); + } } ); break; @@ -310,3 +335,48 @@ function shouldBlockBeSynced( block: Block ): boolean { // Allow all other blocks to be synced. return true; } + +// Cache rich-text attributes for looked-up block types. +const cachedRichTextAttributes = new Map< string, Map< string, 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 attributeKey The key of the attribute to check, e.g. 'content'. + * @return True if the attribute is rich-text typed, false otherwise. + */ +function isRichTextAttribute( + blockName: string, + attributeKey: string +): boolean { + if ( cachedRichTextAttributes.has( blockName ) ) { + // If we've already cached the rich-text attributes for this block type, + // return the cached value. + return ( + cachedRichTextAttributes.get( blockName )?.has( attributeKey ) ?? + false + ); + } + + const allRegisteredBlockTypes = getBlockTypes(); + const matchingBlockType = allRegisteredBlockTypes.find( + ( blockType ) => blockType.name === blockName + ); + + const isBlockTypeRegistered = matchingBlockType !== undefined; + const richTextAttributeMap = new Map< string, true >(); + + if ( isBlockTypeRegistered ) { + for ( const [ registeredKey, registeredProperties ] of Object.entries( + matchingBlockType.attributes as Record< string, { type: string } > + ) ) { + if ( registeredProperties.type === 'rich-text' ) { + richTextAttributeMap.set( registeredKey, true ); + } + } + } + + cachedRichTextAttributes.set( blockName, richTextAttributeMap ); + return richTextAttributeMap.has( attributeKey ); +} From 9a67df3402ca7ea2ee98a44813af853331f659fb Mon Sep 17 00:00:00 2001 From: chriszarate Date: Thu, 28 Aug 2025 14:58:04 -0600 Subject: [PATCH 10/12] Improve type safety, DRY up, and slightly more efficient --- packages/core-data/src/utils/crdt-blocks.ts | 148 ++++++++++---------- 1 file changed, 72 insertions(+), 76 deletions(-) diff --git a/packages/core-data/src/utils/crdt-blocks.ts b/packages/core-data/src/utils/crdt-blocks.ts index 1b40ca07c9ed50..f4fde8f64e074e 100644 --- a/packages/core-data/src/utils/crdt-blocks.ts +++ b/packages/core-data/src/utils/crdt-blocks.ts @@ -18,6 +18,11 @@ interface BlockAttributes { [ key: string ]: unknown; } +interface BlockType { + name: string; + attributes?: Record< string, { type?: string } >; +} + export interface Block { attributes: BlockAttributes; clientId?: string; @@ -104,10 +109,39 @@ function areBlocksEqual( gblock: Block, yblock: YBlock ): boolean { ); } +function createNewYAttributeMap( + blockName: string, + attributes: BlockAttributes +): Y.Map< Y.Text | unknown > { + return new Y.Map( + Object.entries( attributes ).map( + ( [ attributeKey, attributeValue ] ) => { + const isRichText = isRichTextAttribute( + blockName, + attributeKey + ); + + if ( isRichText && 'string' === typeof attributeValue ) { + return [ + attributeKey, + new Y.Text( attributeValue as string ), + ]; + } + + return [ attributeKey, attributeValue ]; + } + ) + ); +} + function createNewYBlock( block: Block ): YBlock { return new Y.Map( Object.entries( block ).map( ( [ key, value ] ) => { switch ( key ) { + case 'attributes': { + return [ key, createNewYAttributeMap( block.name, value ) ]; + } + case 'innerBlocks': { const innerBlocks = new Y.Array(); @@ -126,30 +160,6 @@ function createNewYBlock( block: Block ): YBlock { return [ key, innerBlocks ]; } - case 'attributes': { - const attributes = new Y.Map( - Object.entries( value ).map( - ( [ attributeKey, attributeValue ] ) => { - const isRichText = isRichTextAttribute( - block.name, - attributeKey - ); - - if ( isRichText ) { - return [ - attributeKey, - new Y.Text( attributeValue as string ), - ]; - } - - return [ attributeKey, attributeValue ]; - } - ) - ); - - return [ key, attributes ]; - } - default: return [ key, value ]; } @@ -241,6 +251,18 @@ export function mergeCrdtBlocks( const yblock = yblocks.get( left ); Object.entries( block ).forEach( ( [ key, value ] ) => { switch ( key ) { + case 'attributes': { + if ( + ! fun.equalityDeep( block[ key ], yblock.get( key ) ) + ) { + yblock.set( + key, + createNewYAttributeMap( block.name, value ) + ); + } + break; + } + case 'innerBlocks': { // Recursively merge innerBlocks const yInnerBlocks = yblock.get( key ) as Y.Array< YBlock >; @@ -248,28 +270,6 @@ export function mergeCrdtBlocks( break; } - case 'attributes': { - const yAttributes = yblock.get( key ) as Y.Map< unknown >; - Object.entries( value ).forEach( - ( [ attributeKey, attributeValue ] ) => { - const isRichText = isRichTextAttribute( - block.name, - attributeKey - ); - - if ( isRichText ) { - const ytext = new Y.Text( - attributeValue as string - ); - yAttributes.set( attributeKey, ytext ); - } else { - yAttributes.set( attributeKey, attributeValue ); - } - } - ); - break; - } - default: if ( ! fun.equalityDeep( block[ key ], yblock.get( key ) ) @@ -336,47 +336,43 @@ function shouldBlockBeSynced( block: Block ): boolean { return true; } -// Cache rich-text attributes for looked-up block types. -const cachedRichTextAttributes = new Map< string, Map< string, true > >(); +// Cache rich-text attributes for all block types. +let cachedRichTextAttributes: Map< string, Map< string, 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 attributeKey The key of the attribute to check, e.g. 'content'. + * @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, - attributeKey: string + attributeName: string ): boolean { - if ( cachedRichTextAttributes.has( blockName ) ) { - // If we've already cached the rich-text attributes for this block type, - // return the cached value. - return ( - cachedRichTextAttributes.get( blockName )?.has( attributeKey ) ?? - false - ); - } - - const allRegisteredBlockTypes = getBlockTypes(); - const matchingBlockType = allRegisteredBlockTypes.find( - ( blockType ) => blockType.name === blockName - ); - - const isBlockTypeRegistered = matchingBlockType !== undefined; - const richTextAttributeMap = new Map< string, true >(); - - if ( isBlockTypeRegistered ) { - for ( const [ registeredKey, registeredProperties ] of Object.entries( - matchingBlockType.attributes as Record< string, { type: string } > - ) ) { - if ( registeredProperties.type === 'rich-text' ) { - richTextAttributeMap.set( registeredKey, true ); + if ( ! cachedRichTextAttributes ) { + // Parse the attributes for all blocks once. + cachedRichTextAttributes = new Map< string, Map< string, true > >(); + + for ( const blockType of getBlockTypes() as BlockType[] ) { + const richTextAttributeMap = new Map< string, true >(); + + for ( const [ name, definition ] of Object.entries( + blockType.attributes ?? {} + ) ) { + if ( 'rich-text' === definition.type ) { + richTextAttributeMap.set( name, true ); + } } + + cachedRichTextAttributes.set( + blockType.name, + richTextAttributeMap + ); } } - cachedRichTextAttributes.set( blockName, richTextAttributeMap ); - return richTextAttributeMap.has( attributeKey ); + return ( + cachedRichTextAttributes.get( blockName )?.has( attributeName ) ?? false + ); } From a7e82289d5dc99f90bd6b93ac5440cfed4f6dd55 Mon Sep 17 00:00:00 2001 From: chriszarate Date: Thu, 28 Aug 2025 15:30:16 -0600 Subject: [PATCH 11/12] Ensure attributes are deleted when removed --- packages/core-data/src/utils/crdt-blocks.ts | 86 +++++++++++++++------ 1 file changed, 62 insertions(+), 24 deletions(-) diff --git a/packages/core-data/src/utils/crdt-blocks.ts b/packages/core-data/src/utils/crdt-blocks.ts index f4fde8f64e074e..00fc83ba0f8819 100644 --- a/packages/core-data/src/utils/crdt-blocks.ts +++ b/packages/core-data/src/utils/crdt-blocks.ts @@ -38,11 +38,13 @@ export type YBlock = Y.Map< /* validationIssues? is an array of strings. */ | string[] /* attributes is a Y.Map< unknown >. */ - | Y.Map< unknown > + | YBlockAttributes /* innerBlocks is a Y.Array< YBlock >. */ | Y.Array< YBlock > >; +export type YBlockAttributes = Y.Map< Y.Text | unknown >; + // The Y.Map type is not easy to work with. The generic type it accepts represents // the possible values of the map, which are varied in our case. This type is // accurate, but will require aggressive type narrowing when the map values are @@ -112,28 +114,37 @@ function areBlocksEqual( gblock: Block, yblock: YBlock ): boolean { function createNewYAttributeMap( blockName: string, attributes: BlockAttributes -): Y.Map< Y.Text | unknown > { +): YBlockAttributes { return new Y.Map( Object.entries( attributes ).map( - ( [ attributeKey, attributeValue ] ) => { - const isRichText = isRichTextAttribute( - blockName, - attributeKey - ); - - if ( isRichText && 'string' === typeof attributeValue ) { - return [ - attributeKey, - new Y.Text( attributeValue as string ), - ]; - } - - return [ attributeKey, attributeValue ]; + ( [ attributeName, attributeValue ] ) => { + return [ + attributeName, + createNewYAttributeValue( + blockName, + attributeName, + attributeValue + ), + ]; } ) ); } +function createNewYAttributeValue( + blockName: string, + attributeName: string, + attributeValue: unknown +): Y.Text | unknown { + const isRichText = isRichTextAttribute( blockName, attributeName ); + + if ( isRichText && 'string' === typeof attributeValue ) { + return new Y.Text( attributeValue ); + } + + return attributeValue; +} + function createNewYBlock( block: Block ): YBlock { return new Y.Map( Object.entries( block ).map( ( [ key, value ] ) => { @@ -252,14 +263,41 @@ export function mergeCrdtBlocks( Object.entries( block ).forEach( ( [ key, value ] ) => { switch ( key ) { case 'attributes': { - if ( - ! fun.equalityDeep( block[ key ], yblock.get( key ) ) - ) { - yblock.set( - key, - createNewYAttributeMap( block.name, value ) - ); - } + const currentAttributes = + ( yblock.get( key ) as YBlockAttributes ) ?? + createNewYAttributeMap( block.name, {} ); + + Object.entries( value ).forEach( + ( [ attributeName, attributeValue ] ) => { + if ( + fun.equalityDeep( + currentAttributes.get( attributeName ), + attributeValue + ) + ) { + return; + } + + currentAttributes.set( + attributeName, + createNewYAttributeValue( + block.name, + attributeName, + attributeValue + ) + ); + } + ); + + // Delete any attributes that are no longer present. + currentAttributes.forEach( + ( _attrValue: unknown, attrName: string ) => { + if ( ! value.hasOwnProperty( attrName ) ) { + currentAttributes.delete( attrName ); + } + } + ); + break; } From 15ad2015b5ea0d573bb6786245d28deda99066af Mon Sep 17 00:00:00 2001 From: chriszarate Date: Thu, 28 Aug 2025 15:58:40 -0600 Subject: [PATCH 12/12] Bugfix: Make sure new attributes are set. --- packages/core-data/src/utils/crdt-blocks.ts | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/packages/core-data/src/utils/crdt-blocks.ts b/packages/core-data/src/utils/crdt-blocks.ts index 00fc83ba0f8819..aaf8cb6ddf4711 100644 --- a/packages/core-data/src/utils/crdt-blocks.ts +++ b/packages/core-data/src/utils/crdt-blocks.ts @@ -263,15 +263,24 @@ export function mergeCrdtBlocks( Object.entries( block ).forEach( ( [ key, value ] ) => { switch ( key ) { case 'attributes': { - const currentAttributes = - ( yblock.get( key ) as YBlockAttributes ) ?? - createNewYAttributeMap( block.name, {} ); + const currentAttributes = yblock.get( + key + ) as YBlockAttributes; + + // If attributes are not set on the yblock, use the new values. + if ( ! currentAttributes ) { + yblock.set( + key, + createNewYAttributeMap( block.name, value ) + ); + break; + } Object.entries( value ).forEach( ( [ attributeName, attributeValue ] ) => { if ( fun.equalityDeep( - currentAttributes.get( attributeName ), + currentAttributes?.get( attributeName ), attributeValue ) ) {