Skip to content

[WIP] [lexical][lexical-clipboard][lexical-playground] Feature: Support DOM shadow roots via platform selection APIs#8660

Draft
etrepum wants to merge 11 commits into
facebook:mainfrom
etrepum:claude/nifty-sagan-mgq2gw
Draft

[WIP] [lexical][lexical-clipboard][lexical-playground] Feature: Support DOM shadow roots via platform selection APIs#8660
etrepum wants to merge 11 commits into
facebook:mainfrom
etrepum:claude/nifty-sagan-mgq2gw

Conversation

@etrepum

@etrepum etrepum commented Jun 9, 2026

Copy link
Copy Markdown
Collaborator

Description

Make Lexical editors work when their contentEditable is inside a DOM ShadowRoot, using platform APIs only — no emulation of browser selection facilities.

When a selection is inside a shadow tree the browser retargets Selection.anchorNode/focusNode/getRangeAt to the shadow host, and document.activeElement to the host, which previously prevented Lexical from resolving the selection back to its nodes.

Core (lexical):

  • Add getDOMShadowRoots, getComposedStaticRange and getComposedSelectionPoints, which resolve selection boundary points through enclosing shadow roots with the standard Selection.getComposedRanges + Selection.direction; and getActiveElement / getActiveElementDeep, which resolve focus with (Document|ShadowRoot).activeElement.
  • Route the DOM->model reads (selection resolution, onSelectionChange, composition and mutation handlers) and the model->DOM write/diff in the reconciler through composed points, and resolve the active editor on selectionchange by descending shadow roots.
  • RangeSelection.modify keeps using native Selection.modify and reads the result via the composed StaticRange, so character/word/line navigation and deletion work inside a shadow root without re-implementing them.
  • The helpers degrade to the existing light-DOM reads when getComposedRanges is unavailable, so older browsers are unaffected.

@lexical/clipboard: resolve the copy guard's selection through shadow roots.

Playground: add a "Render in Shadow DOM" setting that portals the editor into an open shadow root, with an e2e spec covering typing, word selection, formatting and deletion.

Add a dev-examples/shadow-dom project and browser unit tests, since jsdom does not implement the shadow-DOM selection APIs.

Closes #7790
Closes #8125
RE #6709 #2119 #8659

Test plan

New browser unit and e2e tests

… shadow roots via platform selection APIs

Make Lexical editors work when their contentEditable is inside a DOM
ShadowRoot, using platform APIs only — no emulation of browser selection
facilities.

When a selection is inside a shadow tree the browser retargets
Selection.anchorNode/focusNode/getRangeAt to the shadow host, and
document.activeElement to the host, which previously prevented Lexical from
resolving the selection back to its nodes.

Core (lexical):
- Add getDOMShadowRoots, getComposedStaticRange and getComposedSelectionPoints,
  which resolve selection boundary points through enclosing shadow roots with
  the standard Selection.getComposedRanges + Selection.direction; and
  getActiveElement / getActiveElementDeep, which resolve focus with
  (Document|ShadowRoot).activeElement.
- Route the DOM->model reads (selection resolution, onSelectionChange,
  composition and mutation handlers) and the model->DOM write/diff in the
  reconciler through composed points, and resolve the active editor on
  selectionchange by descending shadow roots.
- RangeSelection.modify keeps using native Selection.modify and reads the
  result via the composed StaticRange, so character/word/line navigation and
  deletion work inside a shadow root without re-implementing them.
- The helpers degrade to the existing light-DOM reads when getComposedRanges
  is unavailable, so older browsers are unaffected.

@lexical/clipboard: resolve the copy guard's selection through shadow roots.

Playground: add a "Render in Shadow DOM" setting that portals the editor into
an open shadow root, with an e2e spec covering typing, word selection,
formatting and deletion.

Add a dev-examples/shadow-dom project and browser unit tests, since jsdom does
not implement the shadow-DOM selection APIs.

https://claude.ai/code/session_01X2Aa2rteqPsVhWs4W6DW6T
@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 Jun 9, 2026
@vercel

vercel Bot commented Jun 9, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
lexical Ready Ready Preview, Comment Jun 10, 2026 2:09pm
lexical-playground Ready Ready Preview, Comment Jun 10, 2026 2:09pm

Request Review

…M: review hardening, library coverage, and a web component example

Deep-review follow-up to the initial DOM shadow root support, also closing
the coverage gaps relative to the earlier attempts (facebook#7790, facebook#8659) and the
scenarios in facebook#2119, facebook#6709 and facebook#8125.

Core (lexical):
- getComposedStaticRange falls back to the legacy variadic
  getComposedRanges(...shadowRoots) call form used by Safari 17–18.1 before
  the spec switched to an options dictionary.
- New getDOMSelectionRange helper: a live Range resolved through shadow
  roots (getRangeAt(0) is retargeted to the host), for callers that need
  layout (getBoundingClientRect); falls back to getRangeAt(0) in the light
  DOM. Replaces the bespoke collapsed-range construction in
  $updateDOMSelection's scroll-into-view path.
- isSelectionCapturedInDecoratorInput resolves the focused element through
  shadow trees; previously decorator-input protection silently failed in a
  shadow root because document.activeElement reports the host.
- $updateDOMBlockCursorElement uses the tree-scoped activeElement so the
  block cursor renders inside a shadow root.
- $updateDOMSelection computes composed points after the early returns,
  preserving the documented deferred-selection-read optimization.
- onDocumentSelectionChange's active-editor fallback only engages when
  focus is actually inside a shadow tree, keeping light DOM behavior
  byte-identical.

@lexical/table: resolve composed points/ranges in
$fixTableSelectionForSelectedTable, the arrow-key table edge detection,
and $getTableEdgeCursorPosition.

@lexical/react: LexicalTypeaheadMenuPlugin positions the menu from
composed selection points so typeahead works in a shadow root.

Playground: getDOMRangeRect, FloatingTextFormatToolbarPlugin,
FloatingLinkEditorPlugin and TableActionMenuPlugin resolve composed
points/ranges so the floating UIs work with the Shadow DOM setting; the
dev error overlay no longer crashes on Error-less events (e.g. the benign
ResizeObserver loop warning).

Add dev-examples/shadow-dom-web-component: a framework-free
<lexical-editor> custom element with toolbar, styles and contentEditable
fully encapsulated in an open shadow root, form-associated via
ElementInternals, dispatching composed input events — the scenario from
the issues above, complementing the React example.

Add a browser regression test that drives real keyboard input (typing and
backspace) into an editor inside a web component's shadow root.

https://claude.ai/code/session_01X2Aa2rteqPsVhWs4W6DW6T
…mples

Both new dev examples now carry Playwright tests that start their Vite dev
server automatically (pinned ports, reuseExistingServer), matching the
convention of the existing examples.

dev-examples/shadow-dom (React): renders into the shadow root, types,
formats a shadow-DOM selection from the light-DOM toolbar, and word
deletion.

dev-examples/shadow-dom-web-component: two editors in independent shadow
roots, typing/formatting with in-shadow toolbar (aria-pressed reflects the
selection), editor independence, word deletion, ElementInternals form
association, and the composed input event crossing the shadow boundary.

Run with `pnpm -C dev-examples/<name> test`.

https://claude.ai/code/session_01X2Aa2rteqPsVhWs4W6DW6T
…getComposedRanges fallback reach empty results

The WebKit (Safari) browser-mode CI run failed two ShadowRootSelection
tests. Root cause: the test helper set the selection with
Selection.addRange, which WebKit does not reliably register for a Range
pointing inside a shadow tree (the sibling test using setBaseAndExtent
passed). Switch the helper to setBaseAndExtent.

Also harden getComposedStaticRange: a browser that does not understand the
dictionary form of getComposedRanges may return an empty array rather than
throwing, so fall through to the legacy variadic form (Safari 17–18.1) on
an empty result, not only on a thrown error — previously that fallback was
unreachable in the empty case.

Verified all three engines pass the browser project (Chromium, Firefox,
and WebKit).

https://claude.ai/code/session_01X2Aa2rteqPsVhWs4W6DW6T
…cal-clipboard][lexical-rich-text] Shadow DOM: resolve focus and point-to-node through shadow roots in library packages

Several shipped features gate behavior on document.activeElement or
document.caretRangeFromPoint, both of which retarget to the shadow host when
the editor is in a shadow root. getActiveElement/getActiveElementDeep are
identical to document.activeElement in the light DOM, so these are
zero-regression fixes.

- @lexical/dragon: voice-control commands were dropped entirely
  (document.activeElement !== rootElement always true in a shadow root).
- @lexical/list: checklist keyboard handling (space/enter toggle, arrow
  navigation, getActiveCheckListItem) resolved the focused <li> via
  document.activeElement and so never matched in a shadow root.
- @lexical/extension AutoFocusExtension and @lexical/react AutoFocusPlugin,
  LexicalComposer initial-selection, useYjsCollaboration focus state, and
  LexicalDraggableBlockPlugin (blur-on-menu via getActiveElementDeep, plus the
  Firefox drag re-focus) all read document.activeElement.
- @lexical/clipboard caretFromPoint + @lexical/rich-text drop handling: prefer
  caretPositionFromPoint with the shadowRoots option so dropped text/files
  resolve to the real node inside a shadow tree instead of the host.

Also revert the playground dev error-overlay tweak from the previous commit:
passing the real Error (instead of the ErrorEvent main passes, which made the
overlay constructor throw and swallow) caused the overlay to surface a
pre-existing dev-only update-recursion warning, intercepting pointer events and
breaking e2e tests that type long content. Restore main's behavior.

https://claude.ai/code/session_01X2Aa2rteqPsVhWs4W6DW6T
…oint-to-node reads through shadow roots

The playground is reference material for people and agents, so its DOM reads
should be correct under shadow DOM too. Each of these retargets to the shadow
host when the editor is in a shadow root; the lexical helpers are identical to
the raw reads in the light DOM, so these are zero-regression.

activeElement (-> getActiveElement / getActiveElementDeep):
- EquationComponent: keep the equation editor open while its input is focused.
- ImageComponent: focus the image button only when not already focused.
- TableActionMenuPlugin: detect whether anything is focused.
- AutocompleteExtension: detect whether the editor is focused.
- FloatingLinkEditorPlugin: keep the link editor open while its input is focused.
- CommentPlugin: capture/restore the focused element across a selection move.

getSelection (-> getDOMSelection + getDOMSelectionRange):
- setFloatingElemPosition: read the caret's text element for end-alignment.

point-to-node (same retargeting class):
- FloatingTextFormatToolbarPlugin: elementFromPoint via the popup's root node.
- ImagesExtension: caretRangeFromPoint via @lexical/clipboard's shadow-aware
  caretFromPoint for image-drop positioning.

https://claude.ai/code/session_01X2Aa2rteqPsVhWs4W6DW6T
…-null getDOMSelectionPoints

getComposedSelectionPoints returned null in the light DOM, so every one of its
~13 call sites repeated `getComposedSelectionPoints(sel, root) || sel`. A
Selection already satisfies DOMSelectionPoints, so fold the fallback into the
helper: getDOMSelectionPoints returns the composed points in a shadow tree and
the Selection itself otherwise, never null. This removes the repeated `|| sel`
at every call site and gives a name consistent with getDOMSelection /
getDOMSelectionRange.

No behavior change — the `|| sel` branch was exactly what the helper now returns
internally, and in the light DOM it still returns the Selection object without
eagerly reading anchorNode/focusNode (preserving the deferred-read optimization
in $updateDOMSelection).

https://claude.ai/code/session_01X2Aa2rteqPsVhWs4W6DW6T
…pport and add an iframe regression test

Add a "Shadow DOM and iframes" concepts page documenting how to embed an
editor in an open shadow root or an iframe, the platform APIs involved
(getComposedRanges / ShadowRoot.activeElement / getRootNode), the requirements
(open roots only; getComposedRanges-capable browsers, with graceful
degradation), styling, and the shadow/iframe-aware helpers (getDOMSelectionPoints,
getDOMSelectionRange, getActiveElement, getActiveElementDeep).

Add a browser test covering an editor whose root element lives in a same-origin
iframe: the top-level document only sees the <iframe> as focused, but
getActiveElement resolves through Node.getRootNode to the inner editor, and the
iframe's (non-retargeted) selection resolves into the model. This is the same
getRootNode-based resolution the activeElement fixes rely on, so iframe-embedded
editors get correct focus handling too.

https://claude.ai/code/session_01X2Aa2rteqPsVhWs4W6DW6T
… cross-frame focus

The iframe test asserted that focusing an element inside a same-origin iframe
makes the top-level document.activeElement the <iframe> element. That does not
hold in a headless browser (the top-level window never gains focus), so CI
failed on Chromium, Firefox and WebKit with document.activeElement === <body>.

Assert the actual contract instead: getActiveElement resolves through
Node.getRootNode to the iframe's own document (so it equals
iframeDoc.activeElement and is never the top-level document.activeElement),
which holds regardless of whether cross-frame focus propagates. The selection
round-trip already does not depend on focus.

https://claude.ai/code/session_01X2Aa2rteqPsVhWs4W6DW6T
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.

Lexical editor does not work inside Shadow DOM (web components)

2 participants