Skip to content

[lexical-yjs][lexical-react] Feature: initial implementation of collab v2#7616

Merged
etrepum merged 30 commits intofacebook:mainfrom
james-atticus:yjs-v2-boilerplate
Oct 2, 2025
Merged

[lexical-yjs][lexical-react] Feature: initial implementation of collab v2#7616
etrepum merged 30 commits intofacebook:mainfrom
james-atticus:yjs-v2-boilerplate

Conversation

@james-atticus
Copy link
Contributor

@james-atticus james-atticus commented Jun 11, 2025

Description

Introduces a new useYjsCollaborationV2__EXPERIMENTAL hook and CollaborationPluginV2__EXPERIMENTAL plugin. Implementation of binding is heavily based off y-prosemirror. Tree shaking should mean that consumers who only use one version won't have a noticeable increase in bundle size.

The first commit is mostly refactoring, splitting out some shared logic from the original hook into useYjsCollaborationInternal (open to naming suggestions).

The second commit adds v2 implementations of syncLexicalUpdateToYjs and syncYjsChangesToLexical. Notably missing is all logic related to keeping selection updated and syncing cursors. That will come in a later PR.

Test plan

Unit tests have been updated to run with both v1 and v2 collab code. Despite the large diff (Github seems to be struggling even with whitespace hidden), the only change was to add the wrapping in describe.each and passing useCollabV2 down into the relevant functions (createTestConnection and expectCorrectInitialContent).

I have E2E tests running with v2 locally, however most of them fail because the selection in the right viewer isn't updated, so assertions about things like toolbar state fail. I'll look at enabling collab v2 in tests in a later PR.

@vercel
Copy link

vercel bot commented Jun 11, 2025

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Preview Comments Updated (UTC)
lexical Ready Ready Preview Comment Oct 2, 2025 10:39pm
lexical-playground Ready Ready Preview Comment Oct 2, 2025 10:39pm

@facebook-github-bot facebook-github-bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Jun 11, 2025
@james-atticus james-atticus marked this pull request as ready for review June 11, 2025 02:11
@james-atticus james-atticus changed the title [lexical-yjs][lexical-react] Refactor: split out useYjsCollaborationInternal with shared v1/v2 logic [lexical-yjs][lexical-react] Feature: initial implementation of collab v2 Jun 12, 2025
@james-atticus james-atticus changed the title [lexical-yjs][lexical-react] Feature: initial implementation of collab v2 [WIP][lexical-yjs][lexical-react] Feature: initial implementation of collab v2 Jun 12, 2025
@james-atticus james-atticus marked this pull request as draft June 12, 2025 23:46
browserName,
}) => {
test.skip(!isCollab || IS_MAC);
test.skip(!isCollab);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Drive-by: this test works fine on mac

Comment on lines 47 to 50
if (useCollabV2) {
// Manually bootstrap editor state.
await waitForReact(() => {
client1.update(() => $getRoot().append($createParagraphNode()));
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Needed because v2 doesn't support shouldBootstrap

Comment on lines 49 to 54
export type LexicalMapping = Map<
// eslint-disable-next-line @typescript-eslint/no-explicit-any
YAbstractType<any>,
// Either a node if type is YXmlElement or an Array of text nodes if YXmlText
LexicalNode | Array<TextNode>
>;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Equivalent of the type in y-prosemirror.

Comment on lines 317 to 326
const dirtyElements = new Set<NodeKey>();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const collectDirty = (_value: any, type: YAbstractType<any>) => {
if (binding.mapping.has(type)) {
const node = binding.mapping.get(type)!;
if (!(node instanceof Array)) {
dirtyElements.add(node.getKey());
}
}
};
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In y-prosemirror, for any YType that's been modified, the corresponding entry is deleted from the mapping. This then causes the sync code to recreate the code in createNodeIfNotExists. In Lexical however, we don't want to do this - we want to update the existing node if possible (i.e. keep the same nodeKey).

/**
* @return Returns node if node could be created. Otherwise it deletes the yjs type and returns null
*/
export const $createOrUpdateNodeFromYElement = (
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Per above, this is basically the same as createNodeFromYElement, except that it takes dirtyElements and updates existing nodes.

): Y.XmlText => {
const type = new Y.XmlText();
const delta = nodes.map((node) => ({
attributes: {__properties: propertiesToAttributes(node, meta)},
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

y-prosemirror has marks (marksToAttributes(node.marks, meta)) for text nodes and attributes (node.attrs) for element nodes. Lexical does have this same delineation - just got the one propertiesToAttributes helper function.

const rightY = yChildren[yChildCnt - right - 1];
const rightP = pChildren[pChildCnt - right - 1];
if (mappedIdentity(meta.mapping.get(rightY), rightP)) {
if (rightP instanceof ElementNode && dirtyElements.has(rightP.getKey())) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is where dirtyElements is used to update YFragments without removing them from the mapping.

tableCellBackgroundColor: true,
tableCellMerge: true,
tableHorizontalScroll: true,
useCollabV2: false,
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding as a setting but not exposing it through the settings menu yet, given how early days this still is.

Copy link
Collaborator

@etrepum etrepum left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is in a good state, it certainly seems to be an improvement over v1. Since it's experimental and not a default I think it's fairly low risk?

It's a shame that NodeState has to be serialized to/from string for Text nodes but if that becomes a bottleneck I'm sure that's something we could deal with later one way or another.

@etrepum etrepum added this pull request to the merge queue Oct 2, 2025
Merged via the queue into facebook:main with commit 9ceb974 Oct 2, 2025
39 checks passed
@etrepum etrepum mentioned this pull request Oct 11, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. extended-tests Run extended e2e tests on a PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants