diff --git a/packages/lexical-yjs/src/SyncCursors.ts b/packages/lexical-yjs/src/SyncCursors.ts index a55500e930e..8770ecf6edf 100644 --- a/packages/lexical-yjs/src/SyncCursors.ts +++ b/packages/lexical-yjs/src/SyncCursors.ts @@ -912,6 +912,17 @@ export function syncCursorPositions( if (cursor === undefined) { cursor = createCursor(name, color); cursors.set(clientID, cursor); + } else if (cursor.name !== name || cursor.color !== color) { + // Awareness is mutable: a peer can rename itself or change colour at + // any time (the React plugin republishes local state whenever its + // `username` / `cursorColor` props change). The name and colour are + // baked into the caret DOM and the ::highlight() rule when the + // selection is built, so drop the stale selection here and let the + // code below rebuild it from the new values. + destroyCursor(binding, cursor); + cursor.name = name; + cursor.color = color; + cursor.selection = null; } if (focusing) { diff --git a/packages/lexical-yjs/src/__tests__/unit/SyncCursorsAwarenessRefresh.test.ts b/packages/lexical-yjs/src/__tests__/unit/SyncCursorsAwarenessRefresh.test.ts new file mode 100644 index 00000000000..a391fb29ca7 --- /dev/null +++ b/packages/lexical-yjs/src/__tests__/unit/SyncCursorsAwarenessRefresh.test.ts @@ -0,0 +1,105 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { + buildEditorFromExtensions, + type LexicalEditorWithDispose, +} from '@lexical/extension'; +import { + createBinding, + type Provider, + syncCursorPositions, + type UserState, +} from '@lexical/yjs'; +import {defineExtension} from 'lexical'; +import {afterEach, assert, describe, expect, test} from 'vitest'; +import {Doc} from 'yjs'; + +const REMOTE_CLIENT_ID = 4242; + +function userState(name: string, color: string): UserState { + return { + anchorPos: null, + awarenessData: {}, + color, + focusPos: null, + focusing: false, + name, + }; +} + +describe('syncCursorPositions awareness refresh', () => { + const editors: LexicalEditorWithDispose[] = []; + afterEach(() => { + for (const editor of editors) { + editor.dispose(); + } + editors.length = 0; + }); + + function buildBinding() { + const editor = buildEditorFromExtensions( + defineExtension({ + $initialEditorState: null, + name: '[cursor-awareness]', + }), + ); + editors.push(editor); + const doc = new Doc(); + const docMap = new Map([['cursor-awareness', doc]]); + const binding = createBinding( + editor, + null as unknown as Provider, + 'cursor-awareness', + doc, + docMap, + ); + return {binding, editor}; + } + + function sync( + binding: ReturnType, + state: UserState, + ): void { + syncCursorPositions(binding, null as unknown as Provider, { + getAwarenessStates: () => + new Map([[REMOTE_CLIENT_ID, state]]), + }); + } + + test('a peer that renames itself updates its cursor name', () => { + const {binding} = buildBinding(); + + sync(binding, userState('Bob', '#ff0000')); + const cursor = binding.cursors.get(REMOTE_CLIENT_ID); + assert(cursor !== undefined); + expect(cursor.name).toBe('Bob'); + + sync(binding, userState('Robert', '#ff0000')); + expect(binding.cursors.get(REMOTE_CLIENT_ID)?.name).toBe('Robert'); + }); + + test('a peer that changes colour updates its cursor colour', () => { + const {binding} = buildBinding(); + + sync(binding, userState('Bob', '#ff0000')); + expect(binding.cursors.get(REMOTE_CLIENT_ID)?.color).toBe('#ff0000'); + + sync(binding, userState('Bob', '#0000ff')); + expect(binding.cursors.get(REMOTE_CLIENT_ID)?.color).toBe('#0000ff'); + }); + + test('an unchanged peer keeps the same cursor object', () => { + const {binding} = buildBinding(); + + sync(binding, userState('Bob', '#ff0000')); + const first = binding.cursors.get(REMOTE_CLIENT_ID); + sync(binding, userState('Bob', '#ff0000')); + expect(binding.cursors.get(REMOTE_CLIENT_ID)).toBe(first); + }); +});