Skip to content

[lexical-react] Fix: ensure selection listeners cleaned up from original owner document#7843

Merged
etrepum merged 3 commits intofacebook:mainfrom
james-atticus:defer-set-root-element-until-connected
Sep 30, 2025
Merged

[lexical-react] Fix: ensure selection listeners cleaned up from original owner document#7843
etrepum merged 3 commits intofacebook:mainfrom
james-atticus:defer-set-root-element-until-connected

Conversation

@james-atticus
Copy link
Copy Markdown
Contributor

@james-atticus james-atticus commented Sep 22, 2025

Description

When rendering into a different window using createPortal, eg with react-new-window, ownerDocument initially refers to the document in which the element was created. Once React has mounted it into the portal however, it points to the portal's document.

This causes an issue with ref counting in addRootElementEvents/removeRootElementEvents as the counter is incremented for one document but decremented for the other, causing invariant(newCount >= 0, 'Root element count less than 0') to fail. It also means that the listeners are not added to the document in which the root element is mounted.

When useCallback refs are used, the ref is called before the element is in DOM: in the debugger, we observed that rootElement.isConnected is false. By switching to an effect, the code is only called after the element has been attached to the new document, i.e. isConnected is true and ownerDocument has the correct reference.

Rather than looking at ownerDocument again when removing listeners, we now instead track what the owner document was at registration time, and decrement the counter/remove listeners for that document.

Test plan

Wasn't able to repro in unit tests, but we monkey-patched this change into Lexical in our application and confirmed it fixes the crash.

Added a unit test which fails pre-change.

@vercel
Copy link
Copy Markdown

vercel bot commented Sep 22, 2025

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

Project Deployment Preview Comments Updated (UTC)
lexical Ready Ready Preview Comment Sep 29, 2025 6:25am
lexical-playground Ready Ready Preview Comment Sep 29, 2025 6:25am

@meta-cla meta-cla 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 Sep 22, 2025
@james-atticus james-atticus marked this pull request as draft September 22, 2025 06:55
@james-atticus james-atticus changed the title [lexical-react] Fix: ensure selection listeners are attached to correct owner document [WIP] [lexical-react] Fix: ensure selection listeners are attached to correct owner document Sep 22, 2025
@etrepum
Copy link
Copy Markdown
Collaborator

etrepum commented Sep 22, 2025

Might be a good opportunity to set up a test suite that runs in vitest browser mode now that we are not using jest?

My primary concern is that we don't know exactly when the ownerDocument will change, in the general case I think a node could be adopted by another document at any time. It would be good to see the sort of code that requires this sort of workaround, maybe it would be better to figure out how to set or reset the rootElement after it is in the correct Document and add some documentation about why that should happen and how to do it.

The counter situation should be fixable by using a Set<editor> instead of a number. Maybe also add some robustness to the event handler if it receives an event for a document that doesn't correspond to the current root ownerDocument (and either do the fix-up or log a warning about how to handle roots that change ownerDocument correctly).

@james-atticus
Copy link
Copy Markdown
Contributor Author

Will give that a go.

@james-atticus
Copy link
Copy Markdown
Contributor Author

james-atticus commented Sep 29, 2025

Changed tack a little bit per your suggestion, and am now tracking which document the element was registered with, then decrementing the counter for that document. I stuck with WeakMap given that's what the initial implementation used, but can switch to Map if you're not worried about the potential memory leak if editors aren't cleaned up properly.

My primary concern is that we don't know exactly when the ownerDocument will change, in the general case I think a node could be adopted by another document at any time.

This is still a concern, however I couldn't see a good way to solve it in the general case. The closest I could find was MutationObserver, but in our case, the node was never actually attached to the original document's tree, so I don't think that'll help.

Which is to say, it'll still be up to the caller to track how they're moving elements around in documents. For us, we'll be able to do something like:

const [contentEditable, setContentEditable] = useState();
useEffect(() => {
    if (!contentEditable) {
        return;
    }
    // If editor is mounted in a portal in a different document,
    // re-register it so listeners are attached correctly.
    editor.setRootElement(null);
    editor.setRootElement(contentEditable);
}, [editor, contentEditable]);
...

<ContentEditable ref={setContentEditable} ... />

For posterity, I spent a bit of time trying to get browser mode going but couldn't get it over the line. Here's what I had:

      {
        define: {
          IS_REACT_ACT_ENVIRONMENT: true,
          __DEV__: true,
          LEXICAL_VERSION: JSON.stringify(
            `${process.env.npm_package_version}+git`,
          ),
        },
        extends: true,
        plugins: [
          tsconfigPaths({projects: ['./tsconfig.test.json']}),
          // lexicalTestMocks(),
        ],
        test: {
          browser: {
            provider: 'playwright',
            enabled: true,
            headless: true,
            instances: [{browser: 'chromium'}],
          },
          include: ['packages/**/__tests__/unit/**/*.test{.ts,.tsx,.js,.jsx}'],
          name: 'browser-unit',
          setupFiles: ['./vitest.setup.mts'],
          typecheck: {
            tsconfig: './tsconfig.test.json',
          },
        },
      },

lexicalTestMocks is commented out because it was erroring with "Cannot define a nested project for a chromium browser. The project name "browser-unit (chromium)" was already defined." otherwise. The above was still failing with "Caused by: ReferenceError: process is not defined ❯ packages/lexical/src/LexicalEditor.ts:1424:24", which is where I stopped looking into it.

@james-atticus james-atticus changed the title [WIP] [lexical-react] Fix: ensure selection listeners are attached to correct owner document [lexical-react] Fix: ensure selection listeners cleaned up from original owner document Sep 29, 2025
@james-atticus james-atticus marked this pull request as ready for review September 29, 2025 06:35
@james-atticus
Copy link
Copy Markdown
Contributor Author

An alternative to the WeakMap would be to store the owner document as a property on the editor.

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.

This looks like a reasonable fix to me, I think we'll probably want to make this more robust later so the ownerDocument transition can just get fixed in place as soon as its detected. I think it's a high probability that some other event will also come along with the missed selectionchange (e.g. focus/blur) for any selectionchange that is relevant to the document

@etrepum etrepum added this pull request to the merge queue Sep 30, 2025
Merged via the queue into facebook:main with commit 843dbab Sep 30, 2025
39 checks passed
@etrepum etrepum mentioned this pull request Oct 1, 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.

2 participants