Skip to content

[lexical-yjs] Bug Fix: don't sync ElementNode __dir property#7330

Merged
etrepum merged 2 commits intofacebook:mainfrom
james-atticus:dont-sync-dir-in-yjs
Mar 27, 2025
Merged

[lexical-yjs] Bug Fix: don't sync ElementNode __dir property#7330
etrepum merged 2 commits intofacebook:mainfrom
james-atticus:dont-sync-dir-in-yjs

Conversation

@james-atticus
Copy link
Copy Markdown
Contributor

@james-atticus james-atticus commented Mar 14, 2025

Description

LexicalReconciler tries to determine whether text should be LTR or RTL based on a regex.

If a given block has no directioned text, then the activeEditorDirection is used. This more-or-less-global variable is set to null at the start of reconciliation but updated when a node with directioned text is encountered.

That's fine when processing the whole tree: node get updated in reading order. However, if you process only some nodes in the tree, then you can end up changing the direction of a node in an unexpected way. This is shown in the third test case where marking the first and third nodes as dirty changes the direction of the latter to RTL.

Edit 2025-03-27: updated the PR to use the second approach below, i.e. exclude __dir for Yjs syncing.

Potential fixes

Don't set node direction to LTR if no directioned text

This makes it so that the node direction is more in sync with the dir property set in the DOM. Currently the reconciler clears the dir property if direction is null OR if it's LTR and the node has no directioned text: https://github.com/facebook/lexical/blob/main/packages/lexical/src/LexicalReconciler.ts#L390-L395

This approach is the one I've gone with in the current PR. The result is that the two editors have slightly different DOMs ('ltr' is removed in left editor but kept in right editor), however I don't think this is a huge issue. That said, a bunch of unit tests are failing.

Mark __dir as an excluded property for Yjs syncing

Based on what the reconciler is doing, figuring out direction from the text content, arguably __dir is a derived node property in the same way that __cachedText is on the root node: https://github.com/facebook/lexical/blob/main/packages/lexical-yjs/src/Utils.ts#L50-L54

I tried adding __dir to the excluded list for element nodes and it was still only the Table test that needed updating.

This feels like a reasonable approach to me, given __dir is derived state, however it would mean you could end up with two editors that display a given node with different directions. Take the example from the third test case - the two nodes were marked dirty but had no changes, so nothing would be synced to Lexical, so other editors would still have null as the direction for the third paragraph.

Backtrack through the tree to determine activeEditorDirection

This would be the most complete fix, however could be a significant change. Didn't want to go down this path without first discussing (and ruling out) the two options above.

The idea here would be to not use activeEditorState but instead look at the previous block node in the tree (direct sibling, or sibling of a parent) and use its direction, assuming you were processing a node with no directioned text. That should give the same result as processing the whole tree in order.

Process the whole tree when reconciling direction

This would give consistent results and would be a small code change, but is a bad idea for obvious performance reasons.

@vercel
Copy link
Copy Markdown

vercel bot commented Mar 14, 2025

The latest updates on your projects. Learn more about Vercel for Git ↗︎

Name Status Preview Comments Updated (UTC)
lexical ✅ Ready (Inspect) Visit Preview 💬 Add feedback Mar 27, 2025 0:14am
lexical-playground ✅ Ready (Inspect) Visit Preview 💬 Add feedback Mar 27, 2025 0:14am

@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 Mar 14, 2025
@etrepum
Copy link
Copy Markdown
Collaborator

etrepum commented Mar 14, 2025

I don't personally have any domain expertise with bidi text so will have to wait for some feedback from other contributors to see what the ideal semantics are. I agree that it seems like it should primarily be a derived/computed property, but without full context I think we need to start with declaring what the DOM should be in the ideal scenario and then work backwards to come up with an implementation strategy that matches that expectation efficiently.

I suspect that probably the complexity here is to make sure that if the user is entering rtl text in one paragraph and then creates another paragraph then it should probably start off as rtl even when empty so the selection is visually in the expected place?

Some questions to answer:

  • Is there a use case for persisting dir outside of the DOM?
  • What should it be set to when ltr and rtl text ends up in the same element?
  • How should that get reconciled when the timing of those events are interleaved due to collab?

@zurfyx - who is the best resource for this?

@etrepum
Copy link
Copy Markdown
Collaborator

etrepum commented Mar 20, 2025

I haven't looked deeply into this but we discussed it briefly on a call this morning. I would lean towards whatever the best long term solution is going to be, when updates to the DOM are made the reconciler is already processing essentially the whole tree top-down (skipping subtrees that are not dirty) so maybe reconciling the direction in a deterministic way could be done coming back up the tree at render time? The directional information could be cached on each DOM node as a cache.

I think the real question is whether the ltr/rtl decisions need to be persisted outside of the DOM and if it makes sense to honor them as an override or have the reconciler update them in-place? Like before, I don't have much direct bidi experience to know what the correct UX and expectations are around this.

@james-atticus
Copy link
Copy Markdown
Contributor Author

if the user is entering rtl text in one paragraph and then creates another paragraph then it should probably start off as rtl

That definitely makes sense. A natural follow-on question is what happens if the sibling paragraph then changes to LTR? What if you have N blank paragraphs? Thinking about the collab case, my gut feeling is that the blank paragraphs should stay as they were when they were created. Say User 1 has their cursor on the blank paragraph, and User 2 changes the first paragraph's text to LTR -- it would seem odd to have that move User 1's cursor to the other side of the page.

I can also see it getting tricky when you have cases such as nested lists - if you're skipping non-dirty sub-trees, you could end up with parent elements that have one direction (because a sibling changed) and children with another. However...

reconciling the direction in a deterministic way could be done coming back up the tree at render time

I haven't looked into any of the rendering code yet -- maybe what you're suggesting would solve the nested list case.

the real question is whether the ltr/rtl decisions need to be persisted outside of the DOM

Agree. That said, given that the current implementation derives direction from text content (i.e. ignores anything persisted), would you be open to a PR that implements the second suggestion above ("Mark __dir as an excluded property for Yjs syncing")? Alternatively, we could just add it to our own excludedProperties, but this feels like something that, if safe to do, should be done in core Lexical.

NB: For context, this came up because we have a custom DecoratorNode on which we set a property based on some Redux state, where that property is in excludedProperties. However, the act of setting that property marks the node and its parents as dirty -> triggers the direction reconciliation -> updates Yjs -> gets recorded as the document being modified.

@etrepum
Copy link
Copy Markdown
Collaborator

etrepum commented Mar 26, 2025

It probably makes sense to have __dir be excluded in yjs as a first step, as long as there are tests to show that it behaves the way it's expected to. I can't imagine that breaking anything, because AFAICT you can't really explicitly set __dir and expect it to stay that way because reconcileBlockDirection is unavoidable. I have a hard time imagining ways that it would make things worse than the status quo.

There isn't really anywhere to associate versioned data (or cache computed data) with a LexicalNode without marking it as dirty, since they're copy on write and the connection to the children are indirect. The previous version has to be considered frozen (in __DEV__ they will actually be frozen) since that computed property could depend on anything else and might not make sense in the past.

@james-atticus james-atticus changed the title [RFC] [lexical] Bug Fix: don't set 'ltr' direction on node with no directioned text [lexical-yjs] Bug Fix: don't sync ElementNode __dir property Mar 27, 2025
@james-atticus
Copy link
Copy Markdown
Contributor Author

PR updated to exclude __dir from Yjs syncing with associated test case. I've also left in a test case for the reconciler to at least document the weird behaviour with activeEditorDirection.

@james-atticus james-atticus marked this pull request as ready for review March 27, 2025 00:21
Copy link
Copy Markdown
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.

LGTM!

@etrepum etrepum added the extended-tests Run extended e2e tests on a PR label Mar 27, 2025
@etrepum
Copy link
Copy Markdown
Collaborator

etrepum commented Mar 27, 2025

(will wait for all extended collab tests to run before merging)

@etrepum etrepum added this pull request to the merge queue Mar 27, 2025
Merged via the queue into facebook:main with commit 7d59a1a Mar 27, 2025
68 checks passed
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