@wordpress/core-data
Version:
Access to and manipulation of core WordPress entities.
1,234 lines (1,022 loc) • 33.3 kB
text/typescript
import { Y } from '@wordpress/sync';
import { describe, expect, it, jest, beforeEach } from '@jest/globals';
/**
* Mock getBlockTypes so CRDT merging can identify rich-text attributes.
* Also stub __unstableSerializeAndClean so we can assert how it's invoked
* (the real implementation returns "" without registered block types, which
* isn't useful for asserting closure-capture behavior).
*/
jest.mock( '@wordpress/blocks', () => {
const actual = jest.requireActual( '@wordpress/blocks' ) as Record<
string,
unknown
>;
return {
...actual,
getBlockTypes: () => [
{
name: 'core/paragraph',
attributes: { content: { type: 'rich-text' } },
},
{
name: 'core/table',
attributes: {
hasFixedLayout: { type: 'boolean' },
caption: { type: 'rich-text' },
body: {
type: 'array',
query: {
cells: {
type: 'array',
query: {
content: { type: 'rich-text' },
tag: { type: 'string' },
},
},
},
},
},
},
],
// Mocked so tests can control what the Code Editor sync path "parses"
// from raw content without needing real block-type registration.
parse: jest.fn( () => [] ),
__unstableSerializeAndClean: jest.fn(
( blocks: unknown[] ) => `serialized:${ blocks?.length ?? 0 }`
),
};
} );
import { parse } from '@wordpress/blocks';
import { RichTextData } from '@wordpress/rich-text';
import { CRDT_RECORD_MAP_KEY } from '../../sync';
import {
applyPostChangesToCRDTDoc,
defaultCollectionSyncConfig,
defaultSyncConfig,
getPostChangesFromCRDTDoc,
POST_META_KEY_FOR_CRDT_DOC_PERSISTENCE,
type PostChanges,
type YPostRecord,
} from '../crdt';
import type { Block, YBlock, YBlockRecord, YBlocks } from '../crdt-blocks';
import { updateSelectionHistory } from '../crdt-selection';
import { createYMap, getRootMap, type YMapWrap } from '../crdt-utils';
import type { Post } from '../../entity-types';
// Default synced properties matching the base set built in entities.js,
// plus 'categories' and 'tags' as example taxonomy rest_base values.
const defaultSyncedProperties = new Set< string >( [
'blocks',
'categories',
'content',
'date',
'excerpt',
'meta',
'slug',
'status',
'tags',
'title',
] );
describe( 'defaultSyncConfig', () => {
// Regression test for https://github.com/WordPress/gutenberg/issues/79907:
// reporting unchanged properties as changes marks resolved records
// (e.g. taxonomy terms) as dirty whenever a synced update arrives.
it( 'getChangesFromCRDTDoc returns no changes when the document matches the edited record', () => {
const doc = new Y.Doc();
const record = {
id: 1,
name: 'Uncategorized',
slug: 'uncategorized',
meta: [],
_links: { self: [ { href: 'https://example.com' } ] },
};
defaultSyncConfig.applyChangesToCRDTDoc( doc, record );
// The edited record deep-equals the document contents, but
// `Y.Map.toJSON()` returns fresh object instances.
const changes = defaultSyncConfig.getChangesFromCRDTDoc( doc, {
...record,
meta: [],
_links: { self: [ { href: 'https://example.com' } ] },
} );
expect( changes ).toEqual( {} );
doc.destroy();
} );
it( 'getChangesFromCRDTDoc returns only the properties that differ from the edited record', () => {
const doc = new Y.Doc();
defaultSyncConfig.applyChangesToCRDTDoc( doc, {
id: 1,
name: 'Renamed category',
slug: 'uncategorized',
} );
const changes = defaultSyncConfig.getChangesFromCRDTDoc( doc, {
id: 1,
name: 'Uncategorized',
slug: 'uncategorized',
} );
expect( changes ).toEqual( { name: 'Renamed category' } );
doc.destroy();
} );
} );
describe( 'defaultCollectionSyncConfig', () => {
it( 'has no-op applyChangesToCRDTDoc', () => {
const doc = new Y.Doc();
// Should not throw and return undefined.
expect(
defaultCollectionSyncConfig.applyChangesToCRDTDoc( doc, {
title: 'test',
} )
).toBeUndefined();
doc.destroy();
} );
it( 'has getChangesFromCRDTDoc that returns empty object', () => {
const doc = new Y.Doc();
const result = defaultCollectionSyncConfig.getChangesFromCRDTDoc( doc, {
title: 'test',
} );
expect( result ).toEqual( {} );
doc.destroy();
} );
it( 'shouldSync returns true when objectId is null (collection)', () => {
expect(
defaultCollectionSyncConfig.shouldSync?.( 'comment', null )
).toBe( true );
} );
it( 'shouldSync returns false when objectId is provided (individual record)', () => {
expect(
defaultCollectionSyncConfig.shouldSync?.( 'comment', '123' )
).toBe( false );
expect(
defaultCollectionSyncConfig.shouldSync?.( 'comment', 'foo' )
).toBe( false );
} );
} );
describe( 'crdt', () => {
let doc: Y.Doc;
beforeEach( () => {
doc = new Y.Doc();
jest.clearAllMocks();
jest.useFakeTimers();
} );
afterEach( () => {
jest.runAllTimers();
jest.useRealTimers();
doc.destroy();
} );
describe( 'applyPostChangesToCRDTDoc', () => {
let map: YMapWrap< YPostRecord >;
beforeEach( () => {
map = getRootMap< YPostRecord >( doc, CRDT_RECORD_MAP_KEY );
} );
it( 'applies simple property changes', () => {
const changes = {
title: 'New Title',
} as PostChanges;
applyPostChangesToCRDTDoc( doc, changes, defaultSyncedProperties );
const title = map.get( 'title' );
expect( title ).toBeInstanceOf( Y.Text );
expect( title?.toString() ).toBe( 'New Title' );
} );
it( 'does not sync disallowed properties', () => {
const changes = {
title: 'New Title',
unsyncedProperty: 'value',
} as unknown as PostChanges;
applyPostChangesToCRDTDoc( doc, changes, defaultSyncedProperties );
expect( map.has( 'unsyncedProperty' ) ).toBe( false );
expect( map.get( 'title' )?.toString() ).toBe( 'New Title' );
} );
it( 'does not sync function values', () => {
const changes = {
title: () => 'function value',
} as unknown as PostChanges;
applyPostChangesToCRDTDoc( doc, changes, defaultSyncedProperties );
expect( map.has( 'title' ) ).toBe( false );
} );
it( 'handles title with RenderedText format', () => {
const changes = {
title: { raw: 'Raw Title', rendered: 'Rendered Title' },
};
applyPostChangesToCRDTDoc( doc, changes, defaultSyncedProperties );
const title = map.get( 'title' );
expect( title ).toBeInstanceOf( Y.Text );
expect( title?.toString() ).toBe( 'Raw Title' );
} );
it( 'skips "Auto Draft" template title when no current value exists', () => {
const changes = {
title: 'Auto Draft',
} as PostChanges;
applyPostChangesToCRDTDoc( doc, changes, defaultSyncedProperties );
const title = map.get( 'title' );
expect( title ).toBeInstanceOf( Y.Text );
expect( title?.toString() ).toBe( '' );
} );
it( 'skips "Auto Draft" template title when current value is empty Y.Text', () => {
// First set an empty title (simulates a prior sync that cleared it).
applyPostChangesToCRDTDoc(
doc,
{ title: '' } as PostChanges,
defaultSyncedProperties
);
const title = map.get( 'title' );
expect( title ).toBeInstanceOf( Y.Text );
expect( title?.toString() ).toBe( '' );
// Now sync "Auto Draft" — should still be suppressed.
applyPostChangesToCRDTDoc(
doc,
{ title: 'Auto Draft' } as PostChanges,
defaultSyncedProperties
);
expect( map.get( 'title' )?.toString() ).toBe( '' );
} );
it( 'handles excerpt with RenderedText format', () => {
const changes = {
excerpt: {
protected: false,
raw: 'Raw excerpt',
rendered: 'Rendered excerpt',
},
};
applyPostChangesToCRDTDoc( doc, changes, defaultSyncedProperties );
const excerpt = map.get( 'excerpt' );
expect( excerpt ).toBeInstanceOf( Y.Text );
expect( excerpt?.toString() ).toBe( 'Raw excerpt' );
} );
it( 'does not sync empty slug', () => {
const changes = {
slug: '',
};
applyPostChangesToCRDTDoc( doc, changes, defaultSyncedProperties );
expect( map.has( 'slug' ) ).toBe( false );
} );
it( 'syncs non-empty slug', () => {
const changes = {
slug: 'my-post-slug',
};
applyPostChangesToCRDTDoc( doc, changes, defaultSyncedProperties );
expect( map.get( 'slug' ) ).toBe( 'my-post-slug' );
} );
it( 'merges blocks changes', () => {
map.set( 'blocks', new Y.Array< YBlock >() );
const changes = {
blocks: [
{
name: 'core/paragraph',
attributes: { content: 'Test' },
innerBlocks: [],
},
],
};
applyPostChangesToCRDTDoc( doc, changes, defaultSyncedProperties );
expect( ( map.get( 'blocks' ) as YBlocks ).toJSON() ).toEqual(
changes.blocks
);
} );
it( 'initializes blocks as Y.Array when not present', () => {
const changes = {
blocks: [],
};
applyPostChangesToCRDTDoc( doc, changes, defaultSyncedProperties );
const blocks = map.get( 'blocks' );
expect( blocks ).toBeInstanceOf( Y.Array );
} );
it( 'sets blocks to undefined when blocks value is undefined and no content is provided', () => {
// First, set some blocks.
map.set( 'blocks', new Y.Array< YBlock >() );
const changes = {
blocks: undefined,
};
applyPostChangesToCRDTDoc( doc, changes, defaultSyncedProperties );
// The key should still exist, but the value should be undefined.
expect( map.has( 'blocks' ) ).toBe( true );
expect( map.get( 'blocks' ) ).toBeUndefined();
} );
it( 'parses content into blocks when blocks=undefined is paired with new content', () => {
// Pre-populate the Y.Doc with two stable blocks. Simulates the
// state after the initial sync: peers share the same blocks Y.Array
// with stable clientIds on every YBlock.
applyPostChangesToCRDTDoc(
doc,
{
blocks: [
{
name: 'core/paragraph',
attributes: { content: 'Hello' },
innerBlocks: [],
clientId: 'stable-first',
},
{
name: 'core/paragraph',
attributes: { content: 'World' },
innerBlocks: [],
clientId: 'stable-second',
},
],
} as PostChanges,
defaultSyncedProperties
);
// The Code Editor flow: dispatch `{ content, blocks: undefined }`
// when the user types. The new HTML edits the second paragraph
// only. `parse()` is mocked to return blocks with freshly minted
// clientIds — the sync layer must not let those overwrite the
// stable clientIds already in the Y.Array.
( parse as jest.Mock ).mockReturnValueOnce( [
{
name: 'core/paragraph',
attributes: { content: 'Hello' },
innerBlocks: [],
clientId: 'fresh-first',
},
{
name: 'core/paragraph',
attributes: { content: 'World!' },
innerBlocks: [],
clientId: 'fresh-second',
},
] );
applyPostChangesToCRDTDoc(
doc,
{
content:
'<!-- wp:paragraph --><p>Hello</p><!-- /wp:paragraph -->' +
'<!-- wp:paragraph --><p>World!</p><!-- /wp:paragraph -->',
blocks: undefined,
} as PostChanges,
defaultSyncedProperties
);
const yblocks = map.get( 'blocks' );
expect( yblocks ).toBeInstanceOf( Y.Array );
const blocksArray = yblocks as YBlocks;
expect( blocksArray.length ).toBe( 2 );
// Both clientIds must be preserved: the unchanged first block via
// the left-right diff sweep, the edited second block via the
// explicit clientId-skip in the update loop.
expect( blocksArray.get( 0 ).get( 'clientId' ) ).toBe(
'stable-first'
);
expect( blocksArray.get( 1 ).get( 'clientId' ) ).toBe(
'stable-second'
);
// The second block's content reflects the edit.
const updatedContent = (
blocksArray
.get( 1 )
.get( 'attributes' ) as unknown as YMapWrap< YBlockRecord >
).get( 'content' ) as Y.Text;
expect( updatedContent.toString() ).toBe( 'World!' );
} );
it( 'syncs content as Y.Text', () => {
const changes = {
content: 'Hello, world!',
} as PostChanges;
applyPostChangesToCRDTDoc( doc, changes, defaultSyncedProperties );
const content = map.get( 'content' );
expect( content ).toBeInstanceOf( Y.Text );
expect( content?.toString() ).toBe( 'Hello, world!' );
} );
it( 'syncs content with RenderedText format', () => {
const changes = {
content: {
raw: '<!-- wp:paragraph --><p>Hello</p><!-- /wp:paragraph -->',
rendered: '<p>Hello</p>',
},
} as PostChanges;
applyPostChangesToCRDTDoc( doc, changes, defaultSyncedProperties );
const content = map.get( 'content' );
expect( content ).toBeInstanceOf( Y.Text );
expect( content?.toString() ).toBe(
'<!-- wp:paragraph --><p>Hello</p><!-- /wp:paragraph -->'
);
} );
it( 'updates existing Y.Text title in place via mergeRichTextUpdate', () => {
// First apply to create the Y.Text.
applyPostChangesToCRDTDoc(
doc,
{ title: 'Old Title' } as PostChanges,
defaultSyncedProperties
);
const titleRef = map.get( 'title' );
// Apply again — should update in place, not replace.
applyPostChangesToCRDTDoc(
doc,
{ title: 'New Title' } as PostChanges,
defaultSyncedProperties
);
expect( map.get( 'title' ) ).toBe( titleRef );
expect( map.get( 'title' )?.toString() ).toBe( 'New Title' );
} );
it( 'updates existing Y.Text content in place via mergeRichTextUpdate', () => {
// First apply to create the Y.Text.
applyPostChangesToCRDTDoc(
doc,
{ content: 'Old content' } as PostChanges,
defaultSyncedProperties
);
const contentRef = map.get( 'content' );
// Apply again — should update in place, not replace.
applyPostChangesToCRDTDoc(
doc,
{ content: 'New content' } as PostChanges,
defaultSyncedProperties
);
expect( map.get( 'content' ) ).toBe( contentRef );
expect( map.get( 'content' )?.toString() ).toBe( 'New content' );
} );
it( 'updates existing Y.Text excerpt in place via mergeRichTextUpdate', () => {
// First apply to create the Y.Text.
applyPostChangesToCRDTDoc(
doc,
{ excerpt: 'Old excerpt' } as PostChanges,
defaultSyncedProperties
);
const excerptRef = map.get( 'excerpt' );
// Apply again — should update in place, not replace.
applyPostChangesToCRDTDoc(
doc,
{ excerpt: 'New excerpt' } as PostChanges,
defaultSyncedProperties
);
expect( map.get( 'excerpt' ) ).toBe( excerptRef );
expect( map.get( 'excerpt' )?.toString() ).toBe( 'New excerpt' );
} );
it( 'syncs meta fields', () => {
const changes = {
meta: {
some_meta: 'new value',
},
};
const metaMap = createYMap();
metaMap.set( 'some_meta', 'old value' );
map.set( 'meta', metaMap );
applyPostChangesToCRDTDoc( doc, changes, defaultSyncedProperties );
expect( metaMap.get( 'some_meta' ) ).toBe( 'new value' );
} );
it( 'syncs non-single meta fields', () => {
const changes = {
meta: {
some_meta: [ 'value', 'value 2' ],
},
};
const metaMap = createYMap();
metaMap.set( 'some_meta', 'old value' );
map.set( 'meta', metaMap );
applyPostChangesToCRDTDoc( doc, changes, defaultSyncedProperties );
expect( metaMap.get( 'some_meta' ) ).toStrictEqual( [
'value',
'value 2',
] );
} );
it( 'initializes meta as Y.Map when not present', () => {
const changes = {
meta: {
custom_field: 'value',
},
};
applyPostChangesToCRDTDoc( doc, changes, defaultSyncedProperties );
const metaMap = map.get( 'meta' );
expect( metaMap ).toBeInstanceOf( Y.Map );
expect( metaMap?.get( 'custom_field' ) ).toBe( 'value' );
} );
it( 'skips function-valued content in changes', () => {
const changes = {
content: ( {
blocks: blocksForSerialization = [],
}: {
blocks: Block[];
} ) =>
blocksForSerialization
.map( ( b ) => b.attributes.content )
.join( '' ),
} as unknown as PostChanges;
applyPostChangesToCRDTDoc( doc, changes, defaultSyncedProperties );
expect( map.has( 'content' ) ).toBe( false );
} );
it( 'syncs taxonomy rest_base values included in syncedProperties', () => {
const changes = {
categories: [ 1, 2, 3 ],
genre: [ 10, 20 ], // should be ignored
tags: [ 4, 5 ],
} as unknown as PostChanges;
applyPostChangesToCRDTDoc( doc, changes, defaultSyncedProperties );
expect( map.get( 'categories' ) ).toEqual( [ 1, 2, 3 ] );
expect( map.get( 'genre' ) ).toBeUndefined();
expect( map.get( 'tags' ) ).toEqual( [ 4, 5 ] );
const customSyncedProperties = new Set( [
...defaultSyncedProperties,
'genre', // now included
] );
applyPostChangesToCRDTDoc( doc, changes, customSyncedProperties );
expect( map.get( 'categories' ) ).toEqual( [ 1, 2, 3 ] );
expect( map.get( 'genre' ) ).toEqual( [ 10, 20 ] );
expect( map.get( 'tags' ) ).toEqual( [ 4, 5 ] );
} );
} );
describe( 'getPostChangesFromCRDTDoc', () => {
let map: YMapWrap< YPostRecord >;
beforeEach( () => {
map = getRootMap< YPostRecord >( doc, CRDT_RECORD_MAP_KEY );
map.set( 'title', new Y.Text( 'CRDT Title' ) );
map.set( 'status', 'draft' );
map.set( 'date', '2025-01-01' );
} );
it( 'returns changes when values differ from record', () => {
const editedRecord = {
title: 'Old Title',
status: 'draft',
} as unknown as Post;
const changes = getPostChangesFromCRDTDoc(
doc,
editedRecord,
defaultSyncedProperties
);
expect( changes.title ).toBe( 'CRDT Title' );
} );
it( 'filters out disallowed properties', () => {
map.set( 'title', new Y.Text( 'Test title' ) );
map.set( 'unsyncedProp', 'value' );
const editedRecord = {} as Post;
const changes = getPostChangesFromCRDTDoc(
doc,
editedRecord,
defaultSyncedProperties
);
expect( changes ).not.toHaveProperty( 'unsyncedProp' );
expect( changes.title ).toBe( 'Test title' );
} );
it( 'does not sync auto-draft status', () => {
map.set( 'status', 'auto-draft' );
const editedRecord = {
status: 'draft',
} as unknown as Post;
const changes = getPostChangesFromCRDTDoc(
doc,
editedRecord,
defaultSyncedProperties
);
expect( changes ).not.toHaveProperty( 'status' );
} );
it( 'does not overwrite null floating date', () => {
map.set( 'status', 'draft' );
map.set( 'date', '' );
const editedRecord = {
status: 'draft',
date: null,
modified: '2025-01-01',
} as unknown as Post;
const changesWithEmptyDate = getPostChangesFromCRDTDoc(
doc,
editedRecord,
defaultSyncedProperties
);
expect( changesWithEmptyDate ).not.toHaveProperty( 'date' );
map.set( 'date', '2025-01-02' );
const changesWithDefinedDate = getPostChangesFromCRDTDoc(
doc,
editedRecord,
defaultSyncedProperties
);
expect( changesWithDefinedDate ).not.toHaveProperty( 'date' );
} );
it( 'does not overwrite defined floating date', () => {
map.set( 'status', 'draft' );
map.set( 'date', '' );
const editedRecord = {
status: 'draft',
date: '2025-01-01', // matches modified
modified: '2025-01-01',
} as unknown as Post;
const changesWithEmptyDate = getPostChangesFromCRDTDoc(
doc,
editedRecord,
defaultSyncedProperties
);
expect( changesWithEmptyDate ).not.toHaveProperty( 'date' );
map.set( 'date', '2025-01-02' );
const changesWithDefinedDate = getPostChangesFromCRDTDoc(
doc,
editedRecord,
defaultSyncedProperties
);
expect( changesWithDefinedDate ).not.toHaveProperty( 'date' );
} );
it( 'includes blocks in changes', () => {
map.set( 'blocks', new Y.Array< YBlock >() );
const editedRecord = {
blocks: [],
} as unknown as Post;
const changes = getPostChangesFromCRDTDoc(
doc,
editedRecord,
defaultSyncedProperties
);
expect( changes ).toHaveProperty( 'blocks' );
} );
it( 'returns rich-text block attributes as RichTextData, not strings', () => {
// Simulate User A writing a paragraph block into the CRDT doc.
addBlockToDoc( map, 'block-1', 'Hello world' );
// Simulate User B reading the CRDT doc with no local blocks.
const editedRecord = { blocks: [] } as unknown as Post;
const changes = getPostChangesFromCRDTDoc(
doc,
editedRecord,
defaultSyncedProperties
);
const block = ( changes.blocks as any[] )?.[ 0 ];
expect( block ).toBeDefined();
expect( block.attributes.content ).toBeInstanceOf( RichTextData );
expect( block.attributes.content.text ).toBe( 'Hello world' );
} );
it( 'returns nested rich-text in array attributes as RichTextData', () => {
// Add a table block to the CRDT doc with nested cell content
// stored as plain strings.
let blocks = map.get( 'blocks' );
if ( ! ( blocks instanceof Y.Array ) ) {
blocks = new Y.Array< YBlock >();
map.set( 'blocks', blocks );
}
const tableBlock = createYMap< YBlockRecord >();
tableBlock.set( 'name', 'core/table' );
tableBlock.set( 'clientId', 'table-1' );
const attrs = new Y.Map();
attrs.set( 'body', [
{
cells: [
{ content: '<strong>Cell</strong>', tag: 'td' },
{ content: 'Plain', tag: 'td' },
],
},
] );
tableBlock.set( 'attributes', attrs );
tableBlock.set( 'innerBlocks', new Y.Array() );
( blocks as YBlocks ).push( [ tableBlock ] );
const editedRecord = { blocks: [] } as unknown as Post;
const changes = getPostChangesFromCRDTDoc(
doc,
editedRecord,
defaultSyncedProperties
);
const block = ( changes.blocks as any[] )?.[ 0 ];
expect( block ).toBeDefined();
const cell = block.attributes.body[ 0 ].cells[ 0 ];
expect( cell.content ).toBeInstanceOf( RichTextData );
expect( ( cell.content as RichTextData ).toHTMLString() ).toBe(
'<strong>Cell</strong>'
);
} );
it( 'includes undefined blocks in changes', () => {
map.set( 'blocks', undefined );
const editedRecord = {
blocks: [
{
name: 'core/paragraph',
attributes: { content: 'Test' },
innerBlocks: [],
},
],
} as unknown as Post;
const changes = getPostChangesFromCRDTDoc(
doc,
editedRecord,
defaultSyncedProperties
);
expect( changes ).toHaveProperty( 'blocks' );
expect( changes.blocks ).toBeUndefined();
} );
it( 'detects content changes from string value', () => {
map.set( 'content', new Y.Text( 'New content' ) );
const editedRecord = {
content: 'Old content',
} as unknown as Post;
const changes = getPostChangesFromCRDTDoc(
doc,
editedRecord,
defaultSyncedProperties
);
expect( changes.content ).toBe( 'New content' );
} );
it( 'detects content changes from RenderedText value', () => {
map.set( 'content', new Y.Text( 'New content' ) );
const editedRecord = {
content: { raw: 'Old content', rendered: 'Old content' },
} as unknown as Post;
const changes = getPostChangesFromCRDTDoc(
doc,
editedRecord,
defaultSyncedProperties
);
expect( changes.content ).toBe( 'New content' );
} );
it( 'excludes content when unchanged from RenderedText value', () => {
map.set( 'content', new Y.Text( 'Same content' ) );
const editedRecord = {
content: { raw: 'Same content', rendered: 'Same content' },
} as unknown as Post;
const changes = getPostChangesFromCRDTDoc(
doc,
editedRecord,
defaultSyncedProperties
);
expect( changes ).not.toHaveProperty( 'content' );
} );
it( 'includes meta in changes', () => {
const metaMap = createYMap();
metaMap.set( 'public_meta', 'new value' );
map.set( 'meta', metaMap );
const editedRecord = {
meta: {
public_meta: 'old value',
},
} as unknown as Post;
const changes = getPostChangesFromCRDTDoc(
doc,
editedRecord,
defaultSyncedProperties
);
expect( changes.meta ).toEqual( {
public_meta: 'new value', // from CRDT
} );
} );
it( 'includes non-single meta in changes', () => {
const metaMap = createYMap();
metaMap.set( 'public_meta', [ 'value', 'value 2' ] );
map.set( 'meta', metaMap );
const editedRecord = {
meta: {
public_meta: 'value',
},
} as unknown as Post;
const changes = getPostChangesFromCRDTDoc(
doc,
editedRecord,
defaultSyncedProperties
);
expect( changes.meta ).toEqual( {
public_meta: [ 'value', 'value 2' ], // from CRDT
} );
} );
it( 'excludes orphaned meta keys not present on the edited record', () => {
// If post meta is registered, saved (landing in a CRDT doc),
// then unregistered, it can permanently mark the record dirty.
// Orphaned values should not show up as a change.
const metaMap = createYMap();
metaMap.set( 'registered_meta', 'value' );
metaMap.set( 'orphaned_meta', 'stale value' );
map.set( 'meta', metaMap );
const editedRecord = {
meta: {
registered_meta: 'value',
},
} as unknown as Post;
const changes = getPostChangesFromCRDTDoc(
doc,
editedRecord,
defaultSyncedProperties
);
expect( changes ).not.toHaveProperty( 'meta' );
} );
it( 'excludes disallowed meta keys in changes', () => {
const metaMap = createYMap();
metaMap.set( 'public_meta', 'new value' );
metaMap.set( POST_META_KEY_FOR_CRDT_DOC_PERSISTENCE, 'exclude me' );
map.set( 'meta', metaMap );
const editedRecord = {
meta: {
public_meta: 'old value',
},
} as unknown as Post;
const changes = getPostChangesFromCRDTDoc(
doc,
editedRecord,
defaultSyncedProperties
);
expect( changes.meta ).toEqual( {
public_meta: 'new value', // from CRDT
} );
expect( changes.meta ).not.toHaveProperty(
POST_META_KEY_FOR_CRDT_DOC_PERSISTENCE
);
} );
it( 'returns taxonomy rest_base changes when in syncedProperties', () => {
map.set( 'categories', [ 1, 2 ] );
map.set( 'genre', [ 10, 20 ] );
map.set( 'tags', [ 3 ] );
const editedRecord = {
categories: [ 1 ],
genre: [ 10 ], // should be ignored
tags: [],
} as unknown as Post;
const changes = getPostChangesFromCRDTDoc(
doc,
editedRecord,
defaultSyncedProperties
) as Record< string, unknown >;
expect( changes.categories ).toEqual( [ 1, 2 ] );
expect( changes.genre ).toBeUndefined();
expect( changes.tags ).toEqual( [ 3 ] );
const customSyncedProperties = new Set( [
...defaultSyncedProperties,
'genre', // now included
] );
const changes2 = getPostChangesFromCRDTDoc(
doc,
editedRecord,
customSyncedProperties
) as Record< string, unknown >;
expect( changes2.categories ).toEqual( [ 1, 2 ] );
expect( changes2.genre ).toEqual( [ 10, 20 ] );
expect( changes2.tags ).toEqual( [ 3 ] );
} );
describe( 'selection recalculation', () => {
it( 'includes recalculated selection when text is inserted before cursor', () => {
const ytext = addBlockToDoc( map, 'block-1', 'Hello world' );
// Record a selection at offset 5 (cursor between "Hello" and " world").
updateSelectionHistory( doc, {
selectionStart: {
clientId: 'block-1',
attributeKey: 'content',
offset: 5,
},
selectionEnd: {
clientId: 'block-1',
attributeKey: 'content',
offset: 5,
},
} );
// Simulate remote insertion: insert "XXX" at position 0.
ytext.insert( 0, 'XXX' );
const editedRecord = {
title: 'CRDT Title',
status: 'draft',
blocks: [],
} as unknown as Post;
const changes = getPostChangesFromCRDTDoc(
doc,
editedRecord,
defaultSyncedProperties
);
expect( changes.selection ).toBeDefined();
expect( changes.selection?.selectionStart.offset ).toBe( 8 ); // 5 + 3
expect( changes.selection?.selectionStart.clientId ).toBe(
'block-1'
);
expect( changes.selection?.selectionStart.attributeKey ).toBe(
'content'
);
expect( changes.selection?.selectionEnd.offset ).toBe( 8 );
} );
it( 'includes recalculated selection when text is deleted before cursor', () => {
const ytext = addBlockToDoc( map, 'block-1', 'Hello world' );
// Record a selection at offset 8 (cursor between "Hello wo" and "rld").
updateSelectionHistory( doc, {
selectionStart: {
clientId: 'block-1',
attributeKey: 'content',
offset: 8,
},
selectionEnd: {
clientId: 'block-1',
attributeKey: 'content',
offset: 8,
},
} );
// Simulate remote deletion: delete "Hello" (5 chars at position 0).
ytext.delete( 0, 5 );
const editedRecord = {
title: 'CRDT Title',
status: 'draft',
blocks: [],
} as unknown as Post;
const changes = getPostChangesFromCRDTDoc(
doc,
editedRecord,
defaultSyncedProperties
);
expect( changes.selection ).toBeDefined();
expect( changes.selection?.selectionStart.offset ).toBe( 3 ); // 8 - 5
} );
it( 'does not include selection when selection history is empty', () => {
addBlockToDoc( map, 'block-1', 'Hello world' );
const editedRecord = {
title: 'CRDT Title',
status: 'draft',
blocks: [],
} as unknown as Post;
const changes = getPostChangesFromCRDTDoc(
doc,
editedRecord,
defaultSyncedProperties
);
expect( changes.selection ).toBeUndefined();
} );
} );
it( 'injects a closure-based content function when blocks changed but content did not', () => {
addBlockToDoc( map, 'block-1', 'Hello world' );
const editedRecord = {
title: 'CRDT Title',
status: 'draft',
content: { raw: 'Same content', rendered: 'Same content' },
blocks: [],
} as unknown as Post;
const changes = getPostChangesFromCRDTDoc(
doc,
editedRecord,
defaultSyncedProperties
);
// Blocks changed, content didn't, so a lazy content function is injected.
expect( changes.blocks ).toBeDefined();
expect( typeof changes.content ).toBe( 'function' );
} );
it( 'injected content function captures the synced blocks and ignores its caller-supplied argument', () => {
addBlockToDoc( map, 'block-1', 'Hello world' );
const editedRecord = {
title: 'CRDT Title',
status: 'draft',
content: { raw: 'Same content', rendered: 'Same content' },
blocks: [],
} as unknown as Post;
const changes = getPostChangesFromCRDTDoc(
doc,
editedRecord,
defaultSyncedProperties
);
// The injected function takes no parameters and serializes the
// captured (synced) blocks. This is what makes getEditedPostContent
// keep working after the Code Editor clears `record.blocks` to force
// a re-parse: the closure already has the right blocks on hand.
//
// The mocked __unstableSerializeAndClean returns "serialized:<n>"
// where n is the length of the blocks it was called with. The
// captured blocks have one entry, so both calls below should yield
// "serialized:1" (proving the closure ignores its argument and
// uses the captured blocks instead).
const contentFn = changes.content as ( args?: {
blocks: Block[];
} ) => string;
expect( contentFn() ).toBe( 'serialized:1' );
expect( contentFn( { blocks: [] } ) ).toBe( 'serialized:1' );
} );
it( 'does not inject a content function when content also changed in the doc', () => {
addBlockToDoc( map, 'block-1', 'Hello world' );
map.set( 'content', new Y.Text( 'New content' ) );
const editedRecord = {
title: 'CRDT Title',
status: 'draft',
content: { raw: 'Old content', rendered: 'Old content' },
blocks: [],
} as unknown as Post;
const changes = getPostChangesFromCRDTDoc(
doc,
editedRecord,
defaultSyncedProperties
);
// Content changed directly, so it should be a string, not a function.
expect( changes.blocks ).toBeDefined();
expect( typeof changes.content ).toBe( 'string' );
expect( changes.content ).toBe( 'New content' );
} );
it( 'does not inject a content function when blocks did not change', () => {
map.set( 'content', new Y.Text( 'Same content' ) );
const editedRecord = {
title: 'CRDT Title',
status: 'draft',
content: { raw: 'Same content', rendered: 'Same content' },
} as unknown as Post;
const changes = getPostChangesFromCRDTDoc(
doc,
editedRecord,
defaultSyncedProperties
);
expect( changes.blocks ).toBeUndefined();
expect( changes.content ).toBeUndefined();
} );
} );
} );
/**
* Helper to create a block with a Y.Text content attribute
* in the CRDT document.
*
* @param map
* @param clientId Block client ID.
* @param content Initial text content.
* @param name Block name (default: 'core/paragraph').
*/
function addBlockToDoc(
map: YMapWrap< YPostRecord >,
clientId: string,
content: string,
name = 'core/paragraph'
): Y.Text {
let blocks = map.get( 'blocks' );
if ( ! ( blocks instanceof Y.Array ) ) {
blocks = new Y.Array< YBlock >();
map.set( 'blocks', blocks );
}
const block = createYMap< YBlockRecord >();
block.set( 'name', name );
block.set( 'clientId', clientId );
const attrs = new Y.Map();
const ytext = new Y.Text( content );
attrs.set( 'content', ytext );
block.set( 'attributes', attrs );
block.set( 'innerBlocks', new Y.Array() );
( blocks as YBlocks ).push( [ block ] );
return ytext;
}