Skip to content

[lexical] Feature: Support DOM shadow roots via platform selection APIs#8694

Open
mayrang wants to merge 147 commits into
facebook:mainfrom
mayrang:feat/8660-shadow-dom
Open

[lexical] Feature: Support DOM shadow roots via platform selection APIs#8694
mayrang wants to merge 147 commits into
facebook:mainfrom
mayrang:feat/8660-shadow-dom

Conversation

@mayrang

@mayrang mayrang commented Jun 14, 2026

Copy link
Copy Markdown
Contributor

Description

Picks up #8660 (@etrepum's draft) and continues the work on this branch. The original platform-API approach is preserved as the squash on this branch; follow-up commits land helper additions, audit fixes, code-quality cleanups, the dev-example / docs work below, and the post-review cleanup that scoped the dev-example surface down to what this PR actually needs to demonstrate.

The work adds DOM shadow root support so an editor can mount inside an open shadow tree without losing selection, focus, drag-and-drop, IME composition, or floating UI behaviour. The reads go through platform APIs — Selection.getComposedRanges, Selection.direction, ShadowRoot.activeElement, Document.caretPositionFromPoint(x, y, {shadowRoots}) — rather than redefining Lexical's own DOM contracts. The light-DOM code paths are unchanged: every shadow helper falls through to the existing Selection.anchorNode / getRangeAt(0) / document.activeElement reads when there is no shadow root.

Helpers

Nine new helpers and one shared type ship from packages/lexical/src/LexicalUtils.ts, all marked @experimental while the shape stabilises:

Export Purpose
isDOMShadowRoot(node) Type guard for ShadowRoot.
getDOMShadowRoots(node) Walk up to every enclosing open shadow root, innermost first.
getComposedStaticRange(selection, rootElement) Selection.getComposedRanges with the legacy variadic-form fallback for Safari 17.
getDOMSelectionRange(selection, rootElement) Live Range built from the composed boundary points.
getDOMSelectionPoints(selection, rootElement) Anchor/focus/direction snapshot through Selection.direction. Returns DOMSelectionBoundaryPoints (or aliases the live Selection in the light DOM so the deferred-read semantics from $updateDOMSelection are preserved).
getDOMSelectionRangeAndPoints(selection, rootElement) Both shapes from a single getComposedRanges read.
getActiveElement(node) DocumentOrShadowRoot.activeElement resolved through Node.getRootNode().
getActiveElementDeep(root) Walks activeElement through every nested shadow root.
getComposedEventTarget(event) event.composedPath()[0] for listeners attached above the shadow boundary.

DOMSelectionBoundaryPoints exposes direction as a Selection.direction pass-through — 'forward' / 'backward' / 'none' when the engine implements it, undefined if a future engine ships getComposedRanges without direction. Callers needing strict backward fidelity inside a shadow root branch on direction !== undefined; the current shipping configurations (Chrome 137+, Firefox 142+, Safari 17.0+) ship both together, so the undefined branch is a forward-looking type safety net rather than a path any real environment exercises today.

Internal sites in lexical, lexical-clipboard, lexical-table, lexical-react, lexical-utils, lexical-devtools, and lexical-playground route through the helpers — $updateDOMSelection, RangeSelection.applyDOMRange, $internalCreateRangeSelection, LexicalEvents (selection-change / click / handleInput / composition-end / document-selection-change), LexicalMutations.$handleTextMutation, LexicalUpdates.iterContentEditables (the collectBuildInformation walk now descends into open shadow roots), caretFromPoint (gains a rootElement argument and prefers caretPositionFromPoint({shadowRoots})), LexicalAutoFocusPlugin, useYjsCollaboration, LexicalComposer, LexicalDraggableBlockPlugin, LexicalTypeaheadMenuPlugin, LexicalMenu (registers scroll listeners on every enclosing shadow root via getDOMShadowRoots), LexicalTableSelectionHelpers, selectionAlwaysOnDisplay, the playground's floating link editor / floating text-format toolbar / table action menu / autocomplete / comment plugin / equation + image components / DropDown / TestRecorder / getDOMRangeRect / setFloatingElemPosition.

Playground

A new Render in Shadow DOM setting toggles <Editor /> between a light-DOM mount and a mount inside ShadowDomWrapper (packages/lexical-playground/src/ui/ShadowDomWrapper.tsx). The wrapper attaches an open shadow root to a <div class="shadow-dom-host"> host, portals <Editor /> into it through createPortal, mirrors document.head's <style> / <link rel="stylesheet"> into the shadow root, and keeps the mirror in sync with Vite HMR:

  • A MutationObserver with childList + subtree + characterData covers Firefox, where HMR replaces a <style>'s text content in place rather than swapping in a new node.
  • Chrome / WebKit update CSS through the CSSOM (style.sheet.replaceSync / insertRule) that the observer never sees, so the wrapper also subscribes to Vite's vite:afterUpdate hook (import.meta.hot.on(...)) for an explicit resync of every clone after each HMR pass. import.meta.hot is undefined in production builds, so the hook is a no-op outside dev.
  • Cleanup removes every mirrored clone so React 18 StrictMode's double-mount doesn't leak duplicate stylesheets through the persistent shadow root.

App.tsx wraps the .editor-shell inside ShadowDomWrapper (not around it) so the .editor-shell .editor-container { ... } descendant rules in index.css keep matching once the editor moves into the shadow tree — CSS shadow boundaries don't carry an outer class across into the inner tree.

dev-examples

dev-examples/shadow-dom/ (React, portal pattern):

  • A ShadowRoot component attaches mode: 'open' and portals the editor into it through createPortal. The editor's CSS is imported as a raw string and injected as a <style> inside the shadow root.
  • A light-DOM Toolbar (Bold / Italic / Underline / Undo / Redo) dispatches FORMAT_TEXT_COMMAND / UNDO_COMMAND / REDO_COMMAND across the shadow boundary through useLexicalComposerContext.
  • The placeholder's line-height matches the input's so the baseline lines up with the first typed character.

dev-examples/shadow-dom-web-component/ (vanilla, form-associated custom element):

  • Defines <lexical-editor>. The element wears an open shadow root that holds its own toolbar, contentEditable, and stylesheet, and is form-associated via ElementInternalsform / validity / willValidate / checkValidity / reportValidity / setCustomValidity mirror the standard form-control surface, formAssociatedCallback / formResetCallback / formStateRestoreCallback round-trip the editor state across DOM moves and bfcache navigation, and formDisabledCallback picks up an ancestor <fieldset disabled>.
  • required / disabled / readonly / aria-label / spellcheck are observed attributes; required blocks empty submissions, disabled / readonly flip Lexical's editable state through a registerEditableListener that also toggles the contentEditable DOM attribute, aria-label and spellcheck mirror onto the contentEditable. inert / lang / dir are standard inherited HTML attributes that cross the shadow boundary on their own — no element-side glue is needed.
  • The page mounts three editors inside the form (name="notes" — required; name="summary" — themable through CSS custom properties; name="prerendered" — pre-renders the shadow tree through <template shadowrootmode="open">) plus a fourth editor inside #nested-host, a wrapper <div> that opens its own shadow root. The nested editor's contentEditable sits two shadow boundaries below the document and exercises getDOMShadowRoots' multi-level walk end-to-end.
  • A light-DOM floating popover anchors to the live selection inside any editor through a composed lexical-selection-rect CustomEvent that carries viewport coordinates from getDOMSelectionRangeAndPoints. Each editor element also registers a scroll listener on its window + every enclosing shadow root (the same per-shadow-root walk LexicalMenu does in lexical-react) so the popover follows the selection while the page scrolls; the rect is dropped when the selection scrolls out of the viewport. The page-side listener identifies the source editor through event.composedPath() so the composed event still resolves the right <lexical-editor> after retargeting through two shadow boundaries (the wrapper's host <div> is what event.target ends up pointing at), and a non-active editor's null rect (every editor emits one on every scroll while only one editor has an active selection) is ignored so the popover doesn't close on the active editor.

Fixes adjacent to the shadow DOM work

The shadow-DOM audits surfaced a handful of pre-existing light-DOM correctness issues. They ship together because the same code paths were already being touched:

  • lexical-clipboard/src/caretFromPoint.ts — the legacy fall-through tested document.caretPositionFromPoint !== 'undefined' against the function value itself rather than typeof === 'function'; it always evaluated truthy, so the second branch was unreachable.
  • lexical/src/LexicalUtils.ts:isSelectionCapturedInDecoratorInput — read document.activeElement even when the anchor DOM had no relationship to the host document, so a detached anchorDOM was being compared against an unrelated element. The new path narrows on anchorDOM.getRootNode(), which also covers the shadow case. Observable enough to call out as a [Breaking Change] candidate.
  • lexical/src/LexicalSelection.ts:$updateDOMSelection — the Firefox cursor-restore branch read document.activeElement before deciding whether to refocus, so a shadow-mounted editor always refocused on every selection update. Split the condition into the outer guard (browser / collapsed / SKIP_SELECTION_FOCUS_TAG) and a nested check that reads getActiveElement(rootElement). The scrollIntoView branch similarly switched from rootElement === document.activeElement to rootElement === getActiveElement(rootElement).
  • lexical-playground/.../FloatingTextFormatToolbarPluginpopupCharStylesEditorRef.current.getRootNode().elementFromPoint(...) would throw when the popup was detached (its getRootNode() returns itself). Narrow on isDOMDocumentNode || isDOMShadowRoot first.
  • lexical-playground/.../getDOMRangeRectnativeSelection.getRangeAt(0) threw when rangeCount was zero; the new path falls through to the editor root's bounding rect.

Backwards compatibility

No public-API removals. Nine new exports on packages/lexical/src/index.ts plus the DOMSelectionBoundaryPoints type, all @experimental. The light-DOM code paths are unchanged.

isSelectionCapturedInDecoratorInput's observable behaviour change above is the one to flag — it was already incorrect for detached anchor DOMs in the light DOM, but the new path is observably different (returns false where the old path could happen to return true against an unrelated host element).

Browser support

API Used for Chrome / Edge Firefox Safari
Selection.getComposedRanges Reading the un-retargeted boundary points 137+ 142+ 17.0+
Selection.direction Mapping the composed range back onto anchor/focus 137+ 126+ 17.0+
ShadowRoot.activeElement Resolving the focused element through the host All modern All modern All modern
Document.caretPositionFromPoint({shadowRoots}) Shadow-aware drop / drag hit-tests 128+ (not yet) 18.1+

Safari 17.0–17.3 ships only the legacy variadic form of getComposedRanges; the helpers try the dictionary form first and fall back to variadic at runtime. Lexical degrades to the light-DOM reads on engines that don't ship any form, so editors that don't live in a shadow tree keep working on every engine Lexical supports.

Closed shadow roots are documented as out of scope (the docs page explains why) — every external probe (Node.getRootNode({composed: false}), host.shadowRoot, getComposedRanges({shadowRoots})) refuses to descend, so a closed shadow root can't read its own selection.

Closes #2119, #8125 (and related shadow-DOM regressions).

Test plan

Automated

  • pnpm tsc clean (root)
  • pnpm tsc --noEmit clean inside each dev-example
  • pnpm lint-flow clean for the touched flow declarations
  • pnpm vitest --project browser packages/lexical/src/__tests__/browser/ShadowRootSelection.test.ts — 23/23 (helpers, IME composition, nested shadow walk, iframe parity, backward selection, Selection.direction undefined fallback)
  • pnpm test-e2e-chromium __tests__/e2e/ShadowDOM.spec.mjs — 23 passed + 1 skipped (collab spec, separate mode)
  • pnpm test-e2e-firefox __tests__/e2e/ShadowDOM.spec.mjs — 21 passed + 3 skipped (collab + the two synthetic ClipboardEvent paths — Firefox keeps the DataTransfer reference but its secure-event policy returns an empty getData() and an empty files list, so a script can't feed a payload into a synthetic paste; the real-ClipboardEvent paths are exercised on Chrome and WebKit)
  • pnpm test-e2e-webkit __tests__/e2e/ShadowDOM.spec.mjs — 22 passed + 2 skipped (collab + Firefox-only clipboard skip carries through)
  • pnpm -C dev-examples/shadow-dom-web-component exec playwright test — 33/33 (form / persistence / theming / popover / nested shadow / popover-on-nested-shadow / SSR / customElements.upgrade / inert / required validation)
  • pnpm -C dev-examples/shadow-dom exec playwright test — 4/4

Tested on Chrome 137+, Firefox 142+, Safari 17.4+ (macOS).

Playground Render in Shadow DOM toggle

Light-DOM baseline:

  • Page load shows placeholder; click editor → caret blinks
  • Hello world typing renders instantly
  • Option+Shift+Leftworld selected; Toolbar Bold → bold applied + aria-pressed=true
  • Cmd+Z undoes bold; Cmd+Shift+Z re-applies
  • Cmd+A + Delete clears the editor

Shadow DOM toggle ON:

  • Render in Shadow DOM checked
  • contentEditable re-binds inside the shadow root, text is preserved (useMemo deps exclude isShadowDOM by design — editor instance survives)
  • Editor body keeps its white background (.editor-shell .editor-container descendant rule keeps matching because the wrapper moves .editor-shell inside the shadow root)
  • ActionsPlugin's 8 bottom-right buttons (mic / Import / Export / Share / Clear / Lock / Markdown / HTML) remain rendered
  • DevTools Elements shows shadow-dom-host > #shadow-root (open) > .editor-shell > [data-lexical-editor]

Inside the shadow root:

  • Click editor → caret enters the shadow tree
  • Hello shadow world typing renders instantly
  • Option+Shift+Left selects the last word; Toolbar Bold → bold applied
  • Cmd+B toggles Bold
  • Word deletion (Option+Backspace) removes the last word
  • Markdown shortcut: # heading + space → H1
  • Markdown shortcut: - item one + space → bullet list; Enter for next item; Enter ×2 exits the list
  • CodeBlock + Tab adds indent; Shift+Tab removes it
  • Toolbar Insert → Image → Sample renders inside the shadow root; click selects it (.focused); Backspace removes it
  • Toolbar Insert → Table (2×2) → click cell → caret enters; type inside; Tab moves between cells
  • Select text → Cmd+K → floating link editor anchors at the selection rect; URL + Enter applies the link; clicking the link reopens the editor
  • Format dropdown opens; clicking inside the editor closes it (outside-click via getComposedEventTarget)

Toggle OFF:

  • Editor re-binds in the light DOM; light-DOM baseline still works
ShadowDomWrapper clone bookkeeping (dev mode)

Helper registered on the page reports the count of mirrored stylesheets inside the shadow root.

  • Toggle ON: baseline count N
  • Toggle OFF → ON × 5 cycles: count stays at N (no React 18 StrictMode leak)
  • Edit a colour in packages/lexical-playground/src/index.css and save → Vite HMR updates the editor's style without a manual page reload, count stays at N
  • Confirmed on Chrome / Firefox / Safari — Firefox catches the in-place <style> text replacement through characterData, Chrome / WebKit through the vite:afterUpdate resync
dev-examples/shadow-dom (React)
  • Single .shadow-host, DevTools confirms the contentEditable inside the shadow root
  • Focus → typing
  • Light-DOM toolbar Bold / Italic / Underline reach the editor across the shadow boundary via useLexicalComposerContext
  • Light-DOM toolbar Undo / Redo
  • Word selection (Option+Shift+Left) + Bold
  • Word deletion (Option+Backspace)
  • Undo / Redo (Cmd+Z / Cmd+Shift+Z)
  • Click outside the editor → blur → click back → caret restored
  • Placeholder baseline lines up with the first typed character (after the line-height: 1.5 fix)
dev-examples/shadow-dom-web-component (vanilla)

Three light-DOM <lexical-editor> instances (name="notes" — required, name="summary" — themable, name="prerendered" — declarative shadow DOM) + one inside the wrapper <div id="nested-host">.

Renders:

  • Three <lexical-editor> rendered in the light DOM, fourth inside #nested-host's shadow
  • DevTools: each <lexical-editor> has its own #shadow-root (open) carrying <style>, .toolbar, .content
  • document.querySelectorAll('lexical-editor').length === 3; Playwright's locator pierces and reports four

Notes editor — required validation:

  • Empty submit → platform validation tooltip ("Please fill in this field.") + #notes-error visible
  • DevTools confirms aria-invalid="true" on .content (no visible border — screen-reader only)
  • Typing clears both surfaces
  • Next submit succeeds; #form-output carries the serialised state

Summary editor — readonly / inert:

  • Type summary text
  • Lock the summary editorcontenteditable="false" (caret can't enter, typing blocked, form value preserved)
  • Unlock restores caret + typing
  • Make the summary editor inert blocks focus / pointer / selection through the shadow boundary

Pre-rendered editor (declarative shadow DOM):

  • Page load shows Hydrated. (<template shadowrootmode="open"> removed by the parser; connectedCallback reuses the pre-rendered .content element)
  • Click → caret enters → typing replaces the initial state

Floating popover:

  • Selecting text in any editor surfaces a light-DOM popover anchored to the selection rect
  • Clicking Bold formats the selection without dropping focus
  • Releasing the selection hides the popover
  • Scrolling the page repositions the popover with the selection; scrolling the selection out of the viewport hides it

Slotted toolbar button:

  • Notes editor's Clear button (<button slot="toolbar-extra">) clears the editor through the host's getEditor() API

DOM move (DevTools Console):

const notes = document.querySelector('lexical-editor[name="notes"]');
const before = notes.value;
document.body.appendChild(notes);
const after = notes.value;
console.log({equal: before === after});
  • equal: true (pendingState round-trip through disconnectedCallback + connectedCallback)
  • Moved editor remains interactive after the move

Form-associated lifecycle (DevTools Console):

(() => {
  const notes = document.querySelector('lexical-editor[name="notes"]');
  console.log('notes.form:', notes.form);
  console.log('lastFormAssociation:', document.querySelector('#last-edited').dataset.lastFormAssociation);
})();
  • notes.form returns <form id="demo-form">
  • lastFormAssociation reflects the initial-load association walk (e.g. prerendered → (none) — the pre-rendered editor sits outside any form)

setCustomValidity (DevTools Console):

(() => {
  const notes = document.querySelector('lexical-editor[name="notes"]');
  notes.setCustomValidity('Custom error message');
  console.log('checkValidity (after set):', notes.checkValidity());
  console.log('customError:', notes.validity.customError);
  notes.setCustomValidity('');
  console.log('checkValidity (after clear):', notes.checkValidity());
})();
  • Logs falsetruetrue
Korean IME inside the playground shadow editor

Tested with the macOS Korean input source:

  • Compose ㄱ → 가 → 강 — caret stays in place, no flicker
  • Compose 한글입력테스트 rapidly — no missed or duplicated syllables
  • Backspace mid-composition removes the in-progress jamo
  • After composition end, Cmd+B formats the composed text
Backward selection across the browser matrix
  • Chrome / Firefox / Safari: Hello world → caret at end → Shift+Left × 5 → Bold formats exactly world
Image drop into the shadow editor
  • OS-level drag of a PNG from Finder onto a specific paragraph position inside the shadow root lands on that caret position (not on the retargeted host)
  • Toggle off and repeat the same drop in the light DOM → lands at the equivalent position
Nested shadow (page → wrapper shadow → editor shadow)
  • DevTools confirms #nested-host > #shadow-root (open) > <lexical-editor name="nested"> > #shadow-root (open) > .content (two shadow boundaries deep)
  • Click → caret enters; typing renders
  • Word selection + in-shadow toolbar Bold work end-to-end
  • DevTools Console: document.activeElement reports #nested-host (retargeted), nestedHost.shadowRoot.querySelector('lexical-editor').shadowRoot.activeElement returns the nested .content
  • Selecting text inside the nested editor surfaces the page's light-DOM popover (composed lexical-selection-rect crosses two shadow boundaries; page-side listener recovers the source editor through event.composedPath())
  • Scrolling repositions the popover with the selection; out-of-viewport hides it
  • The new Playwright test (floating popover anchors to a selection inside the nested shadow root) covers the same path in CI

@vercel

vercel Bot commented Jun 14, 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 21, 2026 6:20am
lexical-playground Ready Ready Preview, Comment Jun 21, 2026 6:20am

Request Review

@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 14, 2026
@etrepum etrepum added the extended-tests Run extended e2e tests on a PR label Jun 14, 2026
@mayrang mayrang force-pushed the feat/8660-shadow-dom branch from 1ed3579 to 316d7a2 Compare June 14, 2026 01:44

@etrepum etrepum left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Here's another LLM review pass:

Claude

Code review — Shadow DOM support (c82b0d9)

Re-reviewed after the latest 19 commits. They resolve essentially all of the prior round's findings cleanly — getParentElement now crosses ShadowRoot→host, caretFromPoint walks the composed ancestor chain, getScrollParent is extracted + realm-aware, getActiveElementDeep is used at the $updateDOMSelection gate, getRangeAt is deferred, and getScrollParent/Flow parity check out. Nice work. The items below are issues in or exposed by those new commits.

I've pushed suggested patches for the three confirmed correctness bugs (#1#3) at etrepum:claude/lexical-pr-8694-review-xt661e (three commits on top of c82b0d9).

Confirmed correctness bugs (patched on the branch)

1. Scroll-into-view lost composed-range resolutionpackages/lexical/src/LexicalSelection.ts:3571
The "defer getRangeAt(0)" change made getCurrentRange() call only domSelection.getRangeAt(0), which is retargeted to the shadow host inside a shadow tree. For a collapsed text-anchor caret in a shadow-mounted editor, the scroll-into-view rect measures the host instead of the caret, so the caret isn't revealed. Fix: route getCurrentRange() through getDOMSelectionRange(domSelection, rootElement) (composed range, with getRangeAt fallback in light DOM).

2. Firefox focus restore steals focus from a coexisting shadow editorpackages/lexical/src/LexicalSelection.ts:3725
The Firefox block's cross-editor guard reads the shallow getActiveElement(rootElement) and feeds it to getNearestEditorFromDOMNode. When focus is in a coexisting shadow-mounted editor, the shallow read is that editor's light-DOM host, which getNearestEditorFromDOMNode can't map back to it → focusEditor === null → the guard passes and focus is stolen from the sibling mid-typing. The shallow read is correct for the rootElement.contains() check, but the editor attribution needs the deep element. Fix: resolve getActiveElementDeep(rootElement.ownerDocument) for the focusEditor lookup; keep shallow for contains().

3. calculateZoomLevel reads styles from the wrong realmpackages/lexical-utils/src/index.ts:1035
It walks the now-shadow-crossing getParentElement but uses the global window.getComputedStyle, unlike the sibling getScrollParent which was made realm-aware. The new draggable-block fix feeds it editor.getRootElement(), so for an iframe-mounted editor it reads zoom through the top-level window (cross-realm getComputedStyle can return ''), giving the wrong zoom and mispositioning the drag menu. Fix: use element.ownerDocument.defaultView.

Open (not patched — worth a look)

4. getComposedEventTarget allocates a composedPath() on every beforeinput/pointerdownLexicalEvents.ts:1109, :573
beforeinput fires per keystroke/IME step; the base read event.target directly. composedPath() materializes the full propagation path on every event just to index [0], only meaningful when a decorator shadow root is present. Consider gating on a cheap shadow-presence check.

5. onDocumentSelectionChange fast-path shifts cost rather than removing itLexicalEvents.ts:1521
The "no shadow editor" fast path runs an O(N-editors) getRootNode()+isDOMShadowRoot pre-scan on every selectionchange, and the shadow branch additionally Array.from(...).sort()s with a comparator re-running getRootNode() for both operands per comparison. A cached per-editor shadow flag + a single-pass partition (instead of a full sort) would avoid the added per-event work.

6. Fast-path shadow probe misses decorator-internal shadowsLexicalEvents.ts:1524
hasShadow checks isDOMShadowRoot(rootElement.getRootNode()), which detects editor-root shadows but not a light-DOM editor whose decorator opens its own shadow root. A selectionchange from inside such a decorator takes the un-shadow-aware fast path and reads the host-retargeted anchorNode — the exact shape the composed-target commit (7aa1ad4) handles elsewhere, so the two disagree.

7. getParentElement's global behavior change has an undocumented blast radiusLexicalUtils.ts:1532
It now crosses ShadowRoot→host for all ancestor-walk callers (getNearestEditorFromDOMNode, getNodeKeyFromDOMNode, isDOMCapturingSelection, …), not just the two named in the comment. Current callers stay correct only because an inner editor/key marker is hit before the boundary; any caller relying on the walk stopping at a shadow boundary now silently leaks into the enclosing tree. Worth a doc note + a test pinning the nearest-marker assumption.

8. caretFromPoint legacy fallthrough returns a wrong non-null caretcaretFromPoint.ts:41
When a browser ignores the {shadowRoots} option, isWithinComposedTree rejects the retargeted node, then caretRangeFromPoint also retargets and returns {node: host}. Callers get a caret on the shadow host (outside editor content) instead of null and bail — failing open rather than closed.

Cleanup

  • resolvedAnchorNode cache is wired into only one of the two attribution branches; the mousedown branch still recomputes getDOMSelectionPoints (LexicalEvents.ts:1565).
  • The deep-active-element resolution (getRootNode() + isDOMDocumentNode/isDOMShadowRoot ? getActiveElementDeep : null) is open-coded in both $updateDOMSelection and isSelectionCapturedInDecoratorInput; getRootOwnerDocument overlaps getDOMOwnerDocument; getCurrentRange is a memoizing closure for a single call site. A getActiveElementDeepFromNode(node) helper would de-dup the first.

Refuted during review: the getActiveElementDeep gate does not drop COLLABORATION_TAG selection updates — both the old shallow and new deep reads return ≠ rootElement when a nested decorator is focused, so the change only improves decorator-input detection.

etrepum and others added 23 commits June 20, 2026 16:32
…o findAllLexicalElementsDeep to specifically target lexical elements, fix lint warnings
getCurrentRange() in $updateDOMSelection called only domSelection.getRangeAt(0),
which is retargeted to the shadow host inside a shadow tree, so the collapsed
scroll-into-view rect measured the host instead of the caret. Route it through
getDOMSelectionRange (composed range, with getRangeAt fallback in light DOM).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012HzcxyuRrTKFDBRPz3S8Qx
… element

The Firefox focus-restore guard read the shallow active element and passed it to
getNearestEditorFromDOMNode. When focus is in a coexisting shadow-mounted editor,
the shallow read is that editor's light-DOM host, which can't be mapped back to
it, so focusEditor resolved to null and the guard stole focus from the sibling.
Resolve the deep focused element for the editor attribution; keep the shallow
read for the shadow-boundary-aware contains() check.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012HzcxyuRrTKFDBRPz3S8Qx
…nt's realm

calculateZoomLevel walked the now-shadow-crossing getParentElement but read
zoom via the global window.getComputedStyle, unlike the realm-aware
getScrollParent. For an iframe-mounted editor that reads from the wrong realm
(and cross-realm getComputedStyle can return an empty zoom). Use the element's
ownerDocument.defaultView.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012HzcxyuRrTKFDBRPz3S8Qx
…mousemove handlers

Defensive replacement of raw event.target with getComposedEventTarget in
CLICK_COMMAND handlers (HorizontalRuleNode, BlockWithAlignableContents)
and mousemove handler (DraggableBlockPlugin). Ensures correct target
resolution if a decorator ever opens its own shadow root.
$handleInput computed getDOMSelectionPoints twice with the same
arguments: once inside $shouldPreventDefaultAndInsertText and again
after the predicate returned true. Lift the computation before the
call, pass via parameter, and reuse the cached result.
…overActionsV2Plugin

The mousemove handler was registered on document, so event.target was
retargeted to the shadow host and closest('td...') never found a cell.
Use getComposedEventTarget to recover the actual target inside the
shadow tree.
…tionMenu and DraggableBlock

CodeActionMenuPlugin: document mousemove handler used event.target for
closest('code...') which fails when retargeted to the shadow host.

DraggableBlockPlugin: document mousedown handler used event.target for
contains() check which misidentifies clicks inside shadow tree as
outside the picker.
… onDocumentSelectionChange

Add getParentElement shadow-boundary text node traversal test
caretFromPoint now returns null for retargeted nodes outside the
editor's composed tree, so clientX/clientY must point inside the
editor for the caret to resolve correctly.
…ailable

Safari lacks caretPositionFromPoint, so its caretRangeFromPoint
fallback is the only path available. Rejecting its retargeted result
would break drag-drop entirely. Only filter fallback results when
caretPositionFromPoint with shadowRoots was tried and failed to
resolve inside the editor's composed tree.
…back for caretFromPoint

Browsers without caretPositionFromPoint({shadowRoots}) (Safari) retarget
shadow-internal nodes to the shadow host via caretRangeFromPoint. Use
shadowRoot.elementFromPoint to find the correct element, then walk its
text nodes measuring each caret position with collapsed Range rects to
find the closest offset. Linear scan — input is small (one Lexical span)
and runs once per drag-drop event.

Add browser tests verifying the fallback resolves the correct text node
and offset inside a shadow tree.
- Use getParentElement for anchorElem.parentElement in 4 floating UI files
- Use root's ownerDocument for createTreeWalker in findAllLexicalElementsDeep
- Add fast path to getComposedEventTarget to skip composedPath() allocation
- Avoid empty array allocation in getDOMShadowRoots for non-shadow editors
- Use getComposedEventTarget in table onTripleClick handler
- Use getParentElement in __DEV__ flex display warning
The shadow caret fallback returned null when elementFromPoint landed outside
rootElement (gutter/padding, slotted content, or a sibling), so a drag-drop near
the editor edge inside a shadow tree was silently discarded. Fall through to the
legacy caretRangeFromPoint path for a best-effort host-level caret instead.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012HzcxyuRrTKFDBRPz3S8Qx
onMouseMove resolved the composed target but onDragover/onDrop still used the
raw event.target, which is retargeted to the shadow host in a shadow tree, so
calculateZoomLevel read zoom from the host instead of the dragged-over content.
Use getComposedEventTarget in both handlers for consistency.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012HzcxyuRrTKFDBRPz3S8Qx
…ceof

findAllLexicalElementsDeep used `root instanceof Document`, which is false for
a Document from another realm (iframe) — sending it to `root.ownerDocument ??
document`, i.e. the global document, the cross-realm case it meant to fix. Use
the realm-safe isDOMDocumentNode (nodeType) check; a ShadowRoot's ownerDocument
is always its own realm's Document, so no global fallback is needed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012HzcxyuRrTKFDBRPz3S8Qx
…tDeep and standardize getRootOwnerDocument

Pass pre-resolved activeElement to isSelectionCapturedInDecoratorInput to
avoid a redundant getActiveElementDeep traversal in $updateDOMSelection.
Standardize playground plugins (CommentPlugin, TableActionMenuPlugin) on
getRootOwnerDocument instead of ad-hoc document resolution.
…anup convention

Convert 10 inline `if (!SUPPORTS_COMPOSED_RANGES) { return }` guards to
test.skipIf so unsupported browsers show "skipped" instead of false "passed"
and avoid unnecessary setUpShadowEditor setup/teardown.

Switch useDynamicPositioning regression test from manual removeChild cleanup
to onTestFinished, matching the pattern used in ShadowRootSelection tests
and LexicalExtensionComposer (PR facebook#8614).
@mayrang

mayrang commented Jun 21, 2026

Copy link
Copy Markdown
Contributor Author

Follow-up from the previous comment. Pushed additional commits covering three areas:

Additional leak fixes (audit follow-up)

  • Floating UI plugins (4 files): parentElementgetParentElement for shadow→host crossing
  • LexicalHorizontalRuleNode, BlockWithAlignableContents, TableHoverActionsV2Plugin, CodeActionMenuPlugin: event.targetgetComposedEventTarget
  • caretFromPoint: added elementFromPoint + text offset fallback — caretRangeFromPoint retargets to the shadow host, so a shadow-internal path is needed for accurate caret placement
  • onTripleClick table handler, __DEV__ flex warning: same composed target / parent element fixes

Performance

  • onDocumentSelectionChange: replaced Array.from + .sort() with a single-pass partition to avoid per-selectionchange array + sort cost; added a fast path that skips the shadow walk entirely when no editor is shadow-mounted
  • getComposedEventTarget / getDOMShadowRoots: fast paths to avoid array allocations in the non-shadow case
  • isSelectionCapturedInDecoratorInput: optional pre-resolved activeElement parameter so $updateDOMSelection skips a redundant getActiveElementDeep traversal

Test cleanup

  • ShadowRootSelection.test.ts: 10 inline if (!SUPPORTS_COMPOSED_RANGES) { return }test.skipIf — the inline returns showed "passed" on unsupported browsers and still ran setUpShadowEditor
  • LexicalMenu.test.tsx: manual removeChildonTestFinished

@mayrang

mayrang commented Jun 21, 2026

Copy link
Copy Markdown
Contributor Author

One note on findTextOffsetAtPoint in caretFromPoint.ts — Safari doesn't support the {shadowRoots} option on caretPositionFromPoint, so when the editor lives inside a shadow root, caretRangeFromPoint retargets shadow-internal nodes to the host. To recover the actual caret position, we use shadowRoot.elementFromPoint to locate the correct element, then walk its text nodes measuring each offset via a collapsed Range rect to find the closest match.

It's a linear scan over every character offset in the element, which is fine for drag-drop (runs once per event on a single Lexical span), but I'm not confident this is the best approach. If there's a more direct API or a smarter heuristic for this, open to changing it.

@etrepum

etrepum commented Jun 21, 2026

Copy link
Copy Markdown
Collaborator

Overall I think this is in a good state now! I should be able to finish looking it all over tomorrow

This is claude's answer to your question, I haven't reviewed it.


Short answer: there's no cleaner native API on Safari for this. caretPositionFromPoint/caretRangeFromPoint are Document-only and retarget across the shadow boundary; the only shadow-aware point primitive is DocumentOrShadowRoot.elementFromPoint (which you're already using). So elementFromPoint + manual measurement is the established technique — the structure is right. Two refinements:

The perf worry is smaller than it looks. The per-offset loop is all reads (getBoundingClientRect) with no interleaved DOM writes, so the browser computes layout once and every subsequent read is cheap — it does not trigger a reflow per character. For a single span run once per drop, the cost is basically call overhead. I wouldn't optimize for speed here.

What's actually worth fixing is correctness. Two issues in the current scan:

  1. Math.hypot(x - rect.left, y - midline) lets a nearer x on the wrong wrapped line win, mis-placing the caret on multi-line spans (and it's shaky for RTL/bidi).
  2. On no text match it returns {node: element, offset: 0}, silently snapping to the block start.

The fix is to compare vertical-first (lock to the line the point is on, then nearest x), and to first pick the nearest node via one getClientRects() call per node — which also bounds the per-offset scan to the single node under the cursor instead of the whole element:

function findTextOffsetAtPoint(
  x: number,
  y: number,
  container: Node,
  doc: Document,
): {node: Node; offset: number} | null {
  const range = doc.createRange();

  // Vertical distance to a line box (0 when y is inside it), then horizontal —
  // compared lexicographically so the caret on the line the point is actually
  // on always wins. Plain hypot lets a nearer x on the wrong wrapped line win.
  const vDist = (r: DOMRect) =>
    y < r.top ? r.top - y : y > r.bottom ? y - r.bottom : 0;
  const hDist = (r: DOMRect) =>
    x < r.left ? r.left - x : x > r.right ? x - r.right : 0;

  // Phase 1: pick the nearest text node via one getClientRects() per node
  // (each returns its per-line fragments) so phase 2 scans a single node.
  const walker = doc.createTreeWalker(container, NodeFilter.SHOW_TEXT);
  let bestNode: Text | null = null;
  let bestV = Infinity;
  let bestH = Infinity;
  for (let n = walker.nextNode(); n; n = walker.nextNode()) {
    range.selectNodeContents(n);
    for (const r of range.getClientRects()) {
      const v = vDist(r);
      const h = hDist(r);
      if (v < bestV || (v === bestV && h < bestH)) {
        bestV = v;
        bestH = h;
        bestNode = n as Text;
      }
    }
  }
  if (bestNode === null) {
    return null; // no measurable text — let the caller fall through
  }

  // Phase 2: closest caret offset within that node, vertical-first again
  // (so LTR and RTL both land on the right line).
  let bestOffset = 0;
  let offV = Infinity;
  let offH = Infinity;
  for (let i = 0; i <= bestNode.length; i++) {
    range.setStart(bestNode, i);
    range.collapse(true);
    const r = range.getBoundingClientRect();
    const v = vDist(r);
    const h = Math.abs(x - r.left); // caret rects are zero-width
    if (v < offV || (v === offV && h < offH)) {
      offV = v;
      offH = h;
      bestOffset = i;
    }
  }
  return {node: bestNode, offset: bestOffset};
}

What changes:

  • Correctness: vertical-first comparison stops cross-line/RTL mispicks; phase 1 picks the right node before scanning.
  • Bounded work: the per-offset loop runs on one node (the one under the cursor), not every text node in the element — and it's all reads, so still ~one layout pass.
  • No silent block-start snap: returns null on no text. The caller (result !== null ? result : {node: element, offset: 0}) should then fall through to the legacy caretRangeFromPoint path instead of defaulting to {element, 0} — a host-retargeted caret is a better worst case than "start of block."

If you'd rather it be O(log n) on pathologically long spans, you can binary-search within the matched line in phase 2, but it's not worth the RTL-handling complexity for single Lexical spans — the bounded linear scan is fine.

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.

Bug: Binding to webcomponent shadow root fails

4 participants