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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions packages/core-data/src/utils/crdt-blocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -599,6 +599,30 @@ export function mergeCrdtBlocks(
break;
}

case 'clientId': {
// Never overwrite the local block's clientId with the
// incoming one. Some callers (e.g. the Code Editor flow
// that parses raw HTML into blocks on every keystroke)
// produce randomized clientIds for blocks whose content
// has changed on every sync. Without this case the default
// branch would replace the stable Y.Doc clientId with
// a new one, causing remote peers to remount the block
// and flash the block's content on reload.
//
// This mirrors the clientId exclusion in `areBlocksEqual`.
// Convergence is preserved. Because we're not writing
// to the clientId, Yjs doesn't send an update to peers
// telling them to change the clientId, so everyone
// sees the same clientId per block.
// Inserts still use a new clientId via createNewYBlock,
// and the duplicate-clientId sweep below catches any
// edge cases. The clientId is anchored to the
// slot in the array rather than to specific content,
// which is consistent with areBlocksEqual ignoring
// clientId when diffing.
break;
}

default:
if (
! fastDeepEqual(
Expand Down
60 changes: 53 additions & 7 deletions packages/core-data/src/utils/crdt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import fastDeepEqual from 'fast-deep-equal/es6/index.js';
*/
import {
__unstableSerializeAndClean,
parse,
type Block as WPBlock,
} from '@wordpress/blocks';
import {
Expand Down Expand Up @@ -157,8 +158,29 @@ export function applyPostChangesToCRDTDoc(

switch ( key ) {
case 'blocks': {
// Block changes from typing are bundled with a 'selection' update.
// Use the resulting cursor position for block merging.
const newCursorPosition = parseCursorSelection(
changes.selection
);

// Blocks are undefined when they need to be re-parsed from content.
if ( ! newValue ) {
// When new content is also part of this change (e.g. the Code
// Editor dispatching `{ content, blocks: undefined }` on every
// keystroke), derive blocks from content so the merge keeps
// stable YBlock identities for unchanged blocks.

const rawContent = getRawValue( changes.content );
if ( ! newValue && typeof rawContent === 'string' ) {
// We have no blocks but an updated content string.
mergeContentWithoutBlocks(
ymap,
rawContent,
newCursorPosition
);
break;
} else if ( ! newValue ) {
// We have an update containing empty blocks and content.
// Set to undefined instead of deleting the key. This is important
// since we iterate over the Y.Map keys in getPostChangesFromCRDTDoc.
ymap.set( key, undefined );
Expand All @@ -173,12 +195,6 @@ export function applyPostChangesToCRDTDoc(
ymap.set( key, currentBlocks );
}

// Block changes from typing are bundled with a 'selection' update.
// Pass the resulting cursor position to the mergeCrdtBlocks function.
const newCursorPosition = parseCursorSelection(
changes.selection
);

// Merge blocks does not need `setValue` because it is operating on a
// Yjs type that is already in the Y.Doc.
mergeCrdtBlocks( currentBlocks, newValue, newCursorPosition );
Expand Down Expand Up @@ -275,6 +291,36 @@ export function applyPostChangesToCRDTDoc(
}
}

/**
* Derive blocks from a raw content string and merge them into the post's
* blocks Y.Array. Used when a caller dispatches a change with `blocks:
* undefined` alongside new content, most notably the Code Editor's
* per-keystroke dispatch.
*
* @param ymap The post's root Y.Map.
* @param rawContent The raw HTML content to parse.
* @param cursorPosition Cursor position derived from the change's selection,
* used by mergeCrdtBlocks for rich-text cursor hints.
*/
function mergeContentWithoutBlocks(
ymap: YMapWrap< YPostRecord >,
rawContent: string,
cursorPosition: MergeCursorPosition
): void {
let currentBlocks = ymap.get( 'blocks' );

if ( ! ( currentBlocks instanceof Y.Array ) ) {
currentBlocks = new Y.Array< YBlock >();
ymap.set( 'blocks', currentBlocks );
}

mergeCrdtBlocks(
currentBlocks,
parse( rawContent ) as Block[],
cursorPosition
);
}

/**
* Only returns a selection object if it describes a selection within a block, with
* a cursor inside a RichText field associated with one of that block’s attributes.
Expand Down
37 changes: 37 additions & 0 deletions packages/core-data/src/utils/test/crdt-blocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,43 @@ describe( 'crdt-blocks', () => {
expect( content.toString() ).toBe( 'Updated content' );
} );

it( 'preserves the local clientId when an updated block arrives with a different clientId', () => {
// Simulates the Code Editor flow: the sender re-parses raw HTML on
// every keystroke, which mints a fresh clientId for every block.
// The Y.Doc's clientId should stay stable so remote peers don't
// remount the block (and any embed iframe within it).
const initialBlocks: Block[] = [
{
name: 'core/paragraph',
attributes: { content: 'Initial content' },
innerBlocks: [],
clientId: 'stable-id',
},
];

mergeCrdtBlocks( yblocks, initialBlocks, null );
expect( yblocks.get( 0 ).get( 'clientId' ) ).toBe( 'stable-id' );

const reparsedBlocks: Block[] = [
{
name: 'core/paragraph',
attributes: { content: 'Updated content' },
innerBlocks: [],
clientId: 'freshly-parsed-id',
},
];

mergeCrdtBlocks( yblocks, reparsedBlocks, null );

expect( yblocks.length ).toBe( 1 );
const block = yblocks.get( 0 );
expect( block.get( 'clientId' ) ).toBe( 'stable-id' );
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[] = [
{
Expand Down
86 changes: 85 additions & 1 deletion packages/core-data/src/utils/test/crdt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ jest.mock( '@wordpress/blocks', () => {
},
},
],
// 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 }`
),
Expand All @@ -55,6 +58,7 @@ jest.mock( '@wordpress/blocks', () => {
/**
* WordPress dependencies
*/
import { parse } from '@wordpress/blocks';
import { RichTextData } from '@wordpress/rich-text';

/**
Expand Down Expand Up @@ -295,7 +299,7 @@ describe( 'crdt', () => {
expect( blocks ).toBeInstanceOf( Y.Array );
} );

it( 'sets blocks to undefined when blocks value is undefined', () => {
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 >() );

Expand All @@ -310,6 +314,86 @@ describe( 'crdt', () => {
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!',
Expand Down
Loading