@wordpress/core-data
Version:
Access to and manipulation of core WordPress entities.
1,420 lines (1,209 loc) • 35.8 kB
text/typescript
/**
* WordPress dependencies
*/
import { Y } from '@wordpress/sync';
/**
* External dependencies
*/
import {
describe,
expect,
it,
jest,
beforeEach,
afterEach,
} from '@jest/globals';
/**
* Mock uuid module
*/
jest.mock( 'uuid', () => ( {
v4: () => 'mocked-uuid-' + Math.random(),
} ) );
/**
* Mock @wordpress/blocks module
*/
jest.mock( '@wordpress/blocks', () => ( {
getBlockTypes: () => [
{
name: 'core/paragraph',
attributes: { content: { type: 'rich-text' } },
},
],
} ) );
/**
* Internal dependencies
*/
import {
mergeCrdtBlocks,
mergeRichTextUpdate,
type Block,
type YBlock,
type YBlocks,
type YBlockAttributes,
} from '../crdt-blocks';
describe( 'crdt-blocks', () => {
let doc: Y.Doc;
let yblocks: Y.Array< YBlock >;
beforeEach( () => {
doc = new Y.Doc();
yblocks = doc.getArray< YBlock >();
jest.clearAllMocks();
} );
afterEach( () => {
doc.destroy();
} );
describe( 'mergeCrdtBlocks', () => {
it( 'inserts new blocks into empty Y.Array', () => {
const incomingBlocks: Block[] = [
{
name: 'core/paragraph',
attributes: { content: 'Hello World' },
innerBlocks: [],
},
];
mergeCrdtBlocks( yblocks, incomingBlocks, null );
expect( yblocks.length ).toBe( 1 );
const block = yblocks.get( 0 );
expect( block.get( 'name' ) ).toBe( 'core/paragraph' );
const content = (
block.get( 'attributes' ) as YBlockAttributes
).get( 'content' ) as Y.Text;
expect( content.toString() ).toBe( 'Hello World' );
} );
it( 'updates existing blocks when content changes', () => {
const initialBlocks: Block[] = [
{
name: 'core/paragraph',
attributes: { content: 'Initial content' },
innerBlocks: [],
clientId: 'block-1',
},
];
mergeCrdtBlocks( yblocks, initialBlocks, null );
const updatedBlocks: Block[] = [
{
name: 'core/paragraph',
attributes: { content: 'Updated content' },
innerBlocks: [],
clientId: 'block-1',
},
];
mergeCrdtBlocks( yblocks, updatedBlocks, null );
expect( yblocks.length ).toBe( 1 );
const block = yblocks.get( 0 );
const content = (
block.get( 'attributes' ) as YBlockAttributes
).get( 'content' ) as Y.Text;
expect( content.toString() ).toBe( 'Updated content' );
} );
it( 'deletes blocks that are removed', () => {
const initialBlocks: Block[] = [
{
name: 'core/paragraph',
attributes: { content: 'Block 1' },
innerBlocks: [],
clientId: 'block-1',
},
{
name: 'core/paragraph',
attributes: { content: 'Block 2' },
innerBlocks: [],
clientId: 'block-2',
},
];
mergeCrdtBlocks( yblocks, initialBlocks, null );
expect( yblocks.length ).toBe( 2 );
const updatedBlocks: Block[] = [
{
name: 'core/paragraph',
attributes: { content: 'Block 1' },
innerBlocks: [],
clientId: 'block-1',
},
];
mergeCrdtBlocks( yblocks, updatedBlocks, null );
expect( yblocks.length ).toBe( 1 );
const block = yblocks.get( 0 );
const content = (
block.get( 'attributes' ) as YBlockAttributes
).get( 'content' ) as Y.Text;
expect( content.toString() ).toBe( 'Block 1' );
} );
it( 'handles innerBlocks recursively', () => {
const blocksWithInner: Block[] = [
{
name: 'core/group',
attributes: {},
innerBlocks: [
{
name: 'core/paragraph',
attributes: { content: 'Inner paragraph' },
innerBlocks: [],
},
],
},
];
mergeCrdtBlocks( yblocks, blocksWithInner, null );
expect( yblocks.length ).toBe( 1 );
const block = yblocks.get( 0 );
const innerBlocks = block.get( 'innerBlocks' ) as YBlocks;
expect( innerBlocks.length ).toBe( 1 );
const innerBlock = innerBlocks.get( 0 );
expect( innerBlock.get( 'name' ) ).toBe( 'core/paragraph' );
} );
it( 'skips gallery blocks with unuploaded images (blob attributes)', () => {
const galleryWithBlobs: Block[] = [
{
name: 'core/gallery',
attributes: {},
innerBlocks: [
{
name: 'core/image',
attributes: {
url: 'http://example.com/image.jpg',
blob: 'blob:...',
},
innerBlocks: [],
},
],
},
];
mergeCrdtBlocks( yblocks, galleryWithBlobs, null );
// Gallery block should not be synced because it has blob attributes
expect( yblocks.length ).toBe( 0 );
} );
it( 'syncs gallery blocks without blob attributes', () => {
const galleryWithoutBlobs: Block[] = [
{
name: 'core/gallery',
attributes: {},
innerBlocks: [
{
name: 'core/image',
attributes: {
url: 'http://example.com/image.jpg',
},
innerBlocks: [],
},
],
},
];
mergeCrdtBlocks( yblocks, galleryWithoutBlobs, null );
expect( yblocks.length ).toBe( 1 );
const block = yblocks.get( 0 );
expect( block.get( 'name' ) ).toBe( 'core/gallery' );
} );
it( 'handles block reordering', () => {
const initialBlocks: Block[] = [
{
name: 'core/paragraph',
attributes: { content: 'First' },
innerBlocks: [],
clientId: 'block-1',
},
{
name: 'core/paragraph',
attributes: { content: 'Second' },
innerBlocks: [],
clientId: 'block-2',
},
];
mergeCrdtBlocks( yblocks, initialBlocks, null );
// Reorder blocks
const reorderedBlocks: Block[] = [
{
name: 'core/paragraph',
attributes: { content: 'Second' },
innerBlocks: [],
clientId: 'block-2',
},
{
name: 'core/paragraph',
attributes: { content: 'First' },
innerBlocks: [],
clientId: 'block-1',
},
];
mergeCrdtBlocks( yblocks, reorderedBlocks, null );
expect( yblocks.length ).toBe( 2 );
const block0 = yblocks.get( 0 );
const content0 = (
block0.get( 'attributes' ) as YBlockAttributes
).get( 'content' ) as Y.Text;
expect( content0.toString() ).toBe( 'Second' );
const block1 = yblocks.get( 1 );
const content1 = (
block1.get( 'attributes' ) as YBlockAttributes
).get( 'content' ) as Y.Text;
expect( content1.toString() ).toBe( 'First' );
} );
it( 'creates Y.Text for rich-text attributes', () => {
const blocks: Block[] = [
{
name: 'core/paragraph',
attributes: { content: 'Rich text content' },
innerBlocks: [],
},
];
mergeCrdtBlocks( yblocks, blocks, null );
const block = yblocks.get( 0 );
const contentAttr = (
block.get( 'attributes' ) as YBlockAttributes
).get( 'content' ) as Y.Text;
expect( contentAttr ).toBeInstanceOf( Y.Text );
expect( contentAttr.toString() ).toBe( 'Rich text content' );
} );
it( 'creates Y.Text for rich-text attributes even when the block name changes', () => {
const blocks: Block[] = [
{
name: 'core/freeform',
attributes: { content: 'Freeform text' },
innerBlocks: [],
},
];
mergeCrdtBlocks( yblocks, blocks, null );
const block = yblocks.get( 0 );
const contentAttr = (
block.get( 'attributes' ) as YBlockAttributes
).get( 'content' );
expect( block.get( 'name' ) ).toBe( 'core/freeform' );
expect( typeof contentAttr ).toBe( 'string' );
expect( contentAttr ).toBe( 'Freeform text' );
const updatedBlocks: Block[] = [
{
name: 'core/paragraph',
attributes: { content: 'Updated text' },
innerBlocks: [],
},
];
mergeCrdtBlocks( yblocks, updatedBlocks, null );
expect( yblocks.length ).toBe( 1 );
const updatedBlock = yblocks.get( 0 );
const updatedContentAttr = (
updatedBlock.get( 'attributes' ) as YBlockAttributes
).get( 'content' ) as Y.Text;
expect( updatedBlock.get( 'name' ) ).toBe( 'core/paragraph' );
expect( updatedContentAttr ).toBeInstanceOf( Y.Text );
expect( updatedContentAttr.toString() ).toBe( 'Updated text' );
} );
it( 'removes duplicate clientIds', () => {
const blocksWithDuplicateIds: Block[] = [
{
name: 'core/paragraph',
attributes: { content: 'First' },
innerBlocks: [],
clientId: 'duplicate-id',
},
{
name: 'core/paragraph',
attributes: { content: 'Second' },
innerBlocks: [],
clientId: 'duplicate-id',
},
];
mergeCrdtBlocks( yblocks, blocksWithDuplicateIds, null );
const block0 = yblocks.get( 0 );
const clientId1 = block0.get( 'clientId' );
const block1 = yblocks.get( 1 );
const clientId2 = block1.get( 'clientId' );
expect( clientId1 ).not.toBe( clientId2 );
} );
it( 'handles attribute deletion', () => {
const initialBlocks: Block[] = [
{
name: 'core/heading',
attributes: {
content: 'Heading',
level: 2,
},
innerBlocks: [],
},
];
mergeCrdtBlocks( yblocks, initialBlocks, null );
const updatedBlocks: Block[] = [
{
name: 'core/heading',
attributes: {
content: 'Heading',
},
innerBlocks: [],
},
];
mergeCrdtBlocks( yblocks, updatedBlocks, null );
const block = yblocks.get( 0 );
const attributes = block.get( 'attributes' ) as YBlockAttributes;
expect( attributes.has( 'level' ) ).toBe( false );
expect( attributes.has( 'content' ) ).toBe( true );
} );
it( 'preserves blocks that match from both left and right', () => {
const initialBlocks: Block[] = [
{
name: 'core/paragraph',
attributes: { content: 'First' },
innerBlocks: [],
},
{
name: 'core/paragraph',
attributes: { content: 'Middle' },
innerBlocks: [],
},
{
name: 'core/paragraph',
attributes: { content: 'Last' },
innerBlocks: [],
},
];
mergeCrdtBlocks( yblocks, initialBlocks, null );
// Update only the middle block
const updatedBlocks: Block[] = [
{
name: 'core/paragraph',
attributes: { content: 'First' },
innerBlocks: [],
},
{
name: 'core/paragraph',
attributes: { content: 'Updated Middle' },
innerBlocks: [],
},
{
name: 'core/paragraph',
attributes: { content: 'Last' },
innerBlocks: [],
},
];
mergeCrdtBlocks( yblocks, updatedBlocks, null );
expect( yblocks.length ).toBe( 3 );
const block = yblocks.get( 1 );
const content = (
block.get( 'attributes' ) as YBlockAttributes
).get( 'content' ) as Y.Text;
expect( content.toString() ).toBe( 'Updated Middle' );
} );
it( 'adds new rich-text attribute to existing block without that attribute', () => {
// Start with a block that has NO content attribute
const initialBlocks: Block[] = [
{
name: 'core/paragraph',
attributes: { level: 1 },
innerBlocks: [],
},
];
mergeCrdtBlocks( yblocks, initialBlocks, null );
// Now add the content attribute (rich-text)
const updatedBlocks: Block[] = [
{
name: 'core/paragraph',
attributes: {
level: 1,
content: 'New content added',
},
innerBlocks: [],
},
];
mergeCrdtBlocks( yblocks, updatedBlocks, null );
expect( yblocks.length ).toBe( 1 );
const block = yblocks.get( 0 );
const attributes = block.get( 'attributes' ) as YBlockAttributes;
// The content attribute should now exist
expect( attributes.has( 'content' ) ).toBe( true );
const content = attributes.get( 'content' ) as Y.Text;
expect( content.toString() ).toBe( 'New content added' );
// The level attribute should still exist
expect( attributes.get( 'level' ) ).toBe( 1 );
} );
it( 'handles block type changes from non-rich-text to rich-text', () => {
// Start with freeform block (content is non-rich-text)
const freeformBlocks: Block[] = [
{
name: 'core/freeform',
attributes: { content: 'Freeform content' },
innerBlocks: [],
clientId: 'block-1',
},
];
mergeCrdtBlocks( yblocks, freeformBlocks, null );
const block1 = yblocks.get( 0 );
const content1 = (
block1.get( 'attributes' ) as YBlockAttributes
).get( 'content' );
expect( block1.get( 'name' ) ).toBe( 'core/freeform' );
expect( typeof content1 ).toBe( 'string' );
expect( content1 ).toBe( 'Freeform content' );
// Change to paragraph block (content becomes rich-text)
const paragraphBlocks: Block[] = [
{
name: 'core/paragraph',
attributes: { content: 'Freeform content' },
innerBlocks: [],
clientId: 'block-1',
},
];
mergeCrdtBlocks( yblocks, paragraphBlocks, null );
expect( yblocks.length ).toBe( 1 );
const block2 = yblocks.get( 0 );
const content2 = (
block2.get( 'attributes' ) as YBlockAttributes
).get( 'content' ) as Y.Text;
expect( block2.get( 'name' ) ).toBe( 'core/paragraph' );
expect( content2 ).toBeInstanceOf( Y.Text );
expect( content2.toString() ).toBe( 'Freeform content' );
} );
it( 'syncs nested blocks with blob attributes', () => {
const nestedGallery: Block[] = [
{
name: 'core/group',
attributes: {},
innerBlocks: [
{
name: 'core/gallery',
attributes: {},
innerBlocks: [
{
name: 'core/image',
attributes: {
url: 'http://example.com/image.jpg',
blob: 'blob:...',
},
innerBlocks: [],
},
],
},
],
},
];
mergeCrdtBlocks( yblocks, nestedGallery, null );
expect( yblocks.length ).toBe( 1 );
const groupBlock = yblocks.get( 0 );
expect( groupBlock.get( 'name' ) ).toBe( 'core/group' );
const innerBlocks = groupBlock.get( 'innerBlocks' ) as YBlocks;
expect( innerBlocks.length ).toBe( 1 );
expect( innerBlocks.get( 0 ).get( 'name' ) ).toBe( 'core/gallery' );
} );
it( 'handles complex block reordering', () => {
const initialBlocks: Block[] = [
{
name: 'core/paragraph',
attributes: { content: 'A' },
innerBlocks: [],
clientId: 'block-a',
},
{
name: 'core/paragraph',
attributes: { content: 'B' },
innerBlocks: [],
clientId: 'block-b',
},
{
name: 'core/paragraph',
attributes: { content: 'C' },
innerBlocks: [],
clientId: 'block-c',
},
{
name: 'core/paragraph',
attributes: { content: 'D' },
innerBlocks: [],
clientId: 'block-d',
},
{
name: 'core/paragraph',
attributes: { content: 'E' },
innerBlocks: [],
clientId: 'block-e',
},
];
mergeCrdtBlocks( yblocks, initialBlocks, null );
expect( yblocks.length ).toBe( 5 );
// Reorder: [A, B, C, D, E] -> [C, A, E, B, D]
const reorderedBlocks: Block[] = [
{
name: 'core/paragraph',
attributes: { content: 'C' },
innerBlocks: [],
clientId: 'block-c',
},
{
name: 'core/paragraph',
attributes: { content: 'A' },
innerBlocks: [],
clientId: 'block-a',
},
{
name: 'core/paragraph',
attributes: { content: 'E' },
innerBlocks: [],
clientId: 'block-e',
},
{
name: 'core/paragraph',
attributes: { content: 'B' },
innerBlocks: [],
clientId: 'block-b',
},
{
name: 'core/paragraph',
attributes: { content: 'D' },
innerBlocks: [],
clientId: 'block-d',
},
];
mergeCrdtBlocks( yblocks, reorderedBlocks, null );
expect( yblocks.length ).toBe( 5 );
const contents = [ 'C', 'A', 'E', 'B', 'D' ];
contents.forEach( ( expectedContent, i ) => {
const block = yblocks.get( i );
const content = (
block.get( 'attributes' ) as YBlockAttributes
).get( 'content' ) as Y.Text;
expect( content.toString() ).toBe( expectedContent );
} );
} );
it( 'handles many deletions (10 blocks to 2 blocks)', () => {
const manyBlocks: Block[] = Array.from(
{ length: 10 },
( _, i ) => ( {
name: 'core/paragraph',
attributes: { content: `Block ${ i }` },
innerBlocks: [],
clientId: `block-${ i }`,
} )
);
mergeCrdtBlocks( yblocks, manyBlocks, null );
expect( yblocks.length ).toBe( 10 );
const fewBlocks: Block[] = [
{
name: 'core/paragraph',
attributes: { content: 'Block 0' },
innerBlocks: [],
clientId: 'block-0',
},
{
name: 'core/paragraph',
attributes: { content: 'Block 9' },
innerBlocks: [],
clientId: 'block-9',
},
];
mergeCrdtBlocks( yblocks, fewBlocks, null );
expect( yblocks.length ).toBe( 2 );
const content0 = (
yblocks.get( 0 ).get( 'attributes' ) as YBlockAttributes
).get( 'content' ) as Y.Text;
expect( content0.toString() ).toBe( 'Block 0' );
const content1 = (
yblocks.get( 1 ).get( 'attributes' ) as YBlockAttributes
).get( 'content' ) as Y.Text;
expect( content1.toString() ).toBe( 'Block 9' );
} );
it( 'handles many insertions (2 blocks to 10 blocks)', () => {
const fewBlocks: Block[] = [
{
name: 'core/paragraph',
attributes: { content: 'Block 0' },
innerBlocks: [],
clientId: 'block-0',
},
{
name: 'core/paragraph',
attributes: { content: 'Block 9' },
innerBlocks: [],
clientId: 'block-9',
},
];
mergeCrdtBlocks( yblocks, fewBlocks, null );
expect( yblocks.length ).toBe( 2 );
const manyBlocks: Block[] = Array.from(
{ length: 10 },
( _, i ) => ( {
name: 'core/paragraph',
attributes: { content: `Block ${ i }` },
innerBlocks: [],
clientId: `block-${ i }`,
} )
);
mergeCrdtBlocks( yblocks, manyBlocks, null );
expect( yblocks.length ).toBe( 10 );
manyBlocks.forEach( ( block, i ) => {
const yblock = yblocks.get( i );
const content = (
yblock.get( 'attributes' ) as YBlockAttributes
).get( 'content' ) as Y.Text;
expect( content.toString() ).toBe( `Block ${ i }` );
} );
} );
it( 'handles changes with all different block content', () => {
const blocksA: Block[] = [
{
name: 'core/paragraph',
attributes: { content: 'A1' },
innerBlocks: [],
},
{
name: 'core/paragraph',
attributes: { content: 'A2' },
innerBlocks: [],
},
{
name: 'core/paragraph',
attributes: { content: 'A3' },
innerBlocks: [],
},
];
mergeCrdtBlocks( yblocks, blocksA, null );
expect( yblocks.length ).toBe( 3 );
const blocksB: Block[] = [
{
name: 'core/paragraph',
attributes: { content: 'B1' },
innerBlocks: [],
},
{
name: 'core/paragraph',
attributes: { content: 'B2' },
innerBlocks: [],
},
{
name: 'core/paragraph',
attributes: { content: 'B3' },
innerBlocks: [],
},
];
mergeCrdtBlocks( yblocks, blocksB, null );
expect( yblocks.length ).toBe( 3 );
[ 'B1', 'B2', 'B3' ].forEach( ( expected, i ) => {
const content = (
yblocks.get( i ).get( 'attributes' ) as YBlockAttributes
).get( 'content' ) as Y.Text;
expect( content.toString() ).toBe( expected );
} );
} );
it( 'clears all blocks when syncing empty array', () => {
const blocks: Block[] = [
{
name: 'core/paragraph',
attributes: { content: 'Content' },
innerBlocks: [],
},
];
mergeCrdtBlocks( yblocks, blocks, null );
expect( yblocks.length ).toBe( 1 );
mergeCrdtBlocks( yblocks, [], null );
expect( yblocks.length ).toBe( 0 );
} );
it( 'handles deeply nested blocks', () => {
const deeplyNested: Block[] = [
{
name: 'core/group',
attributes: {},
innerBlocks: [
{
name: 'core/group',
attributes: {},
innerBlocks: [
{
name: 'core/group',
attributes: {},
innerBlocks: [
{
name: 'core/group',
attributes: {},
innerBlocks: [
{
name: 'core/paragraph',
attributes: {
content: 'Deep content',
},
innerBlocks: [],
},
],
},
],
},
],
},
],
},
];
mergeCrdtBlocks( yblocks, deeplyNested, null );
// Navigate to the deepest block
let current: YBlocks | YBlock = yblocks;
for ( let i = 0; i < 4; i++ ) {
expect( ( current as YBlocks ).length ).toBe( 1 );
current = ( current as YBlocks ).get( 0 );
current = ( current as YBlock ).get( 'innerBlocks' ) as YBlocks;
}
expect( ( current as YBlocks ).length ).toBe( 1 );
const deepBlock = ( current as YBlocks ).get( 0 );
const content = (
deepBlock.get( 'attributes' ) as YBlockAttributes
).get( 'content' ) as Y.Text;
expect( content.toString() ).toBe( 'Deep content' );
// Update innermost block
const updatedDeep: Block[] = [
{
name: 'core/group',
attributes: {},
innerBlocks: [
{
name: 'core/group',
attributes: {},
innerBlocks: [
{
name: 'core/group',
attributes: {},
innerBlocks: [
{
name: 'core/group',
attributes: {},
innerBlocks: [
{
name: 'core/paragraph',
attributes: {
content: 'Updated deep',
},
innerBlocks: [],
},
],
},
],
},
],
},
],
},
];
mergeCrdtBlocks( yblocks, updatedDeep, null );
// Verify update propagated
current = yblocks;
for ( let i = 0; i < 4; i++ ) {
current = ( current as YBlocks ).get( 0 );
current = ( current as YBlock ).get( 'innerBlocks' ) as YBlocks;
}
const updatedBlock = ( current as YBlocks ).get( 0 );
const updatedContent = (
updatedBlock.get( 'attributes' ) as YBlockAttributes
).get( 'content' ) as Y.Text;
expect( updatedContent.toString() ).toBe( 'Updated deep' );
} );
it( 'handles null and undefined attribute values', () => {
const blocksWithNullAttrs: Block[] = [
{
name: 'core/paragraph',
attributes: {
content: 'Content',
customAttr: null,
otherAttr: undefined,
},
innerBlocks: [],
},
];
mergeCrdtBlocks( yblocks, blocksWithNullAttrs, null );
expect( yblocks.length ).toBe( 1 );
const block = yblocks.get( 0 );
const attributes = block.get( 'attributes' ) as YBlockAttributes;
expect( attributes.get( 'content' ) ).toBeInstanceOf( Y.Text );
expect( attributes.get( 'customAttr' ) ).toBe( null );
} );
it( 'handles rich-text updates with cursor at start', () => {
const blocks: Block[] = [
{
name: 'core/paragraph',
attributes: { content: 'Hello World' },
innerBlocks: [],
},
];
mergeCrdtBlocks( yblocks, blocks, null );
const updatedBlocks: Block[] = [
{
name: 'core/paragraph',
attributes: { content: 'XHello World' },
innerBlocks: [],
},
];
mergeCrdtBlocks( yblocks, updatedBlocks, 0 );
const block = yblocks.get( 0 );
const content = (
block.get( 'attributes' ) as YBlockAttributes
).get( 'content' ) as Y.Text;
expect( content.toString() ).toBe( 'XHello World' );
} );
it( 'handles rich-text updates with cursor at end', () => {
const blocks: Block[] = [
{
name: 'core/paragraph',
attributes: { content: 'Hello World' },
innerBlocks: [],
},
];
mergeCrdtBlocks( yblocks, blocks, null );
const updatedBlocks: Block[] = [
{
name: 'core/paragraph',
attributes: { content: 'Hello World!' },
innerBlocks: [],
},
];
mergeCrdtBlocks( yblocks, updatedBlocks, 11 );
const block = yblocks.get( 0 );
const content = (
block.get( 'attributes' ) as YBlockAttributes
).get( 'content' ) as Y.Text;
expect( content.toString() ).toBe( 'Hello World!' );
} );
it( 'handles rich-text updates with cursor beyond text length', () => {
const blocks: Block[] = [
{
name: 'core/paragraph',
attributes: { content: 'Hello' },
innerBlocks: [],
},
];
mergeCrdtBlocks( yblocks, blocks, null );
const updatedBlocks: Block[] = [
{
name: 'core/paragraph',
attributes: { content: 'Hello World' },
innerBlocks: [],
},
];
mergeCrdtBlocks( yblocks, updatedBlocks, 999 );
const block = yblocks.get( 0 );
const content = (
block.get( 'attributes' ) as YBlockAttributes
).get( 'content' ) as Y.Text;
expect( content.toString() ).toBe( 'Hello World' );
} );
it( 'deletes extra block properties not in incoming blocks', () => {
const initialBlocks: Block[] = [
{
name: 'core/paragraph',
attributes: { content: 'Content' },
innerBlocks: [],
clientId: 'block-1',
isValid: true,
originalContent: 'Original',
},
];
mergeCrdtBlocks( yblocks, initialBlocks, null );
const block1 = yblocks.get( 0 );
expect( block1.get( 'isValid' ) ).toBe( true );
expect( block1.get( 'originalContent' ) ).toBe( 'Original' );
const updatedBlocks: Block[] = [
{
name: 'core/paragraph',
attributes: { content: 'Content' },
innerBlocks: [],
clientId: 'block-1',
},
];
mergeCrdtBlocks( yblocks, updatedBlocks, null );
const block2 = yblocks.get( 0 );
expect( block2.has( 'isValid' ) ).toBe( false );
expect( block2.has( 'originalContent' ) ).toBe( false );
} );
it( 'deletes rich-text attributes when removed from block', () => {
const blocksWithRichText: Block[] = [
{
name: 'core/paragraph',
attributes: {
content: 'Rich text content',
caption: 'Caption text',
},
innerBlocks: [],
},
];
mergeCrdtBlocks( yblocks, blocksWithRichText, null );
const block1 = yblocks.get( 0 );
const attrs1 = block1.get( 'attributes' ) as YBlockAttributes;
expect( attrs1.has( 'content' ) ).toBe( true );
expect( attrs1.has( 'caption' ) ).toBe( true );
const blocksWithoutCaption: Block[] = [
{
name: 'core/paragraph',
attributes: {
content: 'Rich text content',
},
innerBlocks: [],
},
];
mergeCrdtBlocks( yblocks, blocksWithoutCaption, null );
const block2 = yblocks.get( 0 );
const attrs2 = block2.get( 'attributes' ) as YBlockAttributes;
expect( attrs2.has( 'content' ) ).toBe( true );
expect( attrs2.has( 'caption' ) ).toBe( false );
} );
} );
describe( 'emoji handling', () => {
// Emoji like 😀 (U+1F600) are surrogate pairs in UTF-16 (.length === 2).
// The CRDT sync must preserve them without corruption (no U+FFFD / '�').
it( 'preserves emoji in initial block content', () => {
const blocks: Block[] = [
{
name: 'core/paragraph',
attributes: { content: 'Hello 😀 World' },
innerBlocks: [],
},
];
mergeCrdtBlocks( yblocks, blocks, null );
const block = yblocks.get( 0 );
const content = (
block.get( 'attributes' ) as YBlockAttributes
).get( 'content' ) as Y.Text;
expect( content.toString() ).toBe( 'Hello 😀 World' );
} );
it( 'handles inserting emoji into existing rich-text', () => {
const initialBlocks: Block[] = [
{
name: 'core/paragraph',
attributes: { content: 'Hello World' },
innerBlocks: [],
clientId: 'block-1',
},
];
mergeCrdtBlocks( yblocks, initialBlocks, null );
const updatedBlocks: Block[] = [
{
name: 'core/paragraph',
attributes: { content: 'Hello 😀 World' },
innerBlocks: [],
clientId: 'block-1',
},
];
// Cursor after 'Hello 😀' = 6 + 2 = 8
mergeCrdtBlocks( yblocks, updatedBlocks, 8 );
const block = yblocks.get( 0 );
const content = (
block.get( 'attributes' ) as YBlockAttributes
).get( 'content' ) as Y.Text;
expect( content.toString() ).toBe( 'Hello 😀 World' );
} );
it( 'handles deleting emoji from rich-text', () => {
const initialBlocks: Block[] = [
{
name: 'core/paragraph',
attributes: { content: 'Hello 😀 World' },
innerBlocks: [],
clientId: 'block-1',
},
];
mergeCrdtBlocks( yblocks, initialBlocks, null );
const updatedBlocks: Block[] = [
{
name: 'core/paragraph',
attributes: { content: 'Hello World' },
innerBlocks: [],
clientId: 'block-1',
},
];
// Cursor at position 6 (after 'Hello ', emoji was deleted)
mergeCrdtBlocks( yblocks, updatedBlocks, 6 );
const block = yblocks.get( 0 );
const content = (
block.get( 'attributes' ) as YBlockAttributes
).get( 'content' ) as Y.Text;
expect( content.toString() ).toBe( 'Hello World' );
} );
it( 'handles typing after emoji in rich-text', () => {
const initialBlocks: Block[] = [
{
name: 'core/paragraph',
attributes: { content: 'a😀b' },
innerBlocks: [],
clientId: 'block-1',
},
];
mergeCrdtBlocks( yblocks, initialBlocks, null );
const updatedBlocks: Block[] = [
{
name: 'core/paragraph',
attributes: { content: 'a😀xb' },
innerBlocks: [],
clientId: 'block-1',
},
];
// Cursor after 'a😀x' = 1 + 2 + 1 = 4
mergeCrdtBlocks( yblocks, updatedBlocks, 4 );
const block = yblocks.get( 0 );
const content = (
block.get( 'attributes' ) as YBlockAttributes
).get( 'content' ) as Y.Text;
expect( content.toString() ).toBe( 'a😀xb' );
} );
it( 'handles multiple emoji in rich-text updates', () => {
const initialBlocks: Block[] = [
{
name: 'core/paragraph',
attributes: { content: '😀🎉🚀' },
innerBlocks: [],
clientId: 'block-1',
},
];
mergeCrdtBlocks( yblocks, initialBlocks, null );
// Insert ' hello ' between first and second emoji
const updatedBlocks: Block[] = [
{
name: 'core/paragraph',
attributes: { content: '😀 hello 🎉🚀' },
innerBlocks: [],
clientId: 'block-1',
},
];
// Cursor after '😀 hello ' = 2 + 7 = 9
mergeCrdtBlocks( yblocks, updatedBlocks, 9 );
const block = yblocks.get( 0 );
const content = (
block.get( 'attributes' ) as YBlockAttributes
).get( 'content' ) as Y.Text;
expect( content.toString() ).toBe( '😀 hello 🎉🚀' );
} );
} );
describe( 'mergeRichTextUpdate - emoji handling', () => {
it( 'preserves emoji when appending text', () => {
const yText = doc.getText( 'test' );
yText.insert( 0, '😀' );
mergeRichTextUpdate( yText, '😀x' );
expect( yText.toString() ).toBe( '😀x' );
} );
it( 'preserves emoji when inserting before emoji', () => {
const yText = doc.getText( 'test' );
yText.insert( 0, '😀' );
mergeRichTextUpdate( yText, 'x😀' );
expect( yText.toString() ).toBe( 'x😀' );
} );
it( 'preserves emoji when replacing text around emoji', () => {
const yText = doc.getText( 'test' );
yText.insert( 0, 'a😀b' );
mergeRichTextUpdate( yText, 'a😀c', 4 );
expect( yText.toString() ).toBe( 'a😀c' );
} );
it( 'handles inserting emoji into plain text', () => {
const yText = doc.getText( 'test' );
yText.insert( 0, 'ab' );
mergeRichTextUpdate( yText, 'a😀b', 3 );
expect( yText.toString() ).toBe( 'a😀b' );
} );
it( 'handles deleting emoji', () => {
const yText = doc.getText( 'test' );
yText.insert( 0, 'a😀b' );
mergeRichTextUpdate( yText, 'ab', 1 );
expect( yText.toString() ).toBe( 'ab' );
} );
it( 'handles text with multiple emoji', () => {
const yText = doc.getText( 'test' );
yText.insert( 0, 'Hello 😀 World 🎉' );
mergeRichTextUpdate( yText, 'Hello 😀 Beautiful World 🎉', 19 );
expect( yText.toString() ).toBe( 'Hello 😀 Beautiful World 🎉' );
} );
it( 'handles compound emoji (flag emoji)', () => {
// Flag emoji like 🏳️🌈 are compound and has .length === 6 in JavaScript
const yText = doc.getText( 'test' );
yText.insert( 0, 'a🏳️🌈b' );
mergeRichTextUpdate( yText, 'a🏳️🌈xb', 7 );
expect( yText.toString() ).toBe( 'a🏳️🌈xb' );
} );
it( 'handles emoji with skin tone modifier', () => {
// 👋🏽 is U+1F44B U+1F3FD (wave + medium skin tone), .length === 4
const yText = doc.getText( 'test' );
yText.insert( 0, 'Hi 👋🏽' );
mergeRichTextUpdate( yText, 'Hi 👋🏽!', 6 );
expect( yText.toString() ).toBe( 'Hi 👋🏽!' );
} );
} );
describe( 'supplementary plane characters (non-emoji)', () => {
// Characters above U+FFFF are stored as surrogate pairs in UTF-16,
// so .length === 2 per character. The diff library v8 counts them
// as 1 grapheme cluster, causing the same mismatch as emoji.
describe( 'mergeCrdtBlocks', () => {
it( 'handles CJK Extension B characters (rare kanji)', () => {
// 𠮷 (U+20BB7) is a real character used in Japanese names.
// Surrogate pair: .length === 2.
const initialBlocks: Block[] = [
{
name: 'core/paragraph',
attributes: { content: '𠮷野家' },
innerBlocks: [],
clientId: 'block-1',
},
];
mergeCrdtBlocks( yblocks, initialBlocks, null );
const updatedBlocks: Block[] = [
{
name: 'core/paragraph',
attributes: { content: '𠮷野家は美味しい' },
innerBlocks: [],
clientId: 'block-1',
},
];
// Cursor after '𠮷野家は美味しい' = 2+1+1+1+1+1+1+1 = 9
mergeCrdtBlocks( yblocks, updatedBlocks, 9 );
const block = yblocks.get( 0 );
const content = (
block.get( 'attributes' ) as YBlockAttributes
).get( 'content' ) as Y.Text;
expect( content.toString() ).toBe( '𠮷野家は美味しい' );
} );
it( 'handles mathematical symbols from supplementary plane', () => {
// 𝐀 (U+1D400) — .length === 2
const initialBlocks: Block[] = [
{
name: 'core/paragraph',
attributes: { content: 'Let 𝐀 be' },
innerBlocks: [],
clientId: 'block-1',
},
];
mergeCrdtBlocks( yblocks, initialBlocks, null );
const updatedBlocks: Block[] = [
{
name: 'core/paragraph',
attributes: { content: 'Let 𝐀 be a matrix' },
innerBlocks: [],
clientId: 'block-1',
},
];
mergeCrdtBlocks( yblocks, updatedBlocks, 18 );
const block = yblocks.get( 0 );
const content = (
block.get( 'attributes' ) as YBlockAttributes
).get( 'content' ) as Y.Text;
expect( content.toString() ).toBe( 'Let 𝐀 be a matrix' );
} );
} );
describe( 'mergeRichTextUpdate', () => {
it( 'preserves CJK Extension B characters when appending', () => {
const yText = doc.getText( 'test' );
yText.insert( 0, '𠮷' );
mergeRichTextUpdate( yText, '𠮷x' );
expect( yText.toString() ).toBe( '𠮷x' );
} );
it( 'handles inserting after CJK Extension B character', () => {
const yText = doc.getText( 'test' );
yText.insert( 0, 'a𠮷b' );
mergeRichTextUpdate( yText, 'a𠮷xb', 4 );
expect( yText.toString() ).toBe( 'a𠮷xb' );
} );
it( 'handles mathematical symbols from supplementary plane', () => {
// 𝐀 (U+1D400) — .length === 2
const yText = doc.getText( 'test' );
yText.insert( 0, 'a𝐀b' );
mergeRichTextUpdate( yText, 'a𝐀xb', 4 );
expect( yText.toString() ).toBe( 'a𝐀xb' );
} );
it( 'handles mixed surrogate pairs and BMP text', () => {
// 𠮷 (CJK Ext B) + 😀 (emoji) — both surrogate pairs
const yText = doc.getText( 'test' );
yText.insert( 0, '𠮷😀' );
mergeRichTextUpdate( yText, '𠮷😀!' );
expect( yText.toString() ).toBe( '𠮷😀!' );
} );
it( 'handles musical symbols (supplementary plane)', () => {
// 𝄞 (U+1D11E, Musical Symbol G Clef) — .length === 2
const yText = doc.getText( 'test' );
yText.insert( 0, 'a𝄞b' );
mergeRichTextUpdate( yText, 'a𝄞xb', 4 );
expect( yText.toString() ).toBe( 'a𝄞xb' );
} );
} );
} );
} );