Skip to content

[lexical][lexical-playground] Bug Fix: Allow mouse drag selection across inline decorators with no text around them - #9329

Open
vijayojha89 wants to merge 12 commits into
mainfrom
fix/decorator-nodes-are-not-selectable-with-mouse
Open

vijayojha89 wants to merge 12 commits into
mainfrom
fix/decorator-nodes-are-not-selectable-with-mouse

Conversation

@vijayojha89

Copy link
Copy Markdown
Contributor

Description

In a line made only of inline DecoratorNodes (no text before or after them), dragging the mouse across them did not select them: the highlight blinked and snapped back.

Three causes, each fixed separately:

  1. $internalResolveSelectionPoints resolved any selection with both endpoints inside decorators to null. That was meant for a selection within one decorator's own content, but it also caught a drag across two different decorators. The commit then removed the DOM ranges. Now only a selection inside one decorator's content, or involving a block decorator, resolves to null, as before.

  2. A click beside such a decorator at the start or end of the line puts the browser caret on the decorator's own contenteditable=false element, and the browser will not extend a drag out of it. That caret now resolves next to the decorator and is marked dirty, so it is written back to the editable parent.

  3. The browser needs an editable position at the edges of the line to start a drag from:

    • start of line: the Bug: DecoratorNode resets the selection of all content #8922 leading boundary anchor (a zero-size, out-of-flow <img>) is now also parked before an inline first-child decorator, not only a block one;
    • end of line: the existing WebKit <img> + <br> managed line break after a trailing inline decorator is now also used on desktop Chromium.

Closes #7158

Test plan

Before

Before.mov

After

After.mov
 ✓ |unit| packages/lexical/src/__tests__/unit/Issue7158Repro.test.ts (11 tests)
      Tests  11 passed (11)
Suite Result
vitest --project unit 358 files: 5828 passed, 1 skipped
pnpm run test-browser (Chromium) 39 files: 436 passed, 1 skipped
pnpm run test-e2e-chromium 862 passed, 22 skipped
pnpm run test-e2e-webkit 844 passed, 40 skipped
tsc (main, test and scripts configs) clean
ESLint, Prettier on changed files clean

@vercel

vercel Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

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

Project Deployment Actions Updated
lexical Ready Ready Preview Oct 8, 2026 5:53am UTC
lexical-playground Ready Ready Preview Oct 8, 2026 5:53am UTC

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 Oct 6, 2026

@potatowagon potatowagon left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Real bug, and the diagnosis is coherent — splitting #7158 into three distinct causes and fixing each separately is the right shape. I checked the null-resolution rewrite case by case and it is careful: same decorator with both endpoints in its content still resolves to null; any block decorator still resolves to null; one endpoint on the element and the other inside its content still resolves to null. Only the two intended shapes escape.

My hesitation is about verification and blast radius rather than the diagnosis.

On CI: the green checks here are thinner than they look. browser-tests, e2e-tests and integration-tests are all skipped on ff3d3f2, and core-tests / browser / browser-test is ubuntu + chromium only (call-core-tests.yml says so explicitly; WebKit browser-mode lives only in the extended matrix). So nothing in CI has exercised WebKit or Firefox on a PR whose entire mechanism is per-engine caret behaviour. The body reports local chromium and webkit e2e runs, which is the right instinct — tests-extended.yml gates on an extended-tests label or an approving review, so adding that label would get the real matrix on the record.

Major

1. The repro test runs in jsdom, and jsdom cannot exhibit the bug.

Issue7158Repro.test.ts drives setBaseAndExtent plus a synthetic selectionchange and asserts what $internalResolveSelectionPoints returns. But the bug is browser caret canonicalization into contenteditable=false DOM — jsdom never canonicalizes, so the test asserts the fix's internal logic against a selection placed by hand that a real engine may never produce. The file is honest about this ("jsdom has no hit testing") and defers the DOM-anchor half to Issue8922Repro.test.ts, which is also jsdom.

AGENTS.md is explicit that behaviour depending on a real layout/selection engine belongs in packages/**/__tests__/browser/, and the harness already exists — WebkitLinebreakImg.test.ts asserts a different DOM contract per engine from one file. Causes 2 and 3 need at least one browser test on chromium and webkit; the unit tests are a good complement to that, not a substitute.

2. The leading anchor is injected far more broadly than the issue requires, on every engine and platform.

$isLeadingDecoratorChild returns true for any decorator, so every paragraph whose first child is an inline decorator now gains an <img> as its first DOM child — in Chrome, Firefox, Safari, Android, jsdom, and SSR hydration. #8922 did this only for block decorators at a block boundary, which is rare. "Paragraph starts with an inline decorator" is one of the most common shapes in a real editor: mentions, chips, hashtags, emoji nodes, inline images. Downstream p > span:first-child or .chip:first-child CSS silently stops matching, and AGENTS.md's backwards-compatibility section is strict about exactly this kind of ripple.

It also sits oddly beside the care taken on the other half. The trailing hack is gated to IS_CHROME && !IS_ANDROID because

Android is left alone: it has no mouse drag, and its IME is sensitive to the DOM around the caret.

The leading anchor is injected on Android too, directly next to the caret, ungated. Either the IME argument holds and the leading anchor needs the same gate, or it does not and the trailing gate is over-cautious — but the two should agree. Relatedly, Firefox gets the leading anchor and no trailing fix, so by this PR's own reasoning Firefox still cannot start a drag at the end of such a line; that case is not discussed either way.

A narrower gate — the engines that actually need it, as the trailing fix does — would keep the DOM unchanged for most users.

3. decoratorDirty forces a DOM-selection rewrite with nothing bounding it.

Every resolve where an endpoint lands on an inline decorator's element now marks the selection dirty so the reconciler writes the caret back into the editable parent. At the two edges of the line that is fine, because this PR gives both an editable inline box. An interior inline decorator has neither: the leading anchor is only before the first child, and the linebreak img only after the last. So an element point between decorators 3 and 4 has no editable box to canonicalize against, and if the engine re-canonicalizes the written-back point into the decorator you get rewrite → selectionchange → resolve → dirty → rewrite.

I cannot rule that out from the code, and the tests cannot either: jsdom never canonicalizes, so such a loop is invisible there by construction. A browser test that drags across a six-decorator line and counts commits would settle it; alternatively, bound the correction to the cases where an editable box is known to exist.

Minor

4. The trailing linebreak <img> is in-flow and less defended than the boundary anchor.

$createDecoratorBoundaryAnchor pins position, width, height, border, margin and padding with !important. insertManagedLineBreak pins only display, border and margin — no width or height. A host app with img { width: 1em } or a min-height in its content reset now gets a visible artifact in Chrome where previously only Safari users could hit it. The file's own comment warns that an in-flow img "would add a stray blank line"; extending that to desktop Chromium covers most of the user base, so it is worth hardening the style pinning in the same PR.

5. $isOnInlineDecoratorElement requires exact element identity.

editor.getElementByKey(node.getKey()) === dom means a caret on an inner wrapper — a <span> inside the decorator, a React-portalled child, a <picture> — resolves fine but is never corrected, leaving the DOM caret in non-editable DOM: the same symptom one level deeper. The node in the issue is exactly that shape (an <img> inside the decorator's span).

The test 'endpoints on the images inside two decorators also resolve' documents the gap rather than closing it: unlike its sibling tests it asserts the Lexical selection but not that the DOM caret moved. I appreciate the tension — you cannot correct inner positions without breaking selection within a decorator's content, which is the behaviour the null resolution exists to protect — but it is worth saying explicitly how the two are meant to be told apart, since "one level in" is the common case.

6. The correction only runs when both resolved points are element-type.

The whole decoratorDirty block sits inside resolvedAnchorPoint.type === 'element' && resolvedFocusPoint.type === 'element'. A drag from text into a trailing decorator leaves one point text-type, so the block is skipped entirely and the stale DOM caret stands. That may be fine (there is text to drag from, so the browser has an editable box) but it is an untested asymmetry.

7. The JSDoc justifying the leading/trailing asymmetry is contradicted by this same PR.

$isLeadingDecoratorChild says the trailing edge needs no anchor because "the managed line break that $reconcileElementTerminatingLineBreak appends after it" already provides an editable position. The LexicalDOMSlot change two files over exists precisely because a bare <br> is not an editable inline box for this purpose. On Firefox the trailing edge still has only the <br>.

8. webkitHack is now half-misnamed, and IS_WEBKIT_BROWSER || (IS_CHROME && !IS_ANDROID) reads better as a named constant beside IS_WEBKIT_BROWSER. Worth noting IS_CHROME is /^(?=.*Chrome).*/i, so Edge, Opera and Brave are included — correct, same engine — while Android desktop mode or DeX with a mouse is excluded.

9. Test plan and subject. ### Before is a video rather than failing command output; AGENTS.md asks for the command plus real output in both subsections (the videos are genuinely useful — as well as, not instead of). The subject line also omits lexical-extension, which the diff touches.

Nits

10. $isLeadingDecoratorChild has nothing to do with "leading" — it is $isDecoratorChild, and the leading-ness is at the call site. The name will mislead the next person looking for an asymmetry inside it.

11. removeSafariLinebreakImgHack now runs for chromium too; the name is stale.

12. Array.from(paragraph.childNodes).findIndex(child => !isBoundaryAnchor(child)) yields -1 for an all-anchor parent, and leadingAnchors is a count derived from an index. filter(isBoundaryAnchor).length says what is meant directly.

13. Two double spaces in the PR body: "only a selection" and "resolves next to".

14. The reflowed header comment in WebkitLinebreakImg.test.ts leaves one over-long line ending "...(#7158). The user-visible symptom it fixes involves native Safari".

Things I checked and found fine

  • No public API change: IS_CHROME and IS_ANDROID are already exported from lexical and already declared in Lexical.js.flow, so no Flow work is owed.
  • E2E HTML assertions: the playground utils already strip data-lexical-decorator-boundary in both places that matter, so the new leading anchors do not break the specs. LexicalEditor.test.tsx was the one raw-innerHTML assertion, and it is updated correctly.
  • Layout: the boundary anchor is position: absolute with zero size, so it stays out of flow and out of flex/grid layout.
  • Reconciler contract: setDecoratorBoundaryAnchor is a no-op when the edge is already in the requested state, so calling it unconditionally per dirty element stays cheap, and $isLeadingDecoratorChild mirrors $isBlockDecoratorChild's signature exactly.
  • Browser-test conventions: the new and modified browser-facing tests use onTestFinished(() => editor.dispose()) rather than using, per the WebKit rule in AGENTS.md.
  • Tree-shaking: no hand-written @__PURE__ annotations and no new module-scope factory calls, so nothing for treeShakingSource.test.ts to catch.

@etrepum etrepum added the extended-tests Run extended e2e tests on a PR label Oct 6, 2026
## Description

The decorator boundary anchor `<img>` now gets the shared zero-size style (border, display, height, margin, min-height, min-width, padding, width) followed by `position: absolute`, but `DECORATOR_BOUNDARY_ANCHOR_HTML` in the lexical test utils still described the old style list, so every unit test that interpolates it into expected HTML failed. This updates the constant to the style the reconciler writes now.

## Test plan

### Before

```
$ ./node_modules/.bin/vitest run --project unit
 FAIL  |unit| packages/lexical-history/src/__tests__/unit/LexicalHistory.test.tsx > SharedHistoryExtension > can create a parent editor
 FAIL  |unit| packages/lexical-react/src/__tests__/unit/LexicalExtensionEditorComposer.test.tsx > LexicalExtensionEditorComposer > can render
 FAIL  |unit| packages/lexical-react/src/__tests__/unit/LexicalNestedComposer.test.tsx > LexicalNestedComposer > (6 tests)
 Test Files  3 failed | 358 passed (361)
      Tests  8 failed | 5978 passed | 1 skipped (5987)
```

### After

```
$ ./node_modules/.bin/vitest run --project unit
 Test Files  361 passed (361)
      Tests  5986 passed | 1 skipped (5987)
```

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@etrepum

etrepum commented Oct 8, 2026

Copy link
Copy Markdown
Collaborator

Will merge the fix for those failing tests shortly, just cleaning up the representation of those img nodes and hardening them against css

claude added 3 commits October 8, 2026 05:19
… removed scaffolding

## Description

Addresses /code-review findings on #9329.

- The leading boundary anchor before an inline first-child decorator was added on every engine, while the trailing img before the managed line break was limited to WebKit and desktop Chromium. Both now use one predicate, `NEEDS_INLINE_DECORATOR_EDGE_BOX`, so Android (IME), Firefox and jsdom keep their previous DOM. Block decorators keep their ungated anchors from #8922.
- The `$internalResolveSelectionPoints` change is dropped. Driven with a real mouse in Chromium, the DOM anchors alone fix the drags in #7158. The selection change alone does not, because Chromium keeps the anchor it took at mousedown even after Lexical rewrites the DOM selection. It also took over selections inside two different inline decorators' content (and inside one decorator's own element), could rewrite the DOM selection on every selectionchange mid-drag, and skipped mixed text/element endpoints. Its jsdom tests, which modeled drags with `setBaseAndExtent`/`extend` and passed without the working fix, are replaced by `Issue7158MouseDrag.test.ts`, which drives Playwright's mouse through a new `mouseDrag` browser command.
- When something outside the reconciler removes scaffolding, the mutation observer now rebuilds it in place. A removed boundary anchor used to stay missing until the element was next reconciled. A removed managed `<img>` was re-appended after the `<br>`, and a removed managed `<br>` that followed the img was not recognized at all. `ElementDOMSlot.restoreManagedLineBreak()` rebuilds the img+br pair inside the trailing boundary, and `$reconcileDecoratorBoundaryAnchors` is reused for the anchors.
- `$createZeroImg` takes the attribute name from the `DATA_LEXICAL_*` constants, and `insertManagedLineBreak`'s parameter is renamed from `webkitHack` to `withEdgeImg` now that it isn't WebKit-only.

## Test plan

### Before

```
$ ./node_modules/.bin/vitest run --project browser packages/lexical/src/__tests__/browser/WebkitLinebreakImg.test.ts packages/lexical/src/__tests__/browser/Issue7158MouseDrag.test.ts
     × the leading boundary anchor 184ms
     × the managed line break img 104ms
     × the managed line break br 107ms
AssertionError: expected [ 'decorator', 'text', …(3) ] to deeply equal [ 'anchor', 'decorator', 'text', …(3) ]
AssertionError: expected [ 'anchor', 'decorator', 'text', …(3) ] to deeply equal [ 'anchor', 'decorator', 'text', …(3) ]
AssertionError: expected [ 'anchor', 'decorator', 'text', …(2) ] to deeply equal [ 'anchor', 'decorator', 'text', …(3) ]
      Tests  3 failed | 9 passed (12)
```

On upstream main, `Issue7158MouseDrag.test.ts` fails 3 of its 4 drags (`expected 1 to be 4`, `expected 'null' to be 6`, `expected 'null' to be 4`).

### After

```
$ ./node_modules/.bin/vitest run --project browser packages/lexical/src/__tests__/browser/WebkitLinebreakImg.test.ts packages/lexical/src/__tests__/browser/Issue7158MouseDrag.test.ts
      Tests  12 passed (12)
$ ./node_modules/.bin/vitest run --project unit
 Test Files  360 passed (360)
      Tests  5975 passed | 1 skipped (5976)
$ ./node_modules/.bin/vitest run --project browser
 Test Files  61 passed (61)
      Tests  668 passed | 2 skipped (670)
```

`tsc` and `pnpm run flow` are clean. Browser tests ran in Chromium only; Firefox and WebKit were not run here.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
# Conflicts:
#	packages/lexical/src/__tests__/unit/LexicalEditor.test.tsx
…pectHtmlToBeEqual

## Description

`LexicalEditor.test.tsx` compared `innerHTML` with a raw `toBe` in 20 places. That makes them depend on attribute insertion order and on void-element serialization, which tests don't care about: reordering the attributes on the decorator boundary anchor broke one of them. 17 of them now use `expectHtmlToBeEqual`, which formats both sides with Prettier and its organize-attributes plugin. The 3 "moves node to different tree branches" tests stay raw, because they render a `<div>` inside a `<p>`, which Prettier's HTML parser rejects ("Unexpected closing tag "p""). Each now has a comment saying so.

## Test plan

### Before

Converting all 20 sites failed the three tests that render a div inside a p:

```
$ ./node_modules/.bin/vitest run --project unit packages/lexical/src/__tests__/unit/LexicalEditor.test.tsx
       × moves node to different tree branches 25ms
       × moves node to different tree branches (inverse) 18ms
       × moves node to different tree branches (node appended twice in two different branches) 15ms
      Tests  3 failed | 91 passed (94)
```

### After

```
$ ./node_modules/.bin/vitest run --project unit packages/lexical/src/__tests__/unit/LexicalEditor.test.tsx
      Tests  94 passed (94)
```

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

This branch was successfully deployed

2 active deployments
Preview – lexical — c95793d6 Deployed Oct 8, 2026 by vercel[bot]
Preview – lexical-playground — c95793d6 Deployed Oct 8, 2026 by vercel[bot]
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: Decorator nodes are not selectable with mouse

4 participants