Skip to content

[lexical][lexical-html][lexical-clipboard] Bug Fix: HTML import/export and slot frames carry the whole state - #9052

Merged
etrepum merged 5 commits into
facebook:mainfrom
LeSingh1:consol/html-import-export-slots
Aug 29, 2026
Merged

[lexical][lexical-html][lexical-clipboard] Bug Fix: HTML import/export and slot frames carry the whole state#9052
etrepum merged 5 commits into
facebook:mainfrom
LeSingh1:consol/html-import-export-slots

Conversation

@LeSingh1

Copy link
Copy Markdown
Contributor

Description

Four independent leaks on the same boundary: the HTML/clipboard import-export
round trip and the slot machinery it walks. In each case one side of the
boundary knows something the other side never receives — a format that is
written but never read back, a redirect applied to one selection type but not
the other, a flag set on the wrong EditorState, and a selection argument that
is documented but ignored. Each fix is self-contained and independently
reviewable:

  • $insertDataTransferForRichText ignores its selection argument for
    text/plain
    ([lexical-clipboard] Bug Fix: $insertDataTransferForRichText ignores its selection argument for text/plain #8974, closes Bug: $insertDataTransferForRichText doesn't use selection parameter #6278). $defaultPlainTextImporter in
    packages/lexical-clipboard/src/ClipboardImportExtension.ts re-read
    $getSelection() for every token it inserted, so text/plain and
    text/uri-list payloads always landed at the editor's current selection
    instead of the supplied one — a caller that builds its own RangeSelection
    (a find-and-replace pass, say) had its content inserted at the caret. The
    re-read is necessary, because each insertion reports its trailing caret
    through the editor's selection and a node replacement can swap the selection
    object outright (Fix insertText outdated selection after node replacement #5954). So promote the supplied selection with
    $setSelection before the loop rather than dropping it, which keeps the
    paste/drop path byte-for-byte identical and matches what the text/html and
    application/x-lexical-editor handlers already do via
    $insertGeneratedNodes.

  • The capitalization formats are exported as text-transform but never
    imported
    ([lexical][lexical-html] Bug Fix: restore the capitalization formats from text-transform on import #8991, closes text-transform (uppercase/lowercase/capitalize) is lost on an HTML round trip #8915). TextNode.exportDOM writes
    text-transform: lowercase|uppercase|capitalize, but neither importer read
    it back: applyTextFormatFromStyle in
    packages/lexical/src/nodes/LexicalTextNode.ts and
    readElementFormatStyle / styleFormatOverride in
    packages/lexical-html/src/import/coreImportRules.ts both handle only
    font-weight, font-style, text-decoration and vertical-align. So
    IS_LOWERCASE / IS_UPPERCASE / IS_CAPITALIZE were silently dropped on
    every $generateHtmlFromNodes -> $generateNodesFromDOM round trip and on
    copy/paste between editors, while every other text format survived. Both
    importers now read text-transform, mirroring how vertical-align is
    handled, including the mutual exclusion (only one of the three can apply).
    On the @lexical/html side text-transform: none clears all three — the
    same "explicit non-decorating value clears the bit" rule font-weight: normal and vertical-align: baseline already follow — and
    text-transform joins FORMAT_BIT_STYLE_PROPS so the property is owned by
    the format bit mask instead of also being materialized onto the node's
    inline style.

  • The slot frame redirect is not applied to a NodeSelection when exporting
    HTML
    ([lexical-html] Bug Fix: apply the slot frame redirect to a NodeSelection when exporting HTML #9010). Slots are shadow-root isolated, so a selection wholly inside
    a slot subtree never includes its host and a walk over the root's children
    misses it. $generateDOMFromNodes in packages/lexical-html/src/index.ts
    redirects the walk through the selection's slot frame, but computed that
    frame only for a RangeSelection; its sibling
    $generateJSONFromSelectedNodes in @lexical/clipboard handles both types.
    The fix for Bug: First issues with named slots #8712 only updated @lexical/clipboard and never brought
    $generateDOMFromNodes along, which split the two clipboard channels:
    selecting a node nested in a slot and copying produced a correct
    application/x-lexical-editor payload and an empty text/html one, so
    pasting into any other application yielded nothing. Anchor on the first
    selected node for a NodeSelection, exactly as the clipboard
    implementation does.

  • A parsed EditorState does not carry the _slotsUsed flag ([lexical] Bug Fix: a parsed EditorState carries the _slotsUsed flag #9042).
    $setSlot latched the flag onto editor._pendingEditorState, which is
    correct inside editor.update() but wrong during parseEditorState:
    $parseSerializedNodeImpl builds a detached EditorState, assigns it to
    activeEditorState, and leaves _pendingEditorState untouched. So a parsed
    slot document arrived with _slotsUsed === false (and an unrelated pending
    state could be stamped instead), setEditorState propagated that false
    onward, and the receiving editor silently skipped every gated path —
    $clampRangeSelectionToSlotFrame, the slot-island re-render in
    setEditable, and the slot walks in LexicalSelection, LexicalUtils and
    LexicalMutations. Mark the active editor state, which is the one being
    built in both cases. The helper moves from LexicalSlot.ts to
    LexicalUtils.ts as $markSlotsUsed because LexicalSlot cannot import
    LexicalUpdates directly — that edge forms a module-initialization cycle,
    the same reason the file already avoids a runtime LexicalNode import —
    while LexicalUtils already imports getActiveEditorState and is already
    imported by LexicalSlot.

Test plan

Four new unit tests, one per fix, each pairing the failing case with a control
that passes before and after so the defect is pinned to the exact condition:
text/html and the ordinary paste path for the clipboard selection argument,
the export assertion and a stray text-transform: none for the capitalization
round trip, the RangeSelection export for the slot frame redirect, and the
update() path plus a slotless document for the parsed _slotsUsed flag. No
existing test expectation was changed.

Before

$ npx vitest run --project unit packages/lexical/src packages/lexical-html packages/lexical-clipboard
   (with the six source files reverted, new tests kept)

     × $generateNodesFromDOM restores lowercase 121ms
     × $generateNodesFromDOM restores uppercase 57ms
     × $generateNodesFromDOM restores capitalize 47ms
     × $generateNodesFromDOMViaExtension restores lowercase 9ms
     × $generateNodesFromDOMViaExtension restores uppercase 37ms
     × $generateNodesFromDOMViaExtension restores capitalize 9ms
     × text/plain is inserted at the supplied selection 16ms
     × multi-line text/plain is inserted at the supplied selection 53ms
     × text/uri-list is inserted at the supplied selection 7ms
     × a NodeSelection inside a slot exports the node, not empty HTML 214ms
     × a parsed state carries the flag 45ms
     × setEditorState latches the flag on an editor that did not parse it 9ms
 Test Files  4 failed | 70 passed (74)
      Tests  12 failed | 1391 passed (1403)

After

$ npx vitest run --project unit packages/lexical/src packages/lexical-html packages/lexical-clipboard

 Test Files  74 passed (74)
      Tests  1403 passed (1403)

$ npx tsc --noEmit -p .
(clean)

Supersedes #8974, #8991, #9010, #9042, consolidated per the review feedback on
#9027 and #9035.

…t and slot frames carry the whole state

## Description

Four independent leaks on the same boundary: the HTML/clipboard import-export
round trip and the slot machinery it walks. In each case one side of the
boundary knows something the other side never receives — a format that is
written but never read back, a redirect applied to one selection type but not
the other, a flag set on the wrong EditorState, and a selection argument that
is documented but ignored. Each fix is self-contained and independently
reviewable:

- **`$insertDataTransferForRichText` ignores its `selection` argument for
  `text/plain`** (facebook#8974, closes facebook#6278). `$defaultPlainTextImporter` in
  `packages/lexical-clipboard/src/ClipboardImportExtension.ts` re-read
  `$getSelection()` for every token it inserted, so `text/plain` and
  `text/uri-list` payloads always landed at the editor's current selection
  instead of the supplied one — a caller that builds its own RangeSelection
  (a find-and-replace pass, say) had its content inserted at the caret. The
  re-read is necessary, because each insertion reports its trailing caret
  through the editor's selection and a node replacement can swap the selection
  object outright (facebook#5954). So promote the supplied selection with
  `$setSelection` before the loop rather than dropping it, which keeps the
  paste/drop path byte-for-byte identical and matches what the `text/html` and
  `application/x-lexical-editor` handlers already do via
  `$insertGeneratedNodes`.

- **The capitalization formats are exported as `text-transform` but never
  imported** (facebook#8991, closes facebook#8915). `TextNode.exportDOM` writes
  `text-transform: lowercase|uppercase|capitalize`, but neither importer read
  it back: `applyTextFormatFromStyle` in
  `packages/lexical/src/nodes/LexicalTextNode.ts` and
  `readElementFormatStyle` / `styleFormatOverride` in
  `packages/lexical-html/src/import/coreImportRules.ts` both handle only
  `font-weight`, `font-style`, `text-decoration` and `vertical-align`. So
  `IS_LOWERCASE` / `IS_UPPERCASE` / `IS_CAPITALIZE` were silently dropped on
  every `$generateHtmlFromNodes` -> `$generateNodesFromDOM` round trip and on
  copy/paste between editors, while every other text format survived. Both
  importers now read `text-transform`, mirroring how `vertical-align` is
  handled, including the mutual exclusion (only one of the three can apply).
  On the `@lexical/html` side `text-transform: none` clears all three — the
  same "explicit non-decorating value clears the bit" rule `font-weight:
  normal` and `vertical-align: baseline` already follow — and
  `text-transform` joins `FORMAT_BIT_STYLE_PROPS` so the property is owned by
  the format bit mask instead of also being materialized onto the node's
  inline style.

- **The slot frame redirect is not applied to a NodeSelection when exporting
  HTML** (facebook#9010). Slots are shadow-root isolated, so a selection wholly inside
  a slot subtree never includes its host and a walk over the root's children
  misses it. `$generateDOMFromNodes` in `packages/lexical-html/src/index.ts`
  redirects the walk through the selection's slot frame, but computed that
  frame only for a `RangeSelection`; its sibling
  `$generateJSONFromSelectedNodes` in `@lexical/clipboard` handles both types.
  The fix for facebook#8712 only updated `@lexical/clipboard` and never brought
  `$generateDOMFromNodes` along, which split the two clipboard channels:
  selecting a node nested in a slot and copying produced a correct
  `application/x-lexical-editor` payload and an *empty* `text/html` one, so
  pasting into any other application yielded nothing. Anchor on the first
  selected node for a `NodeSelection`, exactly as the clipboard
  implementation does.

- **A parsed EditorState does not carry the `_slotsUsed` flag** (facebook#9042).
  `$setSlot` latched the flag onto `editor._pendingEditorState`, which is
  correct inside `editor.update()` but wrong during `parseEditorState`:
  `$parseSerializedNodeImpl` builds a *detached* `EditorState`, assigns it to
  `activeEditorState`, and leaves `_pendingEditorState` untouched. So a parsed
  slot document arrived with `_slotsUsed === false` (and an unrelated pending
  state could be stamped instead), `setEditorState` propagated that `false`
  onward, and the receiving editor silently skipped every gated path —
  `$clampRangeSelectionToSlotFrame`, the slot-island re-render in
  `setEditable`, and the slot walks in `LexicalSelection`, `LexicalUtils` and
  `LexicalMutations`. Mark the *active* editor state, which is the one being
  built in both cases. The helper moves from `LexicalSlot.ts` to
  `LexicalUtils.ts` as `$markSlotsUsed` because `LexicalSlot` cannot import
  `LexicalUpdates` directly — that edge forms a module-initialization cycle,
  the same reason the file already avoids a runtime `LexicalNode` import —
  while `LexicalUtils` already imports `getActiveEditorState` and is already
  imported by `LexicalSlot`.

## Test plan

Four new unit tests, one per fix, each pairing the failing case with a control
that passes before and after so the defect is pinned to the exact condition:
`text/html` and the ordinary paste path for the clipboard selection argument,
the export assertion and a stray `text-transform: none` for the capitalization
round trip, the RangeSelection export for the slot frame redirect, and the
`update()` path plus a slotless document for the parsed `_slotsUsed` flag. No
existing test expectation was changed.

### Before

```
$ npx vitest run --project unit packages/lexical/src packages/lexical-html packages/lexical-clipboard
   (with the six source files reverted, new tests kept)

     × $generateNodesFromDOM restores lowercase 121ms
     × $generateNodesFromDOM restores uppercase 57ms
     × $generateNodesFromDOM restores capitalize 47ms
     × $generateNodesFromDOMViaExtension restores lowercase 9ms
     × $generateNodesFromDOMViaExtension restores uppercase 37ms
     × $generateNodesFromDOMViaExtension restores capitalize 9ms
     × text/plain is inserted at the supplied selection 16ms
     × multi-line text/plain is inserted at the supplied selection 53ms
     × text/uri-list is inserted at the supplied selection 7ms
     × a NodeSelection inside a slot exports the node, not empty HTML 214ms
     × a parsed state carries the flag 45ms
     × setEditorState latches the flag on an editor that did not parse it 9ms
 Test Files  4 failed | 70 passed (74)
      Tests  12 failed | 1391 passed (1403)
```

### After

```
$ npx vitest run --project unit packages/lexical/src packages/lexical-html packages/lexical-clipboard

 Test Files  74 passed (74)
      Tests  1403 passed (1403)

$ npx tsc --noEmit -p .
(clean)
```

Supersedes facebook#8974, facebook#8991, facebook#9010, facebook#9042, consolidated per the review feedback on
facebook#9027 and facebook#9035.
@vercel

vercel Bot commented Aug 10, 2026

Copy link
Copy Markdown

@LeSingh1 is attempting to deploy a commit to the Meta Open Source Team on Vercel.

A member of the Team first needs to authorize it.

@vercel

vercel Bot commented Aug 16, 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 Aug 29, 2026 8:50pm
lexical-playground Ready Ready Preview Aug 29, 2026 8:50pm

Request Review

…hare one slot-frame lookup across the export pipelines

## Description

Review follow-up to the slot-frame work in this branch. The NodeSelection
redirect landed in `$generateDOMFromNodes` as a verbatim copy of the block
already in `$generateJSONFromSelectedNodes`, so the same eight-line anchor
computation and its twelve-line comment now exist in two packages with nothing
holding them together. Both copies also enumerate selection types
(`$isRangeSelection`, else `$isNodeSelection`, else give up), and a
`TableSelection` is neither: it `implements BaseSelection` directly, while
`TableNode.isShadowRoot()` is true. Copying a cell range from a table nested in
a slot therefore walked the root's children, never reached the slot subtree,
and produced an empty `text/html` payload *and* an empty
`application/x-lexical-editor` one.

- **One helper, exported from `lexical`.** `$getSelectionSlotFrame(selection)`
  joins `$getSlotFrame` in `packages/lexical/src/LexicalSlot.ts` and is the
  single place the anchor rule lives. `@lexical/html` and `@lexical/clipboard`
  both call it, so the two clipboard channels can no longer drift the way they
  did when the fix for facebook#8712 updated only one of them.

- **Every selection type participates.** A `RangeSelection` anchors on its
  anchor point, as before; anything else anchors on the first node it reports,
  which covers `NodeSelection`, `TableSelection`, and any `BaseSelection` an
  app defines, instead of falling through to a root walk. The
  `getNodes()[0]`-is-insertion-order caveat moves into the helper's doc
  comment, where it now applies to one implementation rather than two.

- **`text-transform: none` is covered.** The clear half of the capitalization
  rule in `coreImportRules.ts` had no test reaching it: the existing case runs
  `$generateNodesFromDOM`, the legacy path, which has no ancestor format
  inheritance to clear and so passes whether or not the branch works. The new
  case nests `text-transform: none` inside `text-transform: uppercase` on the
  `@lexical/html` path.

- **`$insertDataTransferForRichText` documents its selection precondition.**
  Routing `text/plain` through `$setSelection` means the supplied selection
  must be one the update may write to; a selection read out of a committed
  EditorState now raises `$setSelection called on frozen selection object` in
  development, where it previously inserted at the caret. The JSDoc said only
  "the selection to use as the insertion point".

No behavior changes beyond closing the `TableSelection` gap; the
`RangeSelection` and `NodeSelection` paths compute the same frame they did
before.

## Test plan

Two new export tests in `@lexical/table` pin the `TableSelection` gap on both
clipboard channels, five in `LexicalSlot.test.ts` cover the helper's contract
directly (null selection, a selection outside any slot, Range and Node
selections inside one, and an empty `NodeSelection`), and one in
`Issue8915Repro.test.ts` reaches the `text-transform: none` branch. The five
helper tests exercise a function that does not exist before this change, so
only the other three are meaningful in the "Before" run.

### Before

```
$ npx vitest --project unit --no-watch \
    packages/lexical-table/src/__tests__/unit/SlotTableSelectionExport.test.ts \
    packages/lexical-html/src/__tests__/unit/Issue8915Repro.test.ts
   (with the five source files reverted, new tests kept)

     × text/html export contains the selected cells 23ms
     × application/x-lexical-editor export contains the selected cells 6ms
⎯⎯⎯⎯⎯⎯⎯ Failed Tests 2 ⎯⎯⎯⎯⎯⎯⎯
AssertionError: expected '' to contain 'SlotTableTarget'
AssertionError: expected '[]' to contain 'SlotTableTarget'
 Test Files  1 failed | 1 passed (2)
      Tests  2 failed | 11 passed (13)
```

### After

```
$ npx vitest --project unit --no-watch \
    packages/lexical-table/src/__tests__/unit/SlotTableSelectionExport.test.ts \
    packages/lexical-html/src/__tests__/unit/Issue8915Repro.test.ts \
    packages/lexical/src/__tests__/unit/LexicalSlot.test.ts

 Test Files  3 passed (3)
      Tests  121 passed (121)

$ pnpm run test-unit

 Test Files  269 passed (269)
      Tests  4514 passed | 1 skipped (4515)

$ pnpm run tsc && pnpm run flow && pnpm run lint && pnpm run prettier
(clean; flow reports "No errors!")

$ pnpm run build-types
(clean)
```

Browser-mode and E2E suites were not exercised — this change touches no
layout, selection-resolution, or rendering code path.
@etrepum
etrepum added this pull request to the merge queue Aug 29, 2026
Merged via the queue into facebook:main with commit 13c8209 Aug 29, 2026
46 checks passed
@etrepum etrepum mentioned this pull request Aug 31, 2026
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.

text-transform (uppercase/lowercase/capitalize) is lost on an HTML round trip Bug: $insertDataTransferForRichText doesn't use selection parameter

4 participants