Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions packages/core-data/src/utils/crdt-blocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ) &&
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The serialize function relies on the value being an instant of RichTextData while the deserialize relies on the attribute for the value mentioning that its rich-text. Would a block author always mention that schema type, or could they skip it and then this path wouldn't be triggered? Should we account for the less than ideal path?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would a block author always mention that schema type

We have been assuming this contract will be upheld in general. We definitely should not go down the path of trying to predict the attribute type based on its value.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Totally, we should follow the path of assuming that the contract will be upheld but I wanted to point it out 🙏🏾

typeof value === 'string'
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The deserialize function, if I'm reading it right, doesn't account for arrays the way the serialize function does (tables for instance). Wouldn't the new rich-text conversion not apply if this kind of text existed in a table?

) {
newAttributes[ key ] = RichTextData.fromHTMLString( value );
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Following this call stack, we eventually land here:

createElement.body.innerHTML = html;

Is this cheap enough (given the Document re-use) that we don't need to cache this operation?

}
}

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
Expand Down
10 changes: 10 additions & 0 deletions packages/core-data/src/utils/crdt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
*/
import { BaseAwareness } from '../awareness/base-awareness';
import {
deserializeBlockAttributes,
mergeCrdtBlocks,
mergeRichTextUpdate,
type Block,
Expand Down Expand Up @@ -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 ) {
Expand Down
48 changes: 47 additions & 1 deletion packages/core-data/src/utils/test/crdt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,30 @@ 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' } },
},
],
};
} );

/**
* WordPress dependencies
*/
import { RichTextData } from '@wordpress/rich-text';

/**
* Internal dependencies
*/
Expand Down Expand Up @@ -515,6 +539,25 @@ 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( 'includes undefined blocks in changes', () => {
map.set( 'blocks', undefined );

Expand Down Expand Up @@ -801,11 +844,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 ) ) {
Expand All @@ -814,6 +859,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 );
Expand Down
Loading