diff --git a/packages/core-data/src/utils/crdt-blocks.ts b/packages/core-data/src/utils/crdt-blocks.ts index c4815b0bc93426..c9b364443c0d24 100644 --- a/packages/core-data/src/utils/crdt-blocks.ts +++ b/packages/core-data/src/utils/crdt-blocks.ts @@ -122,6 +122,57 @@ function makeBlocksSerializable( blocks: Block[] ): Block[] { } ); } +/** + * Convert rich-text string attributes in a block back to RichTextData + * instances. This is the inverse of makeBlockAttributesSerializable and is + * needed when blocks are extracted from the CRDT document, where rich-text + * values are stored as Y.Text (which serializes to plain strings via + * toJSON()). Without this conversion, block edit components that rely on + * RichTextData methods (e.g. `.text`) will receive a raw string and + * malfunction. + * + * @param blockName The block type name, e.g. 'core/code'. + * @param attributes The plain-object attributes from CRDT (toJSON). + * @return Attributes with rich-text strings replaced by RichTextData. + */ +function deserializeBlockAttributeValues( + blockName: string, + attributes: BlockAttributes +): BlockAttributes { + const newAttributes = { ...attributes }; + + for ( const [ key, value ] of Object.entries( attributes ) ) { + if ( + isRichTextAttribute( blockName, key ) && + typeof value === 'string' + ) { + newAttributes[ key ] = RichTextData.fromHTMLString( value ); + } + } + + return newAttributes; +} + +/** + * Convert blocks from their CRDT-serialized form back to the runtime form + * expected by the block editor. This ensures that rich-text attributes are + * RichTextData instances rather than raw strings. + * + * @param blocks Blocks as extracted from the CRDT document via toJSON(). + * @return Blocks with rich-text attributes restored to RichTextData. + */ +export function deserializeBlockAttributes( blocks: Block[] ): Block[] { + return blocks.map( ( block: Block ) => { + const { name, innerBlocks, attributes, ...rest } = block; + return { + ...rest, + name, + attributes: deserializeBlockAttributeValues( name, attributes ), + innerBlocks: deserializeBlockAttributes( innerBlocks ?? [] ), + }; + } ); +} + /** * @param {any} gblock * @param {Y.Map} yblock diff --git a/packages/core-data/src/utils/crdt.ts b/packages/core-data/src/utils/crdt.ts index 6b674623c43862..59f0c9439385c8 100644 --- a/packages/core-data/src/utils/crdt.ts +++ b/packages/core-data/src/utils/crdt.ts @@ -20,6 +20,7 @@ import { */ import { BaseAwareness } from '../awareness/base-awareness'; import { + deserializeBlockAttributes, mergeCrdtBlocks, mergeRichTextUpdate, type Block, @@ -382,6 +383,15 @@ export function getPostChangesFromCRDTDoc( } ) ); + // Blocks extracted from the CRDT document have rich-text attributes as + // plain strings (from Y.Text.toJSON()). Convert them back to RichTextData + // so block edit components receive the same types as locally-created blocks. + if ( changes.blocks ) { + changes.blocks = deserializeBlockAttributes( + changes.blocks as Block[] + ); + } + // Meta changes must be merged with the edited record since not all meta // properties are synced. if ( 'object' === typeof changes.meta ) { diff --git a/packages/core-data/src/utils/test/crdt.ts b/packages/core-data/src/utils/test/crdt.ts index 13487638a9cedd..d14af7ccc68d23 100644 --- a/packages/core-data/src/utils/test/crdt.ts +++ b/packages/core-data/src/utils/test/crdt.ts @@ -8,6 +8,49 @@ import { Y } from '@wordpress/sync'; */ import { describe, expect, it, jest, beforeEach } from '@jest/globals'; +/** + * Mock getBlockTypes so isRichTextAttribute can identify rich-text attrs. + */ +jest.mock( '@wordpress/blocks', () => { + const actual = jest.requireActual( '@wordpress/blocks' ) as Record< + string, + unknown + >; + return { + ...actual, + getBlockTypes: () => [ + { + name: 'core/paragraph', + attributes: { content: { type: 'rich-text' } }, + }, + { + name: 'core/table', + attributes: { + hasFixedLayout: { type: 'boolean' }, + caption: { type: 'rich-text' }, + body: { + type: 'array', + query: { + cells: { + type: 'array', + query: { + content: { type: 'rich-text' }, + tag: { type: 'string' }, + }, + }, + }, + }, + }, + }, + ], + }; +} ); + +/** + * WordPress dependencies + */ +import { RichTextData } from '@wordpress/rich-text'; + /** * Internal dependencies */ @@ -515,6 +558,68 @@ describe( 'crdt', () => { expect( changes ).toHaveProperty( 'blocks' ); } ); + it( 'returns rich-text block attributes as RichTextData, not strings', () => { + // Simulate User A writing a paragraph block into the CRDT doc. + addBlockToDoc( map, 'block-1', 'Hello world' ); + + // Simulate User B reading the CRDT doc with no local blocks. + const editedRecord = { blocks: [] } as unknown as Post; + + const changes = getPostChangesFromCRDTDoc( + doc, + editedRecord, + defaultSyncedProperties + ); + + const block = ( changes.blocks as any[] )?.[ 0 ]; + expect( block ).toBeDefined(); + expect( block.attributes.content ).toBeInstanceOf( RichTextData ); + expect( block.attributes.content.text ).toBe( 'Hello world' ); + } ); + + it( 'returns nested rich-text in array attributes as RichTextData', () => { + // Add a table block to the CRDT doc with nested cell content + // stored as plain strings. + let blocks = map.get( 'blocks' ); + if ( ! ( blocks instanceof Y.Array ) ) { + blocks = new Y.Array< YBlock >(); + map.set( 'blocks', blocks ); + } + + const tableBlock = createYMap< YBlockRecord >(); + tableBlock.set( 'name', 'core/table' ); + tableBlock.set( 'clientId', 'table-1' ); + const attrs = new Y.Map(); + attrs.set( 'body', [ + { + cells: [ + { content: 'Cell', tag: 'td' }, + { content: 'Plain', tag: 'td' }, + ], + }, + ] ); + tableBlock.set( 'attributes', attrs ); + tableBlock.set( 'innerBlocks', new Y.Array() ); + ( blocks as YBlocks ).push( [ tableBlock ] ); + + const editedRecord = { blocks: [] } as unknown as Post; + + const changes = getPostChangesFromCRDTDoc( + doc, + editedRecord, + defaultSyncedProperties + ); + + const block = ( changes.blocks as any[] )?.[ 0 ]; + expect( block ).toBeDefined(); + + const cell = block.attributes.body[ 0 ].cells[ 0 ]; + expect( cell.content ).toBeInstanceOf( RichTextData ); + expect( ( cell.content as RichTextData ).toHTMLString() ).toBe( + 'Cell' + ); + } ); + it( 'includes undefined blocks in changes', () => { map.set( 'blocks', undefined ); @@ -801,11 +906,13 @@ describe( 'crdt', () => { * @param map * @param clientId Block client ID. * @param content Initial text content. + * @param name Block name (default: 'core/paragraph'). */ function addBlockToDoc( map: YMapWrap< YPostRecord >, clientId: string, - content: string + content: string, + name = 'core/paragraph' ): Y.Text { let blocks = map.get( 'blocks' ); if ( ! ( blocks instanceof Y.Array ) ) { @@ -814,6 +921,7 @@ function addBlockToDoc( } const block = createYMap< YBlockRecord >(); + block.set( 'name', name ); block.set( 'clientId', clientId ); const attrs = new Y.Map(); const ytext = new Y.Text( content );