[lexical] Feature: Support DOM shadow roots via platform selection APIs#8694
[lexical] Feature: Support DOM shadow roots via platform selection APIs#8694mayrang wants to merge 147 commits into
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
1ed3579 to
316d7a2
Compare
…r is shadow-mounted
etrepum
left a comment
There was a problem hiding this comment.
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 resolution — packages/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 editor — packages/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 realm — packages/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/pointerdown — LexicalEvents.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 it — LexicalEvents.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 shadows — LexicalEvents.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 radius — LexicalUtils.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 caret — caretFromPoint.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
resolvedAnchorNodecache is wired into only one of the two attribution branches; the mousedown branch still recomputesgetDOMSelectionPoints(LexicalEvents.ts:1565).- The deep-active-element resolution (
getRootNode()+isDOMDocumentNode/isDOMShadowRoot ? getActiveElementDeep : null) is open-coded in both$updateDOMSelectionandisSelectionCapturedInDecoratorInput;getRootOwnerDocumentoverlapsgetDOMOwnerDocument;getCurrentRangeis a memoizing closure for a single call site. AgetActiveElementDeepFromNode(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.
…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.
…t shadow fallback
… 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).
|
Follow-up from the previous comment. Pushed additional commits covering three areas: Additional leak fixes (audit follow-up)
Performance
Test cleanup
|
|
One note on 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. |
|
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. The perf worry is smaller than it looks. The per-offset loop is all reads ( What's actually worth fixing is correctness. Two issues in the current scan:
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 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:
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. |
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 existingSelection.anchorNode/getRangeAt(0)/document.activeElementreads when there is no shadow root.Helpers
Nine new helpers and one shared type ship from
packages/lexical/src/LexicalUtils.ts, all marked@experimentalwhile the shape stabilises:isDOMShadowRoot(node)ShadowRoot.getDOMShadowRoots(node)getComposedStaticRange(selection, rootElement)Selection.getComposedRangeswith the legacy variadic-form fallback for Safari 17.getDOMSelectionRange(selection, rootElement)getDOMSelectionPoints(selection, rootElement)Selection.direction. ReturnsDOMSelectionBoundaryPoints(or aliases the liveSelectionin the light DOM so the deferred-read semantics from$updateDOMSelectionare preserved).getDOMSelectionRangeAndPoints(selection, rootElement)getComposedRangesread.getActiveElement(node)DocumentOrShadowRoot.activeElementresolved throughNode.getRootNode().getActiveElementDeep(root)activeElementthrough every nested shadow root.getComposedEventTarget(event)event.composedPath()[0]for listeners attached above the shadow boundary.DOMSelectionBoundaryPointsexposesdirectionas aSelection.directionpass-through —'forward'/'backward'/'none'when the engine implements it,undefinedif a future engine shipsgetComposedRangeswithoutdirection. Callers needing strict backward fidelity inside a shadow root branch ondirection !== 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, andlexical-playgroundroute through the helpers —$updateDOMSelection,RangeSelection.applyDOMRange,$internalCreateRangeSelection,LexicalEvents(selection-change / click / handleInput / composition-end / document-selection-change),LexicalMutations.$handleTextMutation,LexicalUpdates.iterContentEditables(thecollectBuildInformationwalk now descends into open shadow roots),caretFromPoint(gains arootElementargument and preferscaretPositionFromPoint({shadowRoots})),LexicalAutoFocusPlugin,useYjsCollaboration,LexicalComposer,LexicalDraggableBlockPlugin,LexicalTypeaheadMenuPlugin,LexicalMenu(registers scroll listeners on every enclosing shadow root viagetDOMShadowRoots),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 DOMsetting toggles<Editor />between a light-DOM mount and a mount insideShadowDomWrapper(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 throughcreatePortal, mirrorsdocument.head's<style>/<link rel="stylesheet">into the shadow root, and keeps the mirror in sync with Vite HMR:MutationObserverwithchildList + subtree + characterDatacovers Firefox, where HMR replaces a<style>'s text content in place rather than swapping in a new node.style.sheet.replaceSync/insertRule) that the observer never sees, so the wrapper also subscribes to Vite'svite:afterUpdatehook (import.meta.hot.on(...)) for an explicit resync of every clone after each HMR pass.import.meta.hotisundefinedin production builds, so the hook is a no-op outside dev.App.tsxwraps the.editor-shellinsideShadowDomWrapper(not around it) so the.editor-shell .editor-container { ... }descendant rules inindex.csskeep 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):ShadowRootcomponent attachesmode: 'open'and portals the editor into it throughcreatePortal. The editor's CSS is imported as a raw string and injected as a<style>inside the shadow root.Toolbar(Bold/Italic/Underline/Undo/Redo) dispatchesFORMAT_TEXT_COMMAND/UNDO_COMMAND/REDO_COMMANDacross the shadow boundary throughuseLexicalComposerContext.line-heightmatches the input's so the baseline lines up with the first typed character.dev-examples/shadow-dom-web-component/(vanilla, form-associated custom element):<lexical-editor>. The element wears an open shadow root that holds its own toolbar, contentEditable, and stylesheet, and is form-associated viaElementInternals—form/validity/willValidate/checkValidity/reportValidity/setCustomValiditymirror the standard form-control surface,formAssociatedCallback/formResetCallback/formStateRestoreCallbackround-trip the editor state across DOM moves and bfcache navigation, andformDisabledCallbackpicks up an ancestor<fieldset disabled>.required/disabled/readonly/aria-label/spellcheckare observed attributes;requiredblocks empty submissions,disabled/readonlyflip Lexical's editable state through aregisterEditableListenerthat also toggles thecontentEditableDOM attribute,aria-labelandspellcheckmirror onto the contentEditable.inert/lang/dirare standard inherited HTML attributes that cross the shadow boundary on their own — no element-side glue is needed.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 exercisesgetDOMShadowRoots' multi-level walk end-to-end.lexical-selection-rectCustomEvent that carries viewport coordinates fromgetDOMSelectionRangeAndPoints. Each editor element also registers a scroll listener on its window + every enclosing shadow root (the same per-shadow-root walkLexicalMenudoes inlexical-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 throughevent.composedPath()so the composed event still resolves the right<lexical-editor>after retargeting through two shadow boundaries (the wrapper's host<div>is whatevent.targetends 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 testeddocument.caretPositionFromPoint !== 'undefined'against the function value itself rather thantypeof === 'function'; it always evaluated truthy, so the second branch was unreachable.lexical/src/LexicalUtils.ts:isSelectionCapturedInDecoratorInput— readdocument.activeElementeven when the anchor DOM had no relationship to the host document, so a detachedanchorDOMwas being compared against an unrelated element. The new path narrows onanchorDOM.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 readdocument.activeElementbefore 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 readsgetActiveElement(rootElement). ThescrollIntoViewbranch similarly switched fromrootElement === document.activeElementtorootElement === getActiveElement(rootElement).lexical-playground/.../FloatingTextFormatToolbarPlugin—popupCharStylesEditorRef.current.getRootNode().elementFromPoint(...)would throw when the popup was detached (itsgetRootNode()returns itself). Narrow onisDOMDocumentNode || isDOMShadowRootfirst.lexical-playground/.../getDOMRangeRect—nativeSelection.getRangeAt(0)threw whenrangeCountwas 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.tsplus theDOMSelectionBoundaryPointstype, 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 (returnsfalsewhere the old path could happen to returntrueagainst an unrelated host element).Browser support
Selection.getComposedRangesSelection.directionShadowRoot.activeElementDocument.caretPositionFromPoint({shadowRoots})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 tscclean (root)pnpm tsc --noEmitclean inside each dev-examplepnpm lint-flowclean for the touched flow declarationspnpm vitest --project browser packages/lexical/src/__tests__/browser/ShadowRootSelection.test.ts— 23/23 (helpers, IME composition, nested shadow walk, iframe parity, backward selection,Selection.directionundefined 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 syntheticClipboardEventpaths — Firefox keeps theDataTransferreference but its secure-event policy returns an emptygetData()and an emptyfileslist, 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/4Tested on Chrome 137+, Firefox 142+, Safari 17.4+ (macOS).
Playground
Render in Shadow DOMtoggleLight-DOM baseline:
Hello worldtyping renders instantlyOption+Shift+Left→worldselected; Toolbar Bold → bold applied +aria-pressed=trueCmd+Zundoes bold;Cmd+Shift+Zre-appliesCmd+A+ Delete clears the editorShadow DOM toggle ON:
Render in Shadow DOMcheckeduseMemodeps excludeisShadowDOMby design — editor instance survives).editor-shell .editor-containerdescendant rule keeps matching because the wrapper moves.editor-shellinside the shadow root)shadow-dom-host > #shadow-root (open) > .editor-shell > [data-lexical-editor]Inside the shadow root:
Hello shadow worldtyping renders instantlyOption+Shift+Leftselects the last word; Toolbar Bold → bold appliedCmd+Btoggles BoldOption+Backspace) removes the last word# heading+ space → H1- item one+ space → bullet list; Enter for next item; Enter ×2 exits the listTabadds indent;Shift+Tabremoves it.focused); Backspace removes itTabmoves between cellsCmd+K→ floating link editor anchors at the selection rect; URL + Enter applies the link; clicking the link reopens the editorgetComposedEventTarget)Toggle OFF:
ShadowDomWrapperclone bookkeeping (dev mode)Helper registered on the page reports the count of mirrored stylesheets inside the shadow root.
NN(no React 18 StrictMode leak)packages/lexical-playground/src/index.cssand save → Vite HMR updates the editor's style without a manual page reload, count stays atN<style>text replacement throughcharacterData, Chrome / WebKit through thevite:afterUpdateresyncdev-examples/shadow-dom(React).shadow-host, DevTools confirms the contentEditable inside the shadow rootBold/Italic/Underlinereach the editor across the shadow boundary viauseLexicalComposerContextUndo/RedoOption+Shift+Left) + BoldOption+Backspace)Cmd+Z/Cmd+Shift+Z)line-height: 1.5fix)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:
<lexical-editor>rendered in the light DOM, fourth inside#nested-host's shadow<lexical-editor>has its own#shadow-root (open)carrying<style>,.toolbar,.contentdocument.querySelectorAll('lexical-editor').length === 3; Playwright's locator pierces and reports fourNotes editor — required validation:
#notes-errorvisiblearia-invalid="true"on.content(no visible border — screen-reader only)#form-outputcarries the serialised stateSummary editor — readonly / inert:
summary textLock the summary editor→contenteditable="false"(caret can't enter, typing blocked, form value preserved)Make the summary editor inertblocks focus / pointer / selection through the shadow boundaryPre-rendered editor (declarative shadow DOM):
Hydrated.(<template shadowrootmode="open">removed by the parser;connectedCallbackreuses the pre-rendered.contentelement)Floating popover:
Slotted toolbar button:
Clearbutton (<button slot="toolbar-extra">) clears the editor through the host'sgetEditor()APIDOM move (DevTools Console):
equal: true(pendingState round-trip throughdisconnectedCallback+connectedCallback)Form-associated lifecycle (DevTools Console):
notes.formreturns<form id="demo-form">lastFormAssociationreflects the initial-load association walk (e.g.prerendered → (none)— the pre-rendered editor sits outside any form)setCustomValidity (DevTools Console):
false→true→trueKorean IME inside the playground shadow editor
Tested with the macOS Korean input source:
ㄱ → 가 → 강— caret stays in place, no flicker한글입력테스트rapidly — no missed or duplicated syllablesCmd+Bformats the composed textBackward selection across the browser matrix
Hello world→ caret at end →Shift+Left× 5 → Bold formats exactlyworldImage drop into the shadow editor
Nested shadow (page → wrapper shadow → editor shadow)
#nested-host > #shadow-root (open) > <lexical-editor name="nested"> > #shadow-root (open) > .content(two shadow boundaries deep)document.activeElementreports#nested-host(retargeted),nestedHost.shadowRoot.querySelector('lexical-editor').shadowRoot.activeElementreturns the nested.contentlexical-selection-rectcrosses two shadow boundaries; page-side listener recovers the source editor throughevent.composedPath())floating popover anchors to a selection inside the nested shadow root) covers the same path in CI