From 01ef3a0365aa289bbd53f5f200afb6373e10b829 Mon Sep 17 00:00:00 2001 From: potatowagon Date: Tue, 30 Jun 2026 10:17:42 -0700 Subject: [PATCH 1/2] [lexical][lexical-html] Bug Fix: gate trailing
import drop on managed line breaks Follow-up to #8750 implementing @etrepum's approach from #7600. #6395 dropped every trailing
in a block on import to match HTML's non-rendering behavior, but that loses authored trailing breaks on round-trip serialization. #8750 narrowed the drop to Safari's Apple-interchange-newline clipboard artifact, but that gate doesn't fire for ordinary structural/authored trailing breaks (or non-Safari pastes), so the phantom-blank-line regression persists for those. Instead, drop a trailing
only when it is a *managed* line break: - data-lexical-managed-linebreak="true": the terminator Lexical's reconciler already injects via LexicalDOMSlot.setManagedLineBreak, so re-importing Lexical's own exported HTML stays idempotent. - class="Apple-interchange-newline": Safari's clipboard transport artifact. An ordinary authored trailing
carries neither marker and is now preserved, so round-trip serialization to HTML is lossless while rendering of external pasted HTML stays faithful. Browser-agnostic for the Lexical-managed case. Applies the same gate to both LineBreakNode.importDOM and the @lexical/html coreImportRules LineBreakRule. Adds test coverage for the preserved / managed / Apple-interchange cases. --- .../src/import/coreImportRules.ts | 18 +++++++---- .../unit/LexicalEventHelpers.test.tsx | 20 ++++++++++-- packages/lexical/src/index.ts | 1 + .../lexical/src/nodes/LexicalLineBreakNode.ts | 32 ++++++++++++++++++- 4 files changed, 61 insertions(+), 10 deletions(-) diff --git a/packages/lexical-html/src/import/coreImportRules.ts b/packages/lexical-html/src/import/coreImportRules.ts index 87c4adc6c65..3d03aedcca5 100644 --- a/packages/lexical-html/src/import/coreImportRules.ts +++ b/packages/lexical-html/src/import/coreImportRules.ts @@ -30,6 +30,7 @@ import { isBlockDomNode, isDOMTextNode, isLastChildInBlockNode, + isManagedLineBreak, isOnlyChildInBlockNode, type LexicalNode, setNodeIndentFromDOM, @@ -446,14 +447,17 @@ const IgnoreScriptStyleRule = /* @__PURE__ */ defineImportRule({ }); const LineBreakRule = /* @__PURE__ */ defineImportRule({ - // Mirror the legacy LineBreakNode.importDOM filter: stray `
` that - // are the sole or trailing child of a block parent (e.g. Apple's - // `
` clipboard sentinel, or the - // trailing `
` browsers insert after the last text in a `
`) - // would otherwise survive as a LineBreakNode and tack an extra blank - // line onto the imported content. + // Mirror the LineBreakNode.importDOM filter: drop `
` that are only a + // rendering artifact rather than authored content. The sole child of a + // block is dropped (empty `
  • `/`

    ` round-trips as an empty element), + // and a trailing `
    ` is dropped only when it is a managed line break — + // Lexical's own `data-lexical-managed-linebreak` terminator or Safari's + // `
    ` clipboard sentinel. An ordinary + // authored trailing `
    ` is preserved so round-trip serialization to + // HTML stays lossless. $import: (_ctx, el) => - isOnlyChildInBlockNode(el) || isLastChildInBlockNode(el) + isOnlyChildInBlockNode(el) || + (isLastChildInBlockNode(el) && isManagedLineBreak(el)) ? [] : [$createLineBreakNode()], match: sel.tag('br'), diff --git a/packages/lexical-utils/src/__tests__/unit/LexicalEventHelpers.test.tsx b/packages/lexical-utils/src/__tests__/unit/LexicalEventHelpers.test.tsx index 4ba20a998a0..aaf9439182b 100644 --- a/packages/lexical-utils/src/__tests__/unit/LexicalEventHelpers.test.tsx +++ b/packages/lexical-utils/src/__tests__/unit/LexicalEventHelpers.test.tsx @@ -737,9 +737,25 @@ describe('LexicalEventHelpers', () => { }, { expectedHTML: - '

    1

    2

    3

    ', + '

    1

    2

    3

    ', inputs: [pasteHTML('1

    2

    3')], - name: 'last br in a block node is ignored', + name: 'authored trailing br in a block node is preserved', + }, + { + expectedHTML: + '

    1

    2

    3

    ', + inputs: [ + pasteHTML('1

    2

    3'), + ], + name: 'managed trailing br in a block node is ignored', + }, + { + expectedHTML: + '

    1

    2

    3

    ', + inputs: [ + pasteHTML('1

    2

    3'), + ], + name: 'apple-interchange trailing br in a block node is ignored', }, ]; diff --git a/packages/lexical/src/index.ts b/packages/lexical/src/index.ts index bc1434f60f3..ca8c1af9313 100644 --- a/packages/lexical/src/index.ts +++ b/packages/lexical/src/index.ts @@ -391,6 +391,7 @@ export { $createLineBreakNode, $isLineBreakNode, isLastChildInBlockNode, + isManagedLineBreak, isOnlyChildInBlockNode, LineBreakNode, } from './nodes/LexicalLineBreakNode'; diff --git a/packages/lexical/src/nodes/LexicalLineBreakNode.ts b/packages/lexical/src/nodes/LexicalLineBreakNode.ts index 376ae273ad7..e9f6376d35a 100644 --- a/packages/lexical/src/nodes/LexicalLineBreakNode.ts +++ b/packages/lexical/src/nodes/LexicalLineBreakNode.ts @@ -19,6 +19,7 @@ import { $applyNodeReplacement, isBlockDomNode, isDOMTextNode, + isHTMLElement, } from '../LexicalUtils'; export type SerializedLineBreakNode = SerializedLexicalNode; @@ -58,7 +59,10 @@ export class LineBreakNode extends LexicalNode { static importDOM(): DOMConversionMap | null { return { br: (node: Node) => { - if (isOnlyChildInBlockNode(node) || isLastChildInBlockNode(node)) { + if ( + isOnlyChildInBlockNode(node) || + (isLastChildInBlockNode(node) && isManagedLineBreak(node)) + ) { return null; } return { @@ -154,3 +158,29 @@ export function isLastChildInBlockNode(node: Node): boolean { function isWhitespaceDomTextNode(node: Node): boolean { return isDOMTextNode(node) && /^( |\t|\r?\n)+$/.test(node.textContent || ''); } + +/** + * True when `node` is a `
    ` that exists only as a rendering artifact + * rather than authored content, and therefore should be dropped on import + * to avoid a phantom trailing line break: + * + * - `data-lexical-managed-linebreak="true"`: the terminating `
    ` that + * Lexical's reconciler injects so a trailing line break is not collapsed + * by the parent block element (see `LexicalDOMSlot.setManagedLineBreak`). + * Re-importing Lexical's own exported HTML must not turn this into a real + * `LineBreakNode`. + * - `class="Apple-interchange-newline"`: the trailing `
    ` WebKit/Safari + * appends to clipboard HTML as a transport artifact. + * + * An ordinary authored trailing `
    ` carries neither marker and is + * preserved so that round-trip serialization to HTML stays lossless. + */ +export function isManagedLineBreak(node: Node): boolean { + if (node.nodeName !== 'BR' || !isHTMLElement(node)) { + return false; + } + return ( + node.getAttribute('data-lexical-managed-linebreak') === 'true' || + node.getAttribute('class') === 'Apple-interchange-newline' + ); +} From 68c830dce31b7a84c257de86814034946fdf4e16 Mon Sep 17 00:00:00 2001 From: potatowagon Date: Fri, 3 Jul 2026 00:56:49 -0700 Subject: [PATCH 2/2] Rename existing LexicalMutations helper to isEditorManagedLineBreak MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses review feedback: the new isManagedLineBreak in LexicalLineBreakNode (inspects serialized markup — the data-lexical-managed-linebreak attribute / Apple-interchange class to decide whether to drop a parsed
    on import) collided by name with the pre-existing isManagedLineBreak in LexicalMutations (identifies a line break the editor is actively managing in the live DOM via the __lexicalLineBreak slot reference / node key). They work differently, so the mutations one is renamed to isEditorManagedLineBreak and both now carry cross-referencing comments. --- packages/lexical/src/LexicalMutations.ts | 14 ++++++++++---- packages/lexical/src/nodes/LexicalLineBreakNode.ts | 4 ++++ 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/packages/lexical/src/LexicalMutations.ts b/packages/lexical/src/LexicalMutations.ts index 48545d5d41d..3a399bba39c 100644 --- a/packages/lexical/src/LexicalMutations.ts +++ b/packages/lexical/src/LexicalMutations.ts @@ -55,7 +55,13 @@ function initTextEntryListener(editor: LexicalEditor): void { } } -function isManagedLineBreak( +// True when `dom` is a line break the editor is actively managing in the +// live DOM — identified by the `__lexicalLineBreak` slot reference or by +// having a node key. This is distinct from `isManagedLineBreak` in +// LexicalLineBreakNode, which inspects serialized markup (the +// `data-lexical-managed-linebreak` attribute / Apple-interchange class) +// to decide whether a parsed `
    ` should be dropped on import. +function isEditorManagedLineBreak( dom: Node, target: Node & LexicalPrivateDOM, editor: LexicalEditor, @@ -202,7 +208,7 @@ function flushMutations( parentDOM != null && addedDOM !== blockCursorElement && node === null && - !isManagedLineBreak(addedDOM, parentDOM, editor) && + !isEditorManagedLineBreak(addedDOM, parentDOM, editor) && // @experimental named-slots. Slot containers are keyless // reconciler scaffolding: a flush that observes one being // parked in its host or relocated by an explicit mount must @@ -243,7 +249,7 @@ function flushMutations( const removedDOM = removedDOMs[s]; if ( - isManagedLineBreak(removedDOM, targetDOM, editor) || + isEditorManagedLineBreak(removedDOM, targetDOM, editor) || blockCursorElement === removedDOM ) { targetDOM.appendChild(removedDOM); @@ -289,7 +295,7 @@ function flushMutations( if ( parentDOM != null && addedDOM.nodeName === 'BR' && - !isManagedLineBreak(addedDOM, target, editor) + !isEditorManagedLineBreak(addedDOM, target, editor) ) { parentDOM.removeChild(addedDOM); } diff --git a/packages/lexical/src/nodes/LexicalLineBreakNode.ts b/packages/lexical/src/nodes/LexicalLineBreakNode.ts index e9f6376d35a..34a233d1988 100644 --- a/packages/lexical/src/nodes/LexicalLineBreakNode.ts +++ b/packages/lexical/src/nodes/LexicalLineBreakNode.ts @@ -174,6 +174,10 @@ function isWhitespaceDomTextNode(node: Node): boolean { * * An ordinary authored trailing `
    ` carries neither marker and is * preserved so that round-trip serialization to HTML stays lossless. + * + * Note: this inspects *serialized markup* and is distinct from + * `isEditorManagedLineBreak` in `LexicalMutations`, which identifies a + * line break the editor is actively managing in the live DOM. */ export function isManagedLineBreak(node: Node): boolean { if (node.nodeName !== 'BR' || !isHTMLElement(node)) {