Skip to content

[lexical][lexical-html] Bug Fix: gate trailing <br> import drop on managed line breaks - #8765

Draft
potatowagon wants to merge 2 commits into
facebook:mainfrom
potatowagon:jv-managed-linebreak-import
Draft

[lexical][lexical-html] Bug Fix: gate trailing <br> import drop on managed line breaks#8765
potatowagon wants to merge 2 commits into
facebook:mainfrom
potatowagon:jv-managed-linebreak-import

Conversation

@potatowagon

@potatowagon potatowagon commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Description

Follow-up to #8750, implementing @etrepum's marker approach from #7600 (review 2893090447): mark Lexical's reconciler-injected terminating <br> (data-lexical-managed-linebreak="true", already set in LexicalDOMSlot.setManagedLineBreak) so importDOM can drop that artifact while preserving a real, unmarked trailing break.

Decision: lossless round-trip is the priority for an unmarked trailing <br>, over matching the browser's non-rendering of a trailing block <br>. This PR therefore preserves unmarked trailing breaks and drops only the marked artifacts. The known cost is documented below.

The core conflict

A trailing <br> as the last child of a block is non-rendering in HTML (browsers show nothing for it). So the same DOM shape <p>2<br></p> means two different things depending on origin:

  • Browser/paste artifact → ideally dropped on import (match browser rendering; no phantom blank line).
  • Authored/intentional break → must be preserved on import (so export → import is lossless).

An ordinary authored <br> carries no marker to distinguish these, so an import rule must pick one policy for unmarked trailing breaks — you cannot have both for the same input. We choose preserve (round-trip fidelity).

Behavior of each approach on a trailing block <br>

Verified empirically (drop = <br> removed on import):

Input (last child <br> of a block) #6395 (original) #8750 (Apple-only) #8765 (this PR)
external / unmarked authored1<p>2<br /></p>3 drop → 1/2/3 keep → blank line keep → blank line
Lexical managed artifact<br data-lexical-managed-linebreak> drop keep → round-trip wrong drop → round-trip correct
Apple clipboard<br class="Apple-interchange-newline"> drop drop drop

Note: #8765 is strictly stronger than #8750 for round-trip#8750 keys only on the Apple class, so it does not drop Lexical's own data-lexical-managed-linebreak artifact and thus fails to fix the round-trip it targeted. #8765 drops that artifact correctly.

Why this is complete for round-trip (no exportDOM change needed)

LineBreakNode has no exportDOM override; the default emits a single unmarked <br>. With the "preserve unmarked" policy, both export paths round-trip losslessly:

Export: no change required

exportDOM is intentionally left untouched. The "preserve unmarked" import policy makes the export-side doubling that #7600 introduced (emitting N+1 <br> in exportDOM) unnecessary — a single unmarked <br> round-trips as-is:

Trailing LineBreakNodes exportDOM HTML re-import result
1 <p>2<br></p> 1 (last-child <br> unmarked → preserved)
2 <p>2<br><br></p> 2 (only the last is a "last child"; unmarked → preserved)
3 <p>2<br><br><br></p> 3
empty block <p><br></p> empty (only-child → dropped, unchanged)

Verified against the $generateHtmlFromNodes$generateNodesFromDOM path and the existing block-heavy snapshots (135 lexical-list + lexical-history tests pass, incl. list items / checkboxes / nested blocks). We deliberately do not stamp data-lexical-managed-linebreak on exportDOM output — that would be the #7600 export direction @etrepum preferred to avoid; round-trip works purely by preservation instead.

Known cost

External HTML with an unmarked trailing <br> (e.g. 1<p>2<br /></p>3) is now preserved and renders a blank line, where a browser would render nothing. This is the accepted trade for lossless round-trip. Matching browser rendering here would require reverting to #6395's drop-on-import and moving the round-trip fix to exportDOM (emit N+1 <br>, #7600-style) — the direction @etrepum preferred not to take.

What this PR does

Drop a trailing <br> on import only when it is a managed line break:

  • data-lexical-managed-linebreak="true" (Lexical's reconciler artifact), or
  • class="Apple-interchange-newline" (Safari clipboard artifact).

Applied to both LineBreakNode.importDOM and the @lexical/html coreImportRules LineBreakRule; isManagedLineBreak is exported from lexical so both share it. The pre-existing isManagedLineBreak in LexicalMutations (which detects an editor-managed live-DOM break) is renamed to isEditorManagedLineBreak to avoid the name collision.

cc @etrepum @levensta

Test plan

vitest --project unit passes for the affected files. Coverage in LexicalEventHelpers.test.tsx:

  • unmarked authored trailing <br> is preserved (1<p>2<br /></p>3) — documents the accepted cost
  • managed trailing <br> (data-lexical-managed-linebreak) is ignored
  • Apple-interchange trailing <br> is ignored
✓ packages/lexical-utils/.../LexicalEventHelpers.test.tsx
✓ packages/lexical-html (87 tests)

…naged line breaks

Follow-up to facebook#8750 implementing @etrepum's approach from facebook#7600.

facebook#6395 dropped every trailing <br> in a block on import to match HTML's
non-rendering behavior, but that loses authored trailing breaks on
round-trip serialization. facebook#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 <br> 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 <br> 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.
@vercel

vercel Bot commented Jun 30, 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 Jul 3, 2026 7:58am
lexical-playground Ready Ready Preview Jul 3, 2026 7:58am

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 Jun 30, 2026
@etrepum etrepum added the extended-tests Run extended e2e tests on a PR label Jun 30, 2026
* An ordinary authored trailing `<br>` carries neither marker and is
* preserved so that round-trip serialization to HTML stays lossless.
*/
export function isManagedLineBreak(node: Node): boolean {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

A different name for this probably makes sense, or we should rename the one in LexicalMutations (maybe isEditorManagedLineBreak). They don't work the same way, so it may cause confusion.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good call — renamed the existing LexicalMutations helper to isEditorManagedLineBreak (68c830d) to avoid the collision. Added cross-referencing comments on both so the distinction is clear: the mutations one identifies a break the editor is actively managing in the live DOM (via __lexicalLineBreak / node key), while the new one inspects serialized markup (data-lexical-managed-linebreak / Apple-interchange class) to decide whether a parsed <br> should be dropped on import.

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 <br> 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.
@levensta

Copy link
Copy Markdown
Contributor

Looks like everything's ready here?

@levensta

Copy link
Copy Markdown
Contributor

Is there anything that prevents this PR from being merged now, other than the draft status? @etrepum @potatowagon

@etrepum

etrepum commented Jul 16, 2026

Copy link
Copy Markdown
Collaborator

Claude claims that this is missing a case where the <br> is the only child of a block, here's the unedited response. I'm not sure if (1) is actually a thing that happens or if it's just guessing, I haven't looked at non-lexical HTML of this nature much recently. I don't think (2) actually matters, this Safari quirk probably isn't going to suddenly add additional classes or change the class it uses.


Code review — gate trailing <br> import drop on managed line breaks

Reviewed the diff (5 files, +75/−14). The change drops a trailing <br> on import only when it carries a "managed" marker (data-lexical-managed-linebreak="true" or class="Apple-interchange-newline"), preserving ordinary authored trailing breaks. Mechanically the diff is clean — the isEditorManagedLineBreak rename is internal-only with all three call sites updated, the new isManagedLineBreak export is wired through index.ts and coreImportRules.ts, and the test expectations are consistent. Findings below, most-severe first.

1. Unmarked browser-inserted trailing <br> is now preserved (phantom blank line) — coreImportRules.ts / LexicalLineBreakNode.importDOM

Pasting external HTML like <div>foo<br></div> or <p>foo<br></p> from a browser contenteditable, Google Docs, or a website: the trailing <br> carries neither marker, so it's now imported as a LineBreakNodefoo\n with an extra empty line. The deleted comment explicitly named "the trailing <br> browsers insert after the last text in a <div>" as a case to drop; that protection is gone in both import paths. This is the crux of the PR — preserving authored breaks necessarily preserves indistinguishable external browser-artifact breaks too. If extra trailing blank lines on some external pastes are an accepted cost of lossless round-trips, fine; if not, this needs an explicit decision before merge.

2. Apple-interchange detection uses exact string equality on the whole class attribute — LexicalLineBreakNode.ts

node.getAttribute('class') === 'Apple-interchange-newline' fails for any <br> whose class isn't byte-for-byte that single token: e.g. class="Apple-interchange-newline foo", a leading/trailing space, or reordered tokens → treated as authored → preserved → extra blank line. Use node.classList.contains('Apple-interchange-newline').

3. Docstring rationale doesn't match how Lexical exports HTML — LexicalLineBreakNode.ts

The comment justifies the marker via "Re-importing Lexical's own exported HTML", but clipboard/serialization goes through $generateHtmlFromNodes (exportDOM), and LineBreakNode has no exportDOM, so it emits a plain <br> with no marker. The data-lexical-managed-linebreak attribute only exists in the live contenteditable DOM (set by LexicalDOMSlot.setManagedLineBreak). So the marker fires only for raw live-DOM copies, not for exported HTML — the comment mis-models when this branch actually triggers.

4. "Lossless round-trip" claim is still violated for a single-linebreak paragraph — LexicalLineBreakNode.ts

isOnlyChildInBlockNode still drops the <br> unconditionally (not gated on managed). An authored paragraph containing exactly one LineBreakNode exports as <p><br></p>; on import the only-child <br> is dropped and the node is lost → empty paragraph. This contradicts the new "round-trip serialization to HTML stays lossless" comment sitting right next to it.

5. Drop predicate is duplicated across two import paths — LexicalLineBreakNode.ts + coreImportRules.ts

Both hardcode isOnlyChildInBlockNode(x) || (isLastChildInBlockNode(x) && isManagedLineBreak(x)). This PR had to edit both; a future change that updates only one will silently diverge the legacy importer from the extension importer. Consider a single shared $shouldDropImportedBr(node) helper.

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.

3 participants