[lexical][lexical-html][lexical-clipboard] Bug Fix: HTML import/export and slot frames carry the whole state - #9052
Merged
Conversation
…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.
LeSingh1
requested review from
acywatson,
etrepum,
fantactuka,
ivailop7,
potatowagon and
zurfyx
as code owners
August 10, 2026 03:59
|
@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. |
This was referenced Aug 10, 2026
Closed
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
…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
approved these changes
Aug 29, 2026
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
$insertDataTransferForRichTextignores itsselectionargument fortext/plain([lexical-clipboard] Bug Fix: $insertDataTransferForRichText ignores its selection argument for text/plain #8974, closes Bug: $insertDataTransferForRichText doesn't use selection parameter #6278).$defaultPlainTextImporterinpackages/lexical-clipboard/src/ClipboardImportExtension.tsre-read$getSelection()for every token it inserted, sotext/plainandtext/uri-listpayloads always landed at the editor's current selectioninstead 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
$setSelectionbefore the loop rather than dropping it, which keeps thepaste/drop path byte-for-byte identical and matches what the
text/htmlandapplication/x-lexical-editorhandlers already do via$insertGeneratedNodes.The capitalization formats are exported as
text-transformbut neverimported ([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.exportDOMwritestext-transform: lowercase|uppercase|capitalize, but neither importer readit back:
applyTextFormatFromStyleinpackages/lexical/src/nodes/LexicalTextNode.tsandreadElementFormatStyle/styleFormatOverrideinpackages/lexical-html/src/import/coreImportRules.tsboth handle onlyfont-weight,font-style,text-decorationandvertical-align. SoIS_LOWERCASE/IS_UPPERCASE/IS_CAPITALIZEwere silently dropped onevery
$generateHtmlFromNodes->$generateNodesFromDOMround trip and oncopy/paste between editors, while every other text format survived. Both
importers now read
text-transform, mirroring howvertical-alignishandled, including the mutual exclusion (only one of the three can apply).
On the
@lexical/htmlsidetext-transform: noneclears all three — thesame "explicit non-decorating value clears the bit" rule
font-weight: normalandvertical-align: baselinealready follow — andtext-transformjoinsFORMAT_BIT_STYLE_PROPSso the property is owned bythe 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.
$generateDOMFromNodesinpackages/lexical-html/src/index.tsredirects the walk through the selection's slot frame, but computed that
frame only for a
RangeSelection; its sibling$generateJSONFromSelectedNodesin@lexical/clipboardhandles both types.The fix for Bug: First issues with named slots #8712 only updated
@lexical/clipboardand never brought$generateDOMFromNodesalong, which split the two clipboard channels:selecting a node nested in a slot and copying produced a correct
application/x-lexical-editorpayload and an emptytext/htmlone, sopasting into any other application yielded nothing. Anchor on the first
selected node for a
NodeSelection, exactly as the clipboardimplementation does.
A parsed EditorState does not carry the
_slotsUsedflag ([lexical] Bug Fix: a parsed EditorState carries the _slotsUsed flag #9042).$setSlotlatched the flag ontoeditor._pendingEditorState, which iscorrect inside
editor.update()but wrong duringparseEditorState:$parseSerializedNodeImplbuilds a detachedEditorState, assigns it toactiveEditorState, and leaves_pendingEditorStateuntouched. So a parsedslot document arrived with
_slotsUsed === false(and an unrelated pendingstate could be stamped instead),
setEditorStatepropagated thatfalseonward, and the receiving editor silently skipped every gated path —
$clampRangeSelectionToSlotFrame, the slot-island re-render insetEditable, and the slot walks inLexicalSelection,LexicalUtilsandLexicalMutations. Mark the active editor state, which is the one beingbuilt in both cases. The helper moves from
LexicalSlot.tstoLexicalUtils.tsas$markSlotsUsedbecauseLexicalSlotcannot importLexicalUpdatesdirectly — that edge forms a module-initialization cycle,the same reason the file already avoids a runtime
LexicalNodeimport —while
LexicalUtilsalready importsgetActiveEditorStateand is alreadyimported 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/htmland the ordinary paste path for the clipboard selection argument,the export assertion and a stray
text-transform: nonefor the capitalizationround trip, the RangeSelection export for the slot frame redirect, and the
update()path plus a slotless document for the parsed_slotsUsedflag. Noexisting test expectation was changed.
Before
After
Supersedes #8974, #8991, #9010, #9042, consolidated per the review feedback on
#9027 and #9035.