/** * 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' } }, }, { name: 'core/image', attributes: { blob: { type: 'string', role: 'local' }, url: { type: 'string' }, }, }, { name: 'core/test-object-query', attributes: { metadata: { type: 'object', query: { title: { type: 'rich-text' }, value: { type: 'string' }, }, }, }, }, { name: 'core/table', attributes: { hasFixedLayout: { type: 'boolean' }, caption: { type: 'rich-text' }, head: { type: 'array', query: { cells: { type: 'array', query: { content: { type: 'rich-text' }, tag: { type: 'string' }, scope: { type: 'string' }, align: { type: 'string' }, }, }, }, }, body: { type: 'array', query: { cells: { type: 'array', query: { content: { type: 'rich-text' }, tag: { type: 'string' }, scope: { type: 'string' }, align: { type: 'string' }, }, }, }, }, foot: { type: 'array', query: { cells: { type: 'array', query: { content: { type: 'rich-text' }, tag: { type: 'string' }, scope: { type: 'string' }, align: { type: 'string' }, }, }, }, }, }, }, ], } ) ); /** * WordPress dependencies */ import { RichTextData } from '@wordpress/rich-text'; /** * Internal dependencies */ import { mergeCrdtBlocks, mergeRichTextUpdate, type Block, type YBlock, type YBlocks, type YBlockAttributes, } from '../crdt-blocks'; import { getCachedRichTextData, createRichTextDataCache } from '../crdt-text'; 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( 'strips local attributes when syncing blocks', () => { const imageWithBlob: Block[] = [ { name: 'core/image', attributes: { url: 'http://example.com/image.jpg', blob: 'blob:...', }, innerBlocks: [], }, ]; mergeCrdtBlocks( yblocks, imageWithBlob, null ); expect( yblocks.length ).toBe( 1 ); const block = yblocks.get( 0 ); expect( block.get( 'name' ) ).toBe( 'core/image' ); const attrs = block.get( 'attributes' ) as YBlockAttributes; expect( attrs.get( 'url' ) ).toBe( 'http://example.com/image.jpg' ); expect( attrs.has( 'blob' ) ).toBe( false ); } ); it( 'strips local attributes from inner blocks', () => { 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 ); expect( yblocks.length ).toBe( 1 ); const gallery = yblocks.get( 0 ); expect( gallery.get( 'name' ) ).toBe( 'core/gallery' ); const innerBlocks = gallery.get( 'innerBlocks' ) as YBlocks; expect( innerBlocks.length ).toBe( 1 ); const image = innerBlocks.get( 0 ); const attrs = image.get( 'attributes' ) as YBlockAttributes; expect( attrs.get( 'url' ) ).toBe( 'http://example.com/image.jpg' ); expect( attrs.has( 'blob' ) ).toBe( false ); } ); 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( 'strips local attributes from deeply nested blocks', () => { 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 ); const gallery = innerBlocks.get( 0 ); const galleryInner = gallery.get( 'innerBlocks' ) as YBlocks; expect( galleryInner.length ).toBe( 1 ); const image = galleryInner.get( 0 ); const attrs = image.get( 'attributes' ) as YBlockAttributes; expect( attrs.get( 'url' ) ).toBe( 'http://example.com/image.jpg' ); expect( attrs.has( 'blob' ) ).toBe( false ); } ); 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( 'table block', () => { it( 'preserves table cell content through CRDT round-trip', () => { const tableBlocks: Block[] = [ { name: 'core/table', attributes: { hasFixedLayout: true, body: [ { cells: [ { content: RichTextData.fromPlainText( '1' ), tag: 'td', }, { content: RichTextData.fromPlainText( '2' ), tag: 'td', }, ], }, { cells: [ { content: RichTextData.fromPlainText( '3' ), tag: 'td', }, { content: RichTextData.fromPlainText( '4' ), tag: 'td', }, ], }, ], }, innerBlocks: [], }, ]; mergeCrdtBlocks( yblocks, tableBlocks, null ); // Simulate a CRDT encode/decode cycle (persistence or sync). const encoded = Y.encodeStateAsUpdate( doc ); const doc2 = new Y.Doc(); Y.applyUpdate( doc2, encoded ); const yblocks2 = doc2.getArray< YBlock >(); expect( yblocks2.length ).toBe( 1 ); const block = yblocks2.get( 0 ); const attrs = block.get( 'attributes' ) as YBlockAttributes; const bodyYArray = attrs.get( 'body' ) as Y.Array< unknown >; const body = bodyYArray.toJSON() as { cells: { content: string; tag: string }[]; }[]; expect( body ).toHaveLength( 2 ); expect( body[ 0 ].cells[ 0 ].content ).toBe( '1' ); expect( body[ 0 ].cells[ 1 ].content ).toBe( '2' ); expect( body[ 1 ].cells[ 0 ].content ).toBe( '3' ); expect( body[ 1 ].cells[ 1 ].content ).toBe( '4' ); doc2.destroy(); } ); it( 'preserves table cell content with HTML formatting', () => { const tableBlocks: Block[] = [ { name: 'core/table', attributes: { hasFixedLayout: true, head: [ { cells: [ { content: RichTextData.fromHTMLString( 'Header' ), tag: 'th', }, ], }, ], body: [ { cells: [ { content: RichTextData.fromHTMLString( 'Link' ), tag: 'td', }, ], }, ], }, innerBlocks: [], }, ]; mergeCrdtBlocks( yblocks, tableBlocks, null ); // Round-trip through encode/decode. const encoded = Y.encodeStateAsUpdate( doc ); const doc2 = new Y.Doc(); Y.applyUpdate( doc2, encoded ); const yblocks2 = doc2.getArray< YBlock >(); const block = yblocks2.get( 0 ); const attrs = block.get( 'attributes' ) as YBlockAttributes; const headYArray = attrs.get( 'head' ) as Y.Array< unknown >; const head = headYArray.toJSON() as { cells: { content: string }[]; }[]; expect( head[ 0 ].cells[ 0 ].content ).toBe( 'Header' ); const bodyYArray = attrs.get( 'body' ) as Y.Array< unknown >; const body = bodyYArray.toJSON() as { cells: { content: string }[]; }[]; expect( body[ 0 ].cells[ 0 ].content ).toBe( 'Link' ); doc2.destroy(); } ); it( 'stores table body as nested Y types (Y.Array of Y.Maps with Y.Text)', () => { const tableBlocks: Block[] = [ { name: 'core/table', attributes: { hasFixedLayout: true, body: [ { cells: [ { content: RichTextData.fromPlainText( 'A1' ), tag: 'td', }, { content: RichTextData.fromPlainText( 'B1' ), tag: 'td', }, ], }, ], }, innerBlocks: [], }, ]; mergeCrdtBlocks( yblocks, tableBlocks, null ); const attrs = yblocks .get( 0 ) .get( 'attributes' ) as YBlockAttributes; const body = attrs.get( 'body' ); // body should be a Y.Array, not a plain array. expect( body ).toBeInstanceOf( Y.Array ); // Each row should be a Y.Map. const row = ( body as Y.Array< unknown > ).get( 0 ); expect( row ).toBeInstanceOf( Y.Map ); // Each row's cells should be a Y.Array. const cells = ( row as Y.Map< unknown > ).get( 'cells' ); expect( cells ).toBeInstanceOf( Y.Array ); // Each cell should be a Y.Map with Y.Text content. const cell = ( cells as Y.Array< unknown > ).get( 0 ); expect( cell ).toBeInstanceOf( Y.Map ); const content = ( cell as Y.Map< unknown > ).get( 'content' ) as Y.Text; expect( content ).toBeInstanceOf( Y.Text ); expect( content.toString() ).toBe( 'A1' ); // tag should be a plain string value. expect( ( cell as Y.Map< unknown > ).get( 'tag' ) ).toBe( 'td' ); } ); it( 'merges table cell edits in-place without replacing sibling cells', () => { const tableBlocks: Block[] = [ { name: 'core/table', attributes: { body: [ { cells: [ { content: 'A1', tag: 'td' }, { content: 'B1', tag: 'td' }, ], }, { cells: [ { content: 'A2', tag: 'td' }, { content: 'B2', tag: 'td' }, ], }, ], }, innerBlocks: [], }, ]; mergeCrdtBlocks( yblocks, tableBlocks, null ); // Grab the Y.Text for cell B2 before the update. const attrs = yblocks .get( 0 ) .get( 'attributes' ) as YBlockAttributes; const body = attrs.get( 'body' ) as Y.Array< unknown >; const row1 = body.get( 1 ) as Y.Map< unknown >; const cells1 = row1.get( 'cells' ) as Y.Array< unknown >; const cellB2 = cells1.get( 1 ) as Y.Map< unknown >; const b2Text = cellB2.get( 'content' ) as Y.Text; // Edit only cell A1. const updatedBlocks: Block[] = [ { name: 'core/table', attributes: { body: [ { cells: [ { content: 'A1-edited', tag: 'td' }, { content: 'B1', tag: 'td' }, ], }, { cells: [ { content: 'A2', tag: 'td' }, { content: 'B2', tag: 'td' }, ], }, ], }, innerBlocks: [], }, ]; mergeCrdtBlocks( yblocks, updatedBlocks, null ); // The Y.Text for B2 should be the exact same object (identity). const bodyAfter = attrs.get( 'body' ) as Y.Array< unknown >; const row1After = bodyAfter.get( 1 ) as Y.Map< unknown >; const cells1After = row1After.get( 'cells' ) as Y.Array< unknown >; const cellB2After = cells1After.get( 1 ) as Y.Map< unknown >; const b2TextAfter = cellB2After.get( 'content' ) as Y.Text; expect( b2TextAfter ).toBe( b2Text ); expect( b2TextAfter.toString() ).toBe( 'B2' ); // Cell A1 should be updated. const row0After = bodyAfter.get( 0 ) as Y.Map< unknown >; const cells0After = row0After.get( 'cells' ) as Y.Array< unknown >; const cellA1After = cells0After.get( 0 ) as Y.Map< unknown >; const a1Content = cellA1After.get( 'content' ) as Y.Text; expect( a1Content.toString() ).toBe( 'A1-edited' ); } ); it( 'preserves existing elements and appends new rows when row count increases', () => { const tableBlocks: Block[] = [ { name: 'core/table', attributes: { body: [ { cells: [ { content: 'A1', tag: 'td' } ], }, ], }, innerBlocks: [], }, ]; mergeCrdtBlocks( yblocks, tableBlocks, null ); // Add a second row. const updatedBlocks: Block[] = [ { name: 'core/table', attributes: { body: [ { cells: [ { content: 'A1', tag: 'td' } ], }, { cells: [ { content: 'A2', tag: 'td' } ], }, ], }, innerBlocks: [], }, ]; mergeCrdtBlocks( yblocks, updatedBlocks, null ); const attrs = yblocks .get( 0 ) .get( 'attributes' ) as YBlockAttributes; const body = attrs.get( 'body' ) as Y.Array< unknown >; expect( body.length ).toBe( 2 ); const row1 = body.get( 1 ) as Y.Map< unknown >; const cells = ( row1.get( 'cells' ) as Y.Array< unknown > ).get( 0 ) as Y.Map< unknown >; const a2Content = cells.get( 'content' ) as Y.Text; expect( a2Content.toString() ).toBe( 'A2' ); } ); it( 'concurrent cell edits on different cells are both preserved', () => { // Simulate two users editing different cells in the same table. const initialBlocks: Block[] = [ { name: 'core/table', attributes: { body: [ { cells: [ { content: 'A1', tag: 'td' }, { content: 'B1', tag: 'td' }, ], }, ], }, innerBlocks: [], }, ]; // Set up doc1 (User A). mergeCrdtBlocks( yblocks, initialBlocks, null ); // Set up doc2 (User B) by syncing initial state. const doc2 = new Y.Doc(); const yblocks2 = doc2.getArray< YBlock >(); Y.applyUpdate( doc2, Y.encodeStateAsUpdate( doc ) ); // User A edits cell A1. const userABlocks: Block[] = [ { name: 'core/table', attributes: { body: [ { cells: [ { content: 'A1-userA', tag: 'td' }, { content: 'B1', tag: 'td' }, ], }, ], }, innerBlocks: [], }, ]; mergeCrdtBlocks( yblocks, userABlocks, null ); // User B edits cell B1 (concurrently, before syncing A's change). const userBBlocks: Block[] = [ { name: 'core/table', attributes: { body: [ { cells: [ { content: 'A1', tag: 'td' }, { content: 'B1-userB', tag: 'td' }, ], }, ], }, innerBlocks: [], }, ]; mergeCrdtBlocks( yblocks2, userBBlocks, null ); // Sync: apply each other's changes. const updateA = Y.encodeStateAsUpdate( doc ); const updateB = Y.encodeStateAsUpdate( doc2 ); Y.applyUpdate( doc2, updateA ); Y.applyUpdate( doc, updateB ); // Both docs should have both edits preserved. for ( const checkBlocks of [ yblocks, yblocks2 ] ) { const attrs = checkBlocks .get( 0 ) .get( 'attributes' ) as YBlockAttributes; const body = ( attrs.get( 'body' ) as Y.Array< unknown > ).toJSON() as { cells: { content: string }[] }[]; expect( body[ 0 ].cells[ 0 ].content ).toBe( 'A1-userA' ); expect( body[ 0 ].cells[ 1 ].content ).toBe( 'B1-userB' ); } doc2.destroy(); } ); it( 'concurrent cell edit is preserved when another user appends a row', () => { // Two users: A edits a cell, B appends a row. After sync, // A's edit should be preserved alongside the new row. const initialBlocks: Block[] = [ { name: 'core/table', attributes: { body: [ { cells: [ { content: 'A1', tag: 'td' }, { content: 'B1', tag: 'td' }, ], }, { cells: [ { content: 'A2', tag: 'td' }, { content: 'B2', tag: 'td' }, ], }, ], }, innerBlocks: [], }, ]; // Set up doc1 (User A). mergeCrdtBlocks( yblocks, initialBlocks, null ); // Set up doc2 (User B) by syncing initial state. const doc2 = new Y.Doc(); const yblocks2 = doc2.getArray< YBlock >(); Y.applyUpdate( doc2, Y.encodeStateAsUpdate( doc ) ); // User A edits cell A1. const userABlocks: Block[] = [ { name: 'core/table', attributes: { body: [ { cells: [ { content: 'A1-userA', tag: 'td' }, { content: 'B1', tag: 'td' }, ], }, { cells: [ { content: 'A2', tag: 'td' }, { content: 'B2', tag: 'td' }, ], }, ], }, innerBlocks: [], }, ]; mergeCrdtBlocks( yblocks, userABlocks, null ); // User B appends a third row (concurrently, before syncing). const userBBlocks: Block[] = [ { name: 'core/table', attributes: { body: [ { cells: [ { content: 'A1', tag: 'td' }, { content: 'B1', tag: 'td' }, ], }, { cells: [ { content: 'A2', tag: 'td' }, { content: 'B2', tag: 'td' }, ], }, { cells: [ { content: 'A3', tag: 'td' }, { content: 'B3', tag: 'td' }, ], }, ], }, innerBlocks: [], }, ]; mergeCrdtBlocks( yblocks2, userBBlocks, null ); // Sync: apply each other's changes. const updateA = Y.encodeStateAsUpdate( doc ); const updateB = Y.encodeStateAsUpdate( doc2 ); Y.applyUpdate( doc2, updateA ); Y.applyUpdate( doc, updateB ); // Both docs should have A's cell edit and B's new row. for ( const checkBlocks of [ yblocks, yblocks2 ] ) { const attrs = checkBlocks .get( 0 ) .get( 'attributes' ) as YBlockAttributes; const body = ( attrs.get( 'body' ) as Y.Array< unknown > ).toJSON() as { cells: { content: string }[] }[]; expect( body.length ).toBe( 3 ); expect( body[ 0 ].cells[ 0 ].content ).toBe( 'A1-userA' ); expect( body[ 0 ].cells[ 1 ].content ).toBe( 'B1' ); expect( body[ 1 ].cells[ 0 ].content ).toBe( 'A2' ); expect( body[ 2 ].cells[ 0 ].content ).toBe( 'A3' ); } doc2.destroy(); } ); it( 'concurrent cell edit is preserved when another user deletes a different row', () => { // Two users: A edits a cell in row 1, B deletes row 2. // After sync, A's edit should survive and row 2 should be gone. const initialBlocks: Block[] = [ { name: 'core/table', attributes: { body: [ { cells: [ { content: 'A1', tag: 'td' }, { content: 'B1', tag: 'td' }, ], }, { cells: [ { content: 'A2', tag: 'td' }, { content: 'B2', tag: 'td' }, ], }, { cells: [ { content: 'A3', tag: 'td' }, { content: 'B3', tag: 'td' }, ], }, ], }, innerBlocks: [], }, ]; // Set up doc1 (User A). mergeCrdtBlocks( yblocks, initialBlocks, null ); // Set up doc2 (User B) by syncing initial state. const doc2 = new Y.Doc(); const yblocks2 = doc2.getArray< YBlock >(); Y.applyUpdate( doc2, Y.encodeStateAsUpdate( doc ) ); // User A edits cell A1 in row 1. const userABlocks: Block[] = [ { name: 'core/table', attributes: { body: [ { cells: [ { content: 'A1-userA', tag: 'td' }, { content: 'B1', tag: 'td' }, ], }, { cells: [ { content: 'A2', tag: 'td' }, { content: 'B2', tag: 'td' }, ], }, { cells: [ { content: 'A3', tag: 'td' }, { content: 'B3', tag: 'td' }, ], }, ], }, innerBlocks: [], }, ]; mergeCrdtBlocks( yblocks, userABlocks, null ); // User B deletes row 2 (the middle row). const userBBlocks: Block[] = [ { name: 'core/table', attributes: { body: [ { cells: [ { content: 'A1', tag: 'td' }, { content: 'B1', tag: 'td' }, ], }, { cells: [ { content: 'A3', tag: 'td' }, { content: 'B3', tag: 'td' }, ], }, ], }, innerBlocks: [], }, ]; mergeCrdtBlocks( yblocks2, userBBlocks, null ); // Sync: apply each other's changes. const updateA = Y.encodeStateAsUpdate( doc ); const updateB = Y.encodeStateAsUpdate( doc2 ); Y.applyUpdate( doc2, updateA ); Y.applyUpdate( doc, updateB ); // Both docs should have A's cell edit and B's row deletion. for ( const checkBlocks of [ yblocks, yblocks2 ] ) { const attrs = checkBlocks .get( 0 ) .get( 'attributes' ) as YBlockAttributes; const body = ( attrs.get( 'body' ) as Y.Array< unknown > ).toJSON() as { cells: { content: string }[] }[]; expect( body.length ).toBe( 2 ); expect( body[ 0 ].cells[ 0 ].content ).toBe( 'A1-userA' ); expect( body[ 1 ].cells[ 0 ].content ).toBe( 'A3' ); } doc2.destroy(); } ); it( 'preserves Y.Map identity for untouched rows when a row is appended', () => { const initialBlocks: Block[] = [ { name: 'core/table', attributes: { body: [ { cells: [ { content: 'A1', tag: 'td' } ], }, ], }, innerBlocks: [], }, ]; mergeCrdtBlocks( yblocks, initialBlocks, null ); // Capture a reference to the Y.Map for the first row. const attrs = yblocks .get( 0 ) .get( 'attributes' ) as YBlockAttributes; const body = attrs.get( 'body' ) as Y.Array< unknown >; const row0Before = body.get( 0 ) as Y.Map< unknown >; // Append a second row. const updatedBlocks: Block[] = [ { name: 'core/table', attributes: { body: [ { cells: [ { content: 'A1', tag: 'td' } ], }, { cells: [ { content: 'A2', tag: 'td' } ], }, ], }, innerBlocks: [], }, ]; mergeCrdtBlocks( yblocks, updatedBlocks, null ); // The first row's Y.Map should be the exact same object. const row0After = body.get( 0 ) as Y.Map< unknown >; expect( row0After ).toBe( row0Before ); // And the new row should exist. expect( body.length ).toBe( 2 ); const row1 = body.get( 1 ) as Y.Map< unknown >; const cells = ( row1.get( 'cells' ) as Y.Array< unknown > ).get( 0 ) as Y.Map< unknown >; const content = cells.get( 'content' ) as Y.Text; expect( content.toString() ).toBe( 'A2' ); } ); it( 'preserves Y.Map identity when a row is prepended', () => { const initialBlocks: Block[] = [ { name: 'core/table', attributes: { body: [ { cells: [ { content: 'A1', tag: 'td' } ], }, { cells: [ { content: 'A2', tag: 'td' } ], }, ], }, innerBlocks: [], }, ]; mergeCrdtBlocks( yblocks, initialBlocks, null ); const attrs = yblocks .get( 0 ) .get( 'attributes' ) as YBlockAttributes; const body = attrs.get( 'body' ) as Y.Array< unknown >; const row0Before = body.get( 0 ) as Y.Map< unknown >; const row1Before = body.get( 1 ) as Y.Map< unknown >; // Prepend a new row. const updatedBlocks: Block[] = [ { name: 'core/table', attributes: { body: [ { cells: [ { content: 'NEW', tag: 'td' } ], }, { cells: [ { content: 'A1', tag: 'td' } ], }, { cells: [ { content: 'A2', tag: 'td' } ], }, ], }, innerBlocks: [], }, ]; mergeCrdtBlocks( yblocks, updatedBlocks, null ); expect( body.length ).toBe( 3 ); const bodyJson = body.toJSON() as { cells: { content: string }[]; }[]; expect( bodyJson[ 0 ].cells[ 0 ].content ).toBe( 'NEW' ); expect( bodyJson[ 1 ].cells[ 0 ].content ).toBe( 'A1' ); expect( bodyJson[ 2 ].cells[ 0 ].content ).toBe( 'A2' ); // Original rows should be the same Y.Map objects (shifted). expect( body.get( 1 ) ).toBe( row0Before ); expect( body.get( 2 ) ).toBe( row1Before ); } ); it( 'preserves Y.Map identity when a row is inserted in the middle', () => { const initialBlocks: Block[] = [ { name: 'core/table', attributes: { body: [ { cells: [ { content: 'A1', tag: 'td' } ], }, { cells: [ { content: 'A3', tag: 'td' } ], }, ], }, innerBlocks: [], }, ]; mergeCrdtBlocks( yblocks, initialBlocks, null ); const attrs = yblocks .get( 0 ) .get( 'attributes' ) as YBlockAttributes; const body = attrs.get( 'body' ) as Y.Array< unknown >; const row0Before = body.get( 0 ) as Y.Map< unknown >; const row1Before = body.get( 1 ) as Y.Map< unknown >; // Insert a new row in the middle. const updatedBlocks: Block[] = [ { name: 'core/table', attributes: { body: [ { cells: [ { content: 'A1', tag: 'td' } ], }, { cells: [ { content: 'A2', tag: 'td' } ], }, { cells: [ { content: 'A3', tag: 'td' } ], }, ], }, innerBlocks: [], }, ]; mergeCrdtBlocks( yblocks, updatedBlocks, null ); expect( body.length ).toBe( 3 ); const bodyJson = body.toJSON() as { cells: { content: string }[]; }[]; expect( bodyJson[ 0 ].cells[ 0 ].content ).toBe( 'A1' ); expect( bodyJson[ 1 ].cells[ 0 ].content ).toBe( 'A2' ); expect( bodyJson[ 2 ].cells[ 0 ].content ).toBe( 'A3' ); // Original rows should be preserved. expect( body.get( 0 ) ).toBe( row0Before ); expect( body.get( 2 ) ).toBe( row1Before ); } ); it( 'preserves Y.Map identity when a row is deleted from the end', () => { const initialBlocks: Block[] = [ { name: 'core/table', attributes: { body: [ { cells: [ { content: 'A1', tag: 'td' } ], }, { cells: [ { content: 'A2', tag: 'td' } ], }, { cells: [ { content: 'A3', tag: 'td' } ], }, ], }, innerBlocks: [], }, ]; mergeCrdtBlocks( yblocks, initialBlocks, null ); const attrs = yblocks .get( 0 ) .get( 'attributes' ) as YBlockAttributes; const body = attrs.get( 'body' ) as Y.Array< unknown >; const row0Before = body.get( 0 ) as Y.Map< unknown >; const row1Before = body.get( 1 ) as Y.Map< unknown >; // Delete the last row. const updatedBlocks: Block[] = [ { name: 'core/table', attributes: { body: [ { cells: [ { content: 'A1', tag: 'td' } ], }, { cells: [ { content: 'A2', tag: 'td' } ], }, ], }, innerBlocks: [], }, ]; mergeCrdtBlocks( yblocks, updatedBlocks, null ); expect( body.length ).toBe( 2 ); const bodyJson = body.toJSON() as { cells: { content: string }[]; }[]; expect( bodyJson[ 0 ].cells[ 0 ].content ).toBe( 'A1' ); expect( bodyJson[ 1 ].cells[ 0 ].content ).toBe( 'A2' ); // Remaining rows should be the same Y.Map objects. expect( body.get( 0 ) ).toBe( row0Before ); expect( body.get( 1 ) ).toBe( row1Before ); } ); it( 'updates all elements in-place when every row changes', () => { const initialBlocks: Block[] = [ { name: 'core/table', attributes: { body: [ { cells: [ { content: 'A1', tag: 'td' } ], }, { cells: [ { content: 'A2', tag: 'td' } ], }, ], }, innerBlocks: [], }, ]; mergeCrdtBlocks( yblocks, initialBlocks, null ); const attrs = yblocks .get( 0 ) .get( 'attributes' ) as YBlockAttributes; const body = attrs.get( 'body' ) as Y.Array< unknown >; const row0Before = body.get( 0 ) as Y.Map< unknown >; const row1Before = body.get( 1 ) as Y.Map< unknown >; // Replace all row contents. const updatedBlocks: Block[] = [ { name: 'core/table', attributes: { body: [ { cells: [ { content: 'X1', tag: 'td' } ], }, { cells: [ { content: 'X2', tag: 'td' } ], }, ], }, innerBlocks: [], }, ]; mergeCrdtBlocks( yblocks, updatedBlocks, null ); expect( body.length ).toBe( 2 ); const bodyJson = body.toJSON() as { cells: { content: string }[]; }[]; expect( bodyJson[ 0 ].cells[ 0 ].content ).toBe( 'X1' ); expect( bodyJson[ 1 ].cells[ 0 ].content ).toBe( 'X2' ); // Y.Map objects should be updated in-place, not recreated. expect( body.get( 0 ) ).toBe( row0Before ); expect( body.get( 1 ) ).toBe( row1Before ); } ); it( 'migrates plain array to Y.Array on first update', () => { // Manually set up a block with a plain array body (old format). const block = new Y.Map() as unknown as YBlock; block.set( 'name' as any, 'core/table' ); block.set( 'clientId' as any, 'table-migration' ); block.set( 'innerBlocks' as any, new Y.Array() ); const attrs = new Y.Map(); attrs.set( 'hasFixedLayout', true ); // Store body as a plain array (pre-migration format). attrs.set( 'body', [ { cells: [ { content: 'old', tag: 'td' } ] }, ] ); block.set( 'attributes' as any, attrs ); doc.transact( () => { yblocks.push( [ block ] ); } ); // The body is currently a plain array. expect( attrs.get( 'body' ) ).not.toBeInstanceOf( Y.Array ); // Now merge blocks, which should trigger migration. const updatedBlocks: Block[] = [ { name: 'core/table', attributes: { hasFixedLayout: true, body: [ { cells: [ { content: 'migrated', tag: 'td' } ], }, ], }, innerBlocks: [], }, ]; mergeCrdtBlocks( yblocks, updatedBlocks, null ); // After migration, body should be a Y.Array. const bodyAfter = attrs.get( 'body' ); expect( bodyAfter ).toBeInstanceOf( Y.Array ); const bodyJson = ( bodyAfter as Y.Array< unknown > ).toJSON() as { cells: { content: string }[]; }[]; expect( bodyJson[ 0 ].cells[ 0 ].content ).toBe( 'migrated' ); } ); it( 'preserves non-rich-text cell properties alongside Y.Text content', () => { const tableBlocks: Block[] = [ { name: 'core/table', attributes: { body: [ { cells: [ { content: 'Header', tag: 'th', scope: 'col', align: 'center', }, ], }, ], }, innerBlocks: [], }, ]; mergeCrdtBlocks( yblocks, tableBlocks, null ); const attrs = yblocks .get( 0 ) .get( 'attributes' ) as YBlockAttributes; const body = attrs.get( 'body' ) as Y.Array< unknown >; const row = body.get( 0 ) as Y.Map< unknown >; const cells = row.get( 'cells' ) as Y.Array< unknown >; const cell = cells.get( 0 ) as Y.Map< unknown >; // Rich-text content should be Y.Text. const content = cell.get( 'content' ) as Y.Text; expect( content ).toBeInstanceOf( Y.Text ); expect( content.toString() ).toBe( 'Header' ); // Plain string properties should be stored as-is. expect( cell.get( 'tag' ) ).toBe( 'th' ); expect( cell.get( 'scope' ) ).toBe( 'col' ); expect( cell.get( 'align' ) ).toBe( 'center' ); // Update only the content, verify other properties remain. const updatedBlocks: Block[] = [ { name: 'core/table', attributes: { body: [ { cells: [ { content: 'Updated Header', tag: 'th', scope: 'col', align: 'center', }, ], }, ], }, innerBlocks: [], }, ]; mergeCrdtBlocks( yblocks, updatedBlocks, null ); const cellAfter = ( ( ( attrs.get( 'body' ) as Y.Array< unknown > ).get( 0 ) as Y.Map< unknown > ).get( 'cells' ) as Y.Array< unknown > ).get( 0 ) as Y.Map< unknown >; expect( ( cellAfter.get( 'content' ) as Y.Text ).toString() ).toBe( 'Updated Header' ); expect( cellAfter.get( 'tag' ) ).toBe( 'th' ); expect( cellAfter.get( 'scope' ) ).toBe( 'col' ); expect( cellAfter.get( 'align' ) ).toBe( 'center' ); } ); it( 'deletes removed properties from Y.Map cells', () => { const tableBlocks: Block[] = [ { name: 'core/table', attributes: { body: [ { cells: [ { content: 'Header', tag: 'th', scope: 'col', }, ], }, ], }, innerBlocks: [], }, ]; mergeCrdtBlocks( yblocks, tableBlocks, null ); const attrs = yblocks .get( 0 ) .get( 'attributes' ) as YBlockAttributes; const body = attrs.get( 'body' ) as Y.Array< unknown >; const row = body.get( 0 ) as Y.Map< unknown >; const cells = row.get( 'cells' ) as Y.Array< unknown >; const cell = cells.get( 0 ) as Y.Map< unknown >; // Scope should exist initially. expect( cell.get( 'scope' ) ).toBe( 'col' ); // Update without the scope property. const updatedBlocks: Block[] = [ { name: 'core/table', attributes: { body: [ { cells: [ { content: 'Header', tag: 'th', }, ], }, ], }, innerBlocks: [], }, ]; mergeCrdtBlocks( yblocks, updatedBlocks, null ); const cellAfter = ( ( ( attrs.get( 'body' ) as Y.Array< unknown > ).get( 0 ) as Y.Map< unknown > ).get( 'cells' ) as Y.Array< unknown > ).get( 0 ) as Y.Map< unknown >; // Scope should be deleted. expect( cellAfter.get( 'scope' ) ).toBeUndefined(); // Other properties should remain. expect( cellAfter.get( 'tag' ) ).toBe( 'th' ); expect( ( cellAfter.get( 'content' ) as Y.Text ).toString() ).toBe( 'Header' ); } ); it( 'rebuilds Y.Array when element is wrong type (partial migration)', () => { // Manually set up a block with a Y.Array whose elements are // plain values instead of Y.Maps (simulating a partial migration). const block = new Y.Map() as unknown as YBlock; block.set( 'name' as any, 'core/table' ); block.set( 'clientId' as any, 'table-partial' ); block.set( 'innerBlocks' as any, new Y.Array() ); const attrs = new Y.Map(); // Create a Y.Array with a plain object element (not a Y.Map). const bodyArray = new Y.Array(); bodyArray.insert( 0, [ { cells: [ { content: 'plain', tag: 'td' } ] }, ] ); attrs.set( 'body', bodyArray ); block.set( 'attributes' as any, attrs ); doc.transact( () => { yblocks.push( [ block ] ); } ); // The element should be a plain object, not a Y.Map. expect( bodyArray.get( 0 ) ).not.toBeInstanceOf( Y.Map ); // Merge, which should detect the wrong type and rebuild. const updatedBlocks: Block[] = [ { name: 'core/table', attributes: { body: [ { cells: [ { content: 'rebuilt', tag: 'td' } ], }, ], }, innerBlocks: [], }, ]; mergeCrdtBlocks( yblocks, updatedBlocks, null ); const bodyAfter = attrs.get( 'body' ) as Y.Array< unknown >; expect( bodyAfter ).toBeInstanceOf( Y.Array ); // After rebuild, elements should be proper Y.Maps. const row = bodyAfter.get( 0 ); expect( row ).toBeInstanceOf( Y.Map ); const cells = ( row as Y.Map< unknown > ).get( 'cells' ) as Y.Array< unknown >; const cell = cells.get( 0 ) as Y.Map< unknown >; expect( cell ).toBeInstanceOf( Y.Map ); expect( ( cell.get( 'content' ) as Y.Text ).toString() ).toBe( 'rebuilt' ); } ); } ); describe( 'object+query attributes', () => { it( 'creates Y.Map for object+query attributes with Y.Text sub-values', () => { const blocks: Block[] = [ { name: 'core/test-object-query', attributes: { metadata: { title: 'Hello', value: 'world', }, }, innerBlocks: [], }, ]; mergeCrdtBlocks( yblocks, blocks, null ); const attrs = yblocks .get( 0 ) .get( 'attributes' ) as YBlockAttributes; const metadata = attrs.get( 'metadata' ); // Should be a Y.Map, not a plain object. expect( metadata ).toBeInstanceOf( Y.Map ); const metadataMap = metadata as Y.Map< unknown >; // title is rich-text, so it should be Y.Text. expect( metadataMap.get( 'title' ) ).toBeInstanceOf( Y.Text ); expect( ( metadataMap.get( 'title' ) as Y.Text ).toString() ).toBe( 'Hello' ); // value is a plain string, so it should remain a string. expect( metadataMap.get( 'value' ) ).toBe( 'world' ); } ); it( 'merges object+query attribute in-place preserving Y.Map identity', () => { const blocks: Block[] = [ { name: 'core/test-object-query', attributes: { metadata: { title: 'Original', value: 'v1', }, }, innerBlocks: [], clientId: 'obj-query-1', }, ]; mergeCrdtBlocks( yblocks, blocks, null ); const attrs = yblocks .get( 0 ) .get( 'attributes' ) as YBlockAttributes; const metadataBefore = attrs.get( 'metadata' ) as Y.Map< unknown >; const titleBefore = metadataBefore.get( 'title' ) as Y.Text; // Update the metadata. const updatedBlocks: Block[] = [ { name: 'core/test-object-query', attributes: { metadata: { title: 'Updated', value: 'v2', }, }, innerBlocks: [], clientId: 'obj-query-1', }, ]; mergeCrdtBlocks( yblocks, updatedBlocks, null ); const metadataAfter = attrs.get( 'metadata' ) as Y.Map< unknown >; // The Y.Map should be the same object (in-place merge). expect( metadataAfter ).toBe( metadataBefore ); // The Y.Text for title should be the same object (merged in-place). const titleAfter = metadataAfter.get( 'title' ) as Y.Text; expect( titleAfter ).toBe( titleBefore ); expect( titleAfter.toString() ).toBe( 'Updated' ); // Plain value should be updated. expect( metadataAfter.get( 'value' ) ).toBe( 'v2' ); } ); it( 'deletes removed properties from object+query Y.Map', () => { const blocks: Block[] = [ { name: 'core/test-object-query', attributes: { metadata: { title: 'Keep', value: 'remove-me', }, }, innerBlocks: [], clientId: 'obj-query-2', }, ]; mergeCrdtBlocks( yblocks, blocks, null ); const attrs = yblocks .get( 0 ) .get( 'attributes' ) as YBlockAttributes; const metadata = attrs.get( 'metadata' ) as Y.Map< unknown >; expect( metadata.get( 'value' ) ).toBe( 'remove-me' ); // Update without the value property. const updatedBlocks: Block[] = [ { name: 'core/test-object-query', attributes: { metadata: { title: 'Keep', }, }, innerBlocks: [], clientId: 'obj-query-2', }, ]; mergeCrdtBlocks( yblocks, updatedBlocks, null ); expect( metadata.get( 'value' ) ).toBeUndefined(); expect( ( metadata.get( 'title' ) as Y.Text ).toString() ).toBe( 'Keep' ); } ); it( 'upgrades plain value to Y.Map when schema requires it', () => { // Manually set up a block with a plain object attribute // where the schema expects object+query (Y.Map). const block = new Y.Map() as unknown as YBlock; block.set( 'name' as any, 'core/test-object-query' ); block.set( 'clientId' as any, 'obj-upgrade' ); block.set( 'innerBlocks' as any, new Y.Array() ); const attrs = new Y.Map(); // Store metadata as a plain object (pre-migration). attrs.set( 'metadata', { title: 'plain', value: 'old' } ); block.set( 'attributes' as any, attrs ); doc.transact( () => { yblocks.push( [ block ] ); } ); // metadata should be a plain object currently. expect( attrs.get( 'metadata' ) ).not.toBeInstanceOf( Y.Map ); // Merge, which should upgrade to Y.Map. const updatedBlocks: Block[] = [ { name: 'core/test-object-query', attributes: { metadata: { title: 'upgraded', value: 'new', }, }, innerBlocks: [], }, ]; mergeCrdtBlocks( yblocks, updatedBlocks, null ); const metadataAfter = attrs.get( 'metadata' ); expect( metadataAfter ).toBeInstanceOf( Y.Map ); const metadataMap = metadataAfter as Y.Map< unknown >; expect( metadataMap.get( 'title' ) ).toBeInstanceOf( Y.Text ); expect( ( metadataMap.get( 'title' ) as Y.Text ).toString() ).toBe( 'upgraded' ); expect( metadataMap.get( 'value' ) ).toBe( 'new' ); } ); } ); 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' ); } ); } ); } ); } ); describe( 'getCachedRichTextData', () => { let spy: ReturnType< typeof jest.spyOn >; beforeEach( () => { spy = jest.spyOn( RichTextData, 'fromHTMLString' ); } ); afterEach( () => { spy.mockRestore(); } ); it( 'does not call fromHTMLString again for the same HTML string', () => { getCachedRichTextData( 'cached-hit' ); getCachedRichTextData( 'cached-hit' ); expect( spy ).toHaveBeenCalledTimes( 1 ); } ); it( 'calls fromHTMLString for each unique HTML string', () => { getCachedRichTextData( 'cached-miss-a' ); getCachedRichTextData( 'cached-miss-b' ); expect( spy ).toHaveBeenCalledTimes( 2 ); } ); it( 'calls fromHTMLString again for an evicted entry', () => { const cacheSize = 10; const getCachedValue = createRichTextDataCache( cacheSize ); const firstString = 'eviction-test-first'; getCachedValue( firstString ); for ( let i = 1; i < cacheSize; i++ ) { getCachedValue( `eviction-test-${ i }` ); } // This should push firstString out of the cache. getCachedValue( 'eviction-test-overflow' ); spy.mockClear(); // firstString was evicted, so fromHTMLString should be called again. getCachedValue( firstString ); expect( spy ).toHaveBeenCalledTimes( 1 ); // The overflow entry is still cached, so fromHTMLString should not be called. getCachedValue( 'eviction-test-overflow' ); expect( spy ).toHaveBeenCalledTimes( 1 ); } ); } );