From 46c263c45063133a517c3ed06f8c3bb30215f4c1 Mon Sep 17 00:00:00 2001 From: Dan Luu Date: Thu, 14 May 2026 14:36:17 -0700 Subject: [PATCH 1/2] Add stale block base CRDT regressions --- .../utils/test/crdt-stale-block-base.test.ts | 262 ++++++++++++++++++ 1 file changed, 262 insertions(+) create mode 100644 packages/core-data/src/utils/test/crdt-stale-block-base.test.ts diff --git a/packages/core-data/src/utils/test/crdt-stale-block-base.test.ts b/packages/core-data/src/utils/test/crdt-stale-block-base.test.ts new file mode 100644 index 00000000000000..c73874486ba8a6 --- /dev/null +++ b/packages/core-data/src/utils/test/crdt-stale-block-base.test.ts @@ -0,0 +1,262 @@ +/** + * WordPress dependencies + */ +import { Y } from '@wordpress/sync'; + +/** + * External dependencies + */ +import { + afterEach, + beforeEach, + describe, + expect, + it, + jest, +} from '@jest/globals'; + +jest.mock( '../crdt-selection', () => ( { + getSelectionHistory: jest.fn( () => [] ), + getShiftedSelection: jest.fn( () => null ), + restoreSelection: jest.fn(), + updateSelectionHistory: jest.fn(), +} ) ); + +/** + * Internal dependencies + */ +import { CRDT_RECORD_MAP_KEY, LOCAL_EDITOR_ORIGIN } from '../../sync'; +import { applyPostChangesToCRDTDoc, type YPostRecord } from '../crdt'; +import { + mergeCrdtBlocks, + type Block, + type MergeCursorPosition, + type YBlock, + type YBlocks, +} from '../crdt-blocks'; +import { getRootMap } from '../crdt-utils'; + +const SYNCED_BLOCK_PROPERTIES = new Set( [ 'blocks' ] ); + +function paragraph( clientId: string, content: string ): Block { + return { + name: 'core/paragraph', + clientId, + attributes: { content }, + innerBlocks: [], + }; +} + +function blockContents( blocks: YBlocks ): string[] { + return ( blocks.toJSON() as Block[] ).map( + ( block ) => block.attributes.content as string + ); +} + +function postBlocks( ydoc: Y.Doc ): YBlocks { + return getRootMap< YPostRecord >( ydoc, CRDT_RECORD_MAP_KEY ).get( + 'blocks' + ) as YBlocks; +} + +const mergeCrdtBlocksWithBase = mergeCrdtBlocks as ( + yblocks: YBlocks, + incomingBlocks: Block[], + attributeCursor: MergeCursorPosition, + options?: { baseBlocks?: Block[] } +) => void; + +const applyPostChangesWithBase = applyPostChangesToCRDTDoc as ( + ydoc: Y.Doc, + changes: { blocks: Block[] }, + syncedProperties: Set< string >, + options?: { baseRecord?: { blocks: Block[] } } +) => void; + +describe( 'CRDT stale block base snapshots', () => { + let doc: Y.Doc; + let yblocks: YBlocks; + + beforeEach( () => { + doc = new Y.Doc(); + yblocks = doc.getArray< YBlock >(); + } ); + + afterEach( () => { + doc.destroy(); + } ); + + it( 'preserves an unseen remote top-level append when applying a stale local edit', () => { + const baseBlocks = [ + paragraph( 'local-edited', 'Alpha' ), + paragraph( 'unchanged', 'Beta' ), + ]; + const currentBlocks = [ + paragraph( 'local-edited', 'Alpha' ), + paragraph( 'unchanged', 'Beta' ), + paragraph( 'remote-appended', 'Gamma' ), + ]; + const staleLocalBlocks = [ + paragraph( 'local-edited', 'Alpha local edit' ), + paragraph( 'unchanged', 'Beta' ), + ]; + + mergeCrdtBlocks( yblocks, baseBlocks, null ); + mergeCrdtBlocks( yblocks, currentBlocks, null ); + mergeCrdtBlocksWithBase( yblocks, staleLocalBlocks, null, { + baseBlocks, + } ); + + expect( blockContents( yblocks ) ).toEqual( [ + 'Alpha local edit', + 'Beta', + 'Gamma', + ] ); + } ); + + it( 'preserves a remote top-level delete when applying a stale local edit', () => { + const baseBlocks = [ + paragraph( 'local-edited', 'Alpha' ), + paragraph( 'unchanged', 'Beta' ), + paragraph( 'remote-deleted', 'Gamma' ), + ]; + const currentBlocks = [ + paragraph( 'local-edited', 'Alpha' ), + paragraph( 'unchanged', 'Beta' ), + ]; + const staleLocalBlocks = [ + paragraph( 'local-edited', 'Alpha local edit' ), + paragraph( 'unchanged', 'Beta' ), + paragraph( 'remote-deleted', 'Gamma' ), + ]; + + mergeCrdtBlocks( yblocks, baseBlocks, null ); + mergeCrdtBlocks( yblocks, currentBlocks, null ); + mergeCrdtBlocksWithBase( yblocks, staleLocalBlocks, null, { + baseBlocks, + } ); + + expect( blockContents( yblocks ) ).toEqual( [ + 'Alpha local edit', + 'Beta', + ] ); + } ); + + it( 'keeps an observed local delete of a top-level block inserted by another peer', () => { + const baseBlocks = [ + paragraph( 'local-edited', 'Alpha' ), + paragraph( 'unchanged', 'Beta' ), + paragraph( 'observed-remote-insert', 'Gamma' ), + ]; + const staleLocalBlocks = [ + paragraph( 'local-edited', 'Alpha local edit' ), + paragraph( 'unchanged', 'Beta' ), + ]; + + mergeCrdtBlocks( yblocks, baseBlocks, null ); + mergeCrdtBlocksWithBase( yblocks, staleLocalBlocks, null, { + baseBlocks, + } ); + + expect( blockContents( yblocks ) ).toEqual( [ + 'Alpha local edit', + 'Beta', + ] ); + } ); + + it( 'uses the post CRDT baseRecord path to preserve a remote delete', () => { + const baseBlocks = [ + paragraph( 'local-edited', 'Alpha' ), + paragraph( 'unchanged', 'Beta' ), + paragraph( 'remote-deleted', 'Gamma' ), + ]; + + applyPostChangesWithBase( + doc, + { blocks: baseBlocks }, + SYNCED_BLOCK_PROPERTIES + ); + + applyPostChangesWithBase( + doc, + { + blocks: [ + paragraph( 'local-edited', 'Alpha' ), + paragraph( 'unchanged', 'Beta' ), + ], + }, + SYNCED_BLOCK_PROPERTIES, + { baseRecord: { blocks: baseBlocks } } + ); + + expect( blockContents( postBlocks( doc ) ) ).toEqual( [ + 'Alpha', + 'Beta', + ] ); + + applyPostChangesWithBase( + doc, + { + blocks: [ + paragraph( 'local-edited', 'Alpha local edit' ), + paragraph( 'unchanged', 'Beta' ), + paragraph( 'remote-deleted', 'Gamma' ), + ], + }, + SYNCED_BLOCK_PROPERTIES, + { baseRecord: { blocks: baseBlocks } } + ); + + expect( blockContents( postBlocks( doc ) ) ).toEqual( [ + 'Alpha local edit', + 'Beta', + ] ); + } ); + + it( 'applies local edits after reload regenerates block client IDs', () => { + const baseBlocks = [ paragraph( 'after-reload', 'Alpha' ) ]; + const currentBlocks = [ paragraph( 'before-reload', 'Alpha' ) ]; + const localBlocks = [ paragraph( 'after-reload', 'Alpha local edit' ) ]; + + mergeCrdtBlocks( yblocks, currentBlocks, null ); + mergeCrdtBlocksWithBase( yblocks, localBlocks, null, { + baseBlocks, + } ); + + expect( blockContents( yblocks ) ).toEqual( [ 'Alpha local edit' ] ); + } ); + + it( 'records undo for local edits after reload regenerates block client IDs', () => { + const recordMap = getRootMap< YPostRecord >( doc, CRDT_RECORD_MAP_KEY ); + const undoManager = new Y.UndoManager( recordMap, { + trackedOrigins: new Set( [ LOCAL_EDITOR_ORIGIN ] ), + } ); + const baseBlocks = [ paragraph( 'after-reload', 'Alpha' ) ]; + const currentBlocks = [ paragraph( 'before-reload', 'Alpha' ) ]; + const localBlocks = [ paragraph( 'after-reload', 'Alpha local edit' ) ]; + + applyPostChangesWithBase( + doc, + { blocks: currentBlocks }, + SYNCED_BLOCK_PROPERTIES + ); + + doc.transact( () => { + applyPostChangesWithBase( + doc, + { blocks: localBlocks }, + SYNCED_BLOCK_PROPERTIES, + { baseRecord: { blocks: baseBlocks } } + ); + }, LOCAL_EDITOR_ORIGIN ); + + expect( blockContents( postBlocks( doc ) ) ).toEqual( [ + 'Alpha local edit', + ] ); + expect( undoManager.canUndo() ).toBe( true ); + + undoManager.undo(); + + expect( blockContents( postBlocks( doc ) ) ).toEqual( [ 'Alpha' ] ); + } ); +} ); From b67abf18ab00c4324f110897a7fbce1642130e50 Mon Sep 17 00:00:00 2001 From: Dan Luu Date: Thu, 14 May 2026 14:43:18 -0700 Subject: [PATCH 2/2] Rebase stale block snapshots with base records --- packages/core-data/src/actions.js | 5 +- packages/core-data/src/entities.js | 18 +- .../src/hooks/use-entity-block-editor.js | 15 +- packages/core-data/src/utils/crdt-blocks.ts | 210 +++++++++++++++++- packages/core-data/src/utils/crdt.ts | 26 ++- packages/sync/src/manager.ts | 11 +- packages/sync/src/types.ts | 4 +- 7 files changed, 272 insertions(+), 17 deletions(-) diff --git a/packages/core-data/src/actions.js b/packages/core-data/src/actions.js index 3198daf2d43303..35ed854e3804db 100644 --- a/packages/core-data/src/actions.js +++ b/packages/core-data/src/actions.js @@ -462,7 +462,10 @@ export const editEntityRecord = objectId, editsWithMerges, origin, - { isNewUndoLevel } + { + baseRecord: options.baseRecord, + isNewUndoLevel, + } ); } if ( ! options.undoIgnore ) { diff --git a/packages/core-data/src/entities.js b/packages/core-data/src/entities.js index 83e1ccd1c79e5f..0de3d8a457906f 100644 --- a/packages/core-data/src/entities.js +++ b/packages/core-data/src/entities.js @@ -412,10 +412,24 @@ async function loadPostTypeEntities() { * * @param {import('@wordpress/sync').CRDTDoc} crdtDoc * @param {Partial< import('@wordpress/sync').ObjectData >} changes + * @param {Object} options * @return {void} */ - applyChangesToCRDTDoc: ( crdtDoc, changes ) => - applyPostChangesToCRDTDoc( crdtDoc, changes, syncedProperties ), + applyChangesToCRDTDoc: ( crdtDoc, changes, options ) => { + if ( options ) { + return applyPostChangesToCRDTDoc( + crdtDoc, + changes, + syncedProperties, + options + ); + } + return applyPostChangesToCRDTDoc( + crdtDoc, + changes, + syncedProperties + ); + }, /** * Create the awareness instance for the entity's CRDT document. diff --git a/packages/core-data/src/hooks/use-entity-block-editor.js b/packages/core-data/src/hooks/use-entity-block-editor.js index 2b569e71daab6c..e0c6f10ad4102b 100644 --- a/packages/core-data/src/hooks/use-entity-block-editor.js +++ b/packages/core-data/src/hooks/use-entity-block-editor.js @@ -1,7 +1,12 @@ /** * WordPress dependencies */ -import { useCallback, useMemo } from '@wordpress/element'; +import { + useCallback, + useLayoutEffect, + useMemo, + useRef, +} from '@wordpress/element'; import { useDispatch, useSelect } from '@wordpress/data'; import { parse, __unstableSerializeAndClean } from '@wordpress/blocks'; @@ -82,6 +87,10 @@ export default function useEntityBlockEditor( kind, name, { id: _id } = {} ) { return _blocks; }, [ kind, name, id, editedBlocks, content ] ); + const blocksRef = useRef( blocks ); + useLayoutEffect( () => { + blocksRef.current = blocks; + }, [ blocks ] ); const onChange = useCallback( ( newBlocks, options ) => { @@ -102,6 +111,7 @@ export default function useEntityBlockEditor( kind, name, { id: _id } = {} ) { }; editEntityRecord( kind, name, id, edits, { + baseRecord: { blocks }, isCached: false, ...rest, } ); @@ -126,11 +136,12 @@ export default function useEntityBlockEditor( kind, name, { id: _id } = {} ) { }; editEntityRecord( kind, name, id, edits, { + baseRecord: { blocks: blocksRef.current }, isCached: true, ...rest, } ); }, - [ kind, name, id, meta, editEntityRecord ] + [ kind, name, id, meta, editEntityRecord, blocksRef ] ); return [ blocks, onInput, onChange ]; diff --git a/packages/core-data/src/utils/crdt-blocks.ts b/packages/core-data/src/utils/crdt-blocks.ts index 0c5e56ff33c7a2..172776cf98ac71 100644 --- a/packages/core-data/src/utils/crdt-blocks.ts +++ b/packages/core-data/src/utils/crdt-blocks.ts @@ -77,6 +77,10 @@ export type YBlockAttributes = Y.Map< Y.Text | unknown >; */ export type MergeCursorPosition = WPBlockSelection | null; +export interface MergeCrdtBlocksOptions { + baseBlocks?: Block[]; +} + const serializableBlocksCache = new WeakMap< WeakKey, Block[] >(); /** @@ -157,6 +161,185 @@ function makeBlocksSerializable( blocks: Block[] ): Block[] { } ); } +function getBlockClientId( block: Block ): string | undefined { + return typeof block.clientId === 'string' && block.clientId + ? block.clientId + : undefined; +} + +function getClientIdsIfEveryBlockHasUniqueId( + blocks: Block[] +): string[] | undefined { + const clientIds = blocks.map( getBlockClientId ); + + if ( clientIds.some( ( clientId ) => ! clientId ) ) { + return undefined; + } + + const uniqueClientIds = new Set( clientIds ); + + return uniqueClientIds.size === clientIds.length + ? ( clientIds as string[] ) + : undefined; +} + +function getBlocksByClientId( blocks: Block[] ): Map< string, Block > { + return new Map( + blocks.map( ( block ) => [ + getBlockClientId( block ) as string, + block, + ] ) + ); +} + +function haveSharedClientId( + firstClientIds: string[], + secondClientIds: string[] +): boolean { + const secondClientIdSet = new Set( secondClientIds ); + return firstClientIds.some( ( clientId ) => + secondClientIdSet.has( clientId ) + ); +} + +function findBlockIndexByClientId( blocks: Block[], clientId: string ): number { + return blocks.findIndex( + ( block ) => getBlockClientId( block ) === clientId + ); +} + +function getRemoteBlockInsertIndex( + currentBlocks: Block[], + currentIndex: number, + blocksToSync: Block[], + blockIdsToSync: Set< string > +): number { + for ( let index = currentIndex - 1; index >= 0; index-- ) { + const previousClientId = getBlockClientId( currentBlocks[ index ] ); + + if ( previousClientId && blockIdsToSync.has( previousClientId ) ) { + const previousIndex = findBlockIndexByClientId( + blocksToSync, + previousClientId + ); + + return previousIndex === -1 + ? blocksToSync.length + : previousIndex + 1; + } + } + + for ( + let index = currentIndex + 1; + index < currentBlocks.length; + index++ + ) { + const nextClientId = getBlockClientId( currentBlocks[ index ] ); + + if ( nextClientId && blockIdsToSync.has( nextClientId ) ) { + const nextIndex = findBlockIndexByClientId( + blocksToSync, + nextClientId + ); + + return nextIndex === -1 ? blocksToSync.length : nextIndex; + } + } + + return blocksToSync.length; +} + +function reconcileBlockWithBase( + localBlock: Block, + currentBlock: Block, + baseBlock: Block +): Block { + const innerBlocks = reconcileBlocksWithBase( + localBlock.innerBlocks ?? [], + currentBlock.innerBlocks ?? [], + baseBlock.innerBlocks ?? [] + ); + + return innerBlocks === localBlock.innerBlocks + ? localBlock + : { ...localBlock, innerBlocks }; +} + +function reconcileBlocksWithBase( + localBlocksToSync: Block[], + currentBlocks: Block[], + baseBlocks: Block[] +): Block[] { + const localClientIds = + getClientIdsIfEveryBlockHasUniqueId( localBlocksToSync ); + const currentClientIds = + getClientIdsIfEveryBlockHasUniqueId( currentBlocks ); + const baseClientIds = getClientIdsIfEveryBlockHasUniqueId( baseBlocks ); + + if ( ! localClientIds || ! currentClientIds || ! baseClientIds ) { + return localBlocksToSync; + } + + // Save/reload can regenerate editor client IDs while the CRDT document + // still has the old IDs. In that state, ID-based stale-delete rebasing + // would treat every local block as remotely deleted. + if ( + baseClientIds.length > 0 && + currentClientIds.length > 0 && + ! haveSharedClientId( baseClientIds, currentClientIds ) + ) { + return localBlocksToSync; + } + + const currentBlocksByClientId = getBlocksByClientId( currentBlocks ); + const baseBlocksByClientId = getBlocksByClientId( baseBlocks ); + const localClientIdSet = new Set( localClientIds ); + const baseClientIdSet = new Set( baseClientIds ); + const blocksToSync: Block[] = []; + const blockIdsToSync = new Set< string >(); + + localBlocksToSync.forEach( ( localBlock ) => { + const clientId = getBlockClientId( localBlock ) as string; + const currentBlock = currentBlocksByClientId.get( clientId ); + const baseBlock = baseBlocksByClientId.get( clientId ); + + if ( baseBlock && ! currentBlock ) { + return; + } + + const blockToSync = + baseBlock && currentBlock + ? reconcileBlockWithBase( localBlock, currentBlock, baseBlock ) + : localBlock; + + blocksToSync.push( blockToSync ); + blockIdsToSync.add( clientId ); + } ); + + currentBlocks.forEach( ( currentBlock, currentIndex ) => { + const clientId = getBlockClientId( currentBlock ) as string; + + if ( + baseClientIdSet.has( clientId ) || + localClientIdSet.has( clientId ) || + blockIdsToSync.has( clientId ) + ) { + return; + } + + const insertIndex = getRemoteBlockInsertIndex( + currentBlocks, + currentIndex, + blocksToSync, + blockIdsToSync + ); + blocksToSync.splice( insertIndex, 0, currentBlock ); + blockIdsToSync.add( clientId ); + } ); + + return blocksToSync; +} + /** * Recursively walk an attribute value and convert any strings that correspond * to rich-text schema nodes into RichTextData instances. This is the inverse @@ -415,16 +598,19 @@ function createNewYBlock( block: Block ): YBlock { * Merge incoming block data into the local Y.Doc. * This function is called to sync local block changes to a shared Y.Doc. * - * @param yblocks The blocks in the local Y.Doc. - * @param incomingBlocks Gutenberg blocks being synced. - * @param attributeCursor When provided, describes a selection cursor falling within a - * RichText field associated with a specific block and attribute. - * Derived from the changes that produced the blocks. + * @param yblocks The blocks in the local Y.Doc. + * @param incomingBlocks Gutenberg blocks being synced. + * @param attributeCursor When provided, describes a selection cursor falling within a + * RichText field associated with a specific block and attribute. + * Derived from the changes that produced the blocks. + * @param options Merge options. + * @param options.baseBlocks Pre-change block snapshot to rebase against. */ export function mergeCrdtBlocks( yblocks: YBlocks, incomingBlocks: Block[], - attributeCursor: MergeCursorPosition + attributeCursor: MergeCursorPosition, + options: MergeCrdtBlocksOptions = {} ): void { // Ensure we are working with serializable block data. if ( ! serializableBlocksCache.has( incomingBlocks ) ) { @@ -434,8 +620,18 @@ export function mergeCrdtBlocks( ); } - const incomingBlocksToSync = + const localBlocksToSync = serializableBlocksCache.get( incomingBlocks ) ?? []; + const baseBlocksToSync = options.baseBlocks + ? makeBlocksSerializable( options.baseBlocks ) + : undefined; + const incomingBlocksToSync = baseBlocksToSync + ? reconcileBlocksWithBase( + localBlocksToSync, + yblocks.toJSON() as Block[], + baseBlocksToSync + ) + : localBlocksToSync; // This is a rudimentary diff implementation similar to the y-prosemirror diffing // approach. diff --git a/packages/core-data/src/utils/crdt.ts b/packages/core-data/src/utils/crdt.ts index a8d7b4bc2f378c..e59027182c6ca5 100644 --- a/packages/core-data/src/utils/crdt.ts +++ b/packages/core-data/src/utils/crdt.ts @@ -24,6 +24,7 @@ import { type Block, deserializeBlockAttributes, mergeCrdtBlocks, + type MergeCrdtBlocksOptions, type MergeCursorPosition, mergeRichTextUpdate, type YBlock, @@ -55,6 +56,10 @@ export type PostChanges = Partial< Post > & { title?: Post[ 'title' ] | string; }; +interface ApplyChangesToCRDTDocOptions { + baseRecord?: Partial< ObjectData >; +} + // A post record as represented in the CRDT document (Y.Map). export interface YPostRecord extends YMapRecord { author: number; @@ -122,12 +127,15 @@ function defaultApplyChangesToCRDTDoc( * @param {CRDTDoc} ydoc * @param {PostChanges} changes * @param {Set} syncedProperties + * @param {Object} options + * @param {ObjectData} options.baseRecord * @return {void} */ export function applyPostChangesToCRDTDoc( ydoc: CRDTDoc, changes: PostChanges, - syncedProperties: Set< string > + syncedProperties: Set< string >, + options: ApplyChangesToCRDTDocOptions = {} ): void { const ymap = getRootMap< YPostRecord >( ydoc, CRDT_RECORD_MAP_KEY ); @@ -169,7 +177,21 @@ export function applyPostChangesToCRDTDoc( // 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 ); + const mergeOptions: MergeCrdtBlocksOptions = {}; + const baseBlocks = ( + options.baseRecord as PostChanges | undefined + )?.blocks; + + if ( Array.isArray( baseBlocks ) ) { + mergeOptions.baseBlocks = baseBlocks; + } + + mergeCrdtBlocks( + currentBlocks, + newValue, + newCursorPosition, + mergeOptions + ); break; } diff --git a/packages/sync/src/manager.ts b/packages/sync/src/manager.ts index 7444f1c4b9b318..c9dc1e629c47d2 100644 --- a/packages/sync/src/manager.ts +++ b/packages/sync/src/manager.ts @@ -555,6 +555,7 @@ export function createSyncManager( debug = false ): SyncManager { * @param {Partial< ObjectData >} changes Updates to make. * @param {string} origin The source of change. * @param {SyncManagerUpdateOptions} options Optional flags for the update. + * @param {ObjectData} options.baseRecord Entity record snapshot before the change. * @param {boolean} options.isSave Whether this update is part of a save operation. Defaults to false. * @param {boolean} options.isNewUndoLevel Whether to create a new undo level for this change. Defaults to false. */ @@ -565,7 +566,7 @@ export function createSyncManager( debug = false ): SyncManager { origin: string, options: SyncManagerUpdateOptions = {} ): void { - const { isSave = false, isNewUndoLevel = false } = options; + const { baseRecord, isSave = false, isNewUndoLevel = false } = options; const entityId = getEntityId( objectType, objectId ); const entityState = entityStates.get( entityId ); const collectionState = collectionStates.get( objectType ); @@ -586,7 +587,13 @@ export function createSyncManager( debug = false ): SyncManager { log( 'updateCRDTDoc', 'applying changes', entityId, { changedKeys: Object.keys( changes ), } ); - syncConfig.applyChangesToCRDTDoc( ydoc, changes ); + if ( baseRecord ) { + syncConfig.applyChangesToCRDTDoc( ydoc, changes, { + baseRecord, + } ); + } else { + syncConfig.applyChangesToCRDTDoc( ydoc, changes ); + } if ( isSave ) { markEntityAsSaved( ydoc ); diff --git a/packages/sync/src/types.ts b/packages/sync/src/types.ts index e9a3e57bfeae09..a29e005dd3c014 100644 --- a/packages/sync/src/types.ts +++ b/packages/sync/src/types.ts @@ -119,6 +119,7 @@ export interface CollectionHandlers { } export interface SyncManagerUpdateOptions { + baseRecord?: Partial< ObjectData >; isSave?: boolean; isNewUndoLevel?: boolean; } @@ -139,7 +140,8 @@ export interface RecordHandlers { export interface SyncConfig { applyChangesToCRDTDoc: ( ydoc: Y.Doc, - changes: Partial< ObjectData > + changes: Partial< ObjectData >, + options?: { baseRecord?: Partial< ObjectData > } ) => void; createAwareness?: ( ydoc: Y.Doc,