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 );