diff --git a/packages/lexical/src/LexicalDOMSlot.ts b/packages/lexical/src/LexicalDOMSlot.ts index c08a3f953be..88047c06ef6 100644 --- a/packages/lexical/src/LexicalDOMSlot.ts +++ b/packages/lexical/src/LexicalDOMSlot.ts @@ -431,11 +431,23 @@ export class ElementDOMSlot< lineBreakType: null | 'empty' | 'line-break' | 'decorator', ): void { const element: HTMLElement & LexicalPrivateDOM = this.element; - element.__lexicalLastChildKind = lineBreakType; - if (lineBreakType === null) { + // Slot containers are a contiguous prefix of the managed range, so only + // its first node needs to be inspected. This also excludes containers + // outside an `after` boundary. + const firstNode = + this.after === null ? element.firstChild : this.after.nextSibling; + const nextLineBreakType = + lineBreakType === 'empty' && isSlotContainerDOM(firstNode) + ? null + : lineBreakType; + if (element.__lexicalLastChildKind === nextLineBreakType) { + return; + } + element.__lexicalLastChildKind = nextLineBreakType; + if (nextLineBreakType === null) { this.removeManagedLineBreak(); } else { - const webkitHack = lineBreakType === 'decorator' && IS_WEBKIT_BROWSER; + const webkitHack = nextLineBreakType === 'decorator' && IS_WEBKIT_BROWSER; this.insertManagedLineBreak(webkitHack); } } diff --git a/packages/lexical/src/LexicalReconciler.ts b/packages/lexical/src/LexicalReconciler.ts index 1aee8c6e73e..cc345c09d60 100644 --- a/packages/lexical/src/LexicalReconciler.ts +++ b/packages/lexical/src/LexicalReconciler.ts @@ -911,12 +911,7 @@ function $isLastChildLineBreakOrDecorator( : null; } } - // A host with slots but no linked-list children is not empty (the slots - // carry its content). The 'empty' line break exists to give a truly empty - // block a caret target; on a slots-only host that
would instead be a - // stray caret target in the host's own child area, after the slot - // containers — text typed there leaks out of the slot. Skip it. - return $readSlots(element).size > 0 ? null : 'empty'; + return 'empty'; } return null; } @@ -962,20 +957,14 @@ function $reconcileElementTerminatingLineBreak( nextElement: ElementNode, dom: HTMLElement & LexicalPrivateDOM, ): void { - // Read previous render's last-child kind from the slot element's cache - // so the prev-state DecoratorNode reference's isInline() (which routes - // through getLatest() and would throw once the key is detached from the - // active node map) is never called. const slot = $getDOMSlot(nextElement, dom, activeEditor); - const slotElement: HTMLElement & LexicalPrivateDOM = slot.element; - const prevLineBreak = slotElement.__lexicalLastChildKind ?? null; const nextLineBreak = $isLastChildLineBreakOrDecorator( nextElement, activeNextNodeMap, ); - if (prevLineBreak !== nextLineBreak) { - slot.setManagedLineBreak(nextLineBreak); - } + // ElementDOMSlot normalizes the empty state against the actual content + // range, including named-slot containers, and caches the result. + slot.setManagedLineBreak(nextLineBreak); } function reconcileTextFormat(element: ElementNode): void { diff --git a/packages/lexical/src/__tests__/unit/LexicalDOMSlot.test.tsx b/packages/lexical/src/__tests__/unit/LexicalDOMSlot.test.tsx index 7e1c2f2e353..f4a647a7bbf 100644 --- a/packages/lexical/src/__tests__/unit/LexicalDOMSlot.test.tsx +++ b/packages/lexical/src/__tests__/unit/LexicalDOMSlot.test.tsx @@ -125,6 +125,19 @@ describe('ElementDOMSlot class', () => { expect(updated).toBe(original); }); + test('setManagedLineBreak ignores slot containers before the managed range', () => { + const el = makeElement(); + const slotContainer = document.createElement('div'); + slotContainer.setAttribute('data-lexical-slot', 'title'); + const after = document.createElement('span'); + el.append(slotContainer, after); + const slot = new ElementDOMSlot(el, null, after); + + inEditor(() => slot.setManagedLineBreak('empty')); + + expect(slot.getManagedLineBreak()).not.toBe(null); + }); + test('insertChild appends when before is null', () => { const el = makeElement(); const slot = new ElementDOMSlot(el); diff --git a/packages/lexical/src/__tests__/unit/LexicalSlot.test.ts b/packages/lexical/src/__tests__/unit/LexicalSlot.test.ts index 9589e05bd38..abe765f8b61 100644 --- a/packages/lexical/src/__tests__/unit/LexicalSlot.test.ts +++ b/packages/lexical/src/__tests__/unit/LexicalSlot.test.ts @@ -143,6 +143,30 @@ class ReservedDeclaredHostNode extends ElementNode { } } +class WrappedSlotHostNode extends ElementNode { + $config() { + return this.config('wrapped_slot_host', { + extends: ElementNode, + slots: ['title'], + }); + } + createDOM() { + const host = document.createElement('div'); + const content = document.createElement('span'); + content.dataset.content = 'true'; + host.appendChild(content); + return host; + } + updateDOM() { + return false; + } + getDOMSlot(dom: HTMLElement) { + return super + .getDOMSlot(dom) + .withElement(dom.querySelector('[data-content]') ?? dom); + } +} + const mountedRoots: HTMLElement[] = []; afterEach(() => { while (mountedRoots.length > 0) { @@ -170,6 +194,7 @@ function createSlotEditor(): LexicalEditorWithDispose { ReorderedHostNode, DupDeclaredHostNode, ReservedDeclaredHostNode, + WrappedSlotHostNode, ], }), ); @@ -182,6 +207,35 @@ function createSlotEditor(): LexicalEditorWithDispose { } describe('named-slots: core foundation', () => { + test('an empty wrapped child area gets a managed line break', () => { + using editor = createSlotEditor(); + let hostKey = ''; + + editor.update( + () => { + const host = $create(WrappedSlotHostNode).append( + $createTextNode('Body'), + ); + $getRoot().append(host); + $setSlot(host, 'title', $slotContainer('Title')); + hostKey = host.getKey(); + }, + {discrete: true}, + ); + editor.update( + () => { + $assertNodeType($getNodeByKey(hostKey), $isElementNode).clear(); + }, + {discrete: true}, + ); + + const hostDom = editor.getElementByKey(hostKey)!; + const content = hostDom.querySelector('[data-content]')!; + expect( + content.querySelector('[data-lexical-managed-linebreak="true"]'), + ).not.toBe(null); + }); + test('a slotted node is reachable, parentless, and attached', () => { using editor = createSlotEditor(); let hostKey = ''; @@ -1398,6 +1452,29 @@ describe('named-slots: core foundation', () => { // A truly empty host (no slots, no children) still gets the br — the // gate is scoped to slots-only hosts. expect(directBr(editor.getElementByKey(emptyHostKey)!)).not.toBe(undefined); + + editor.update( + () => { + $removeSlot( + $assertNodeType($getNodeByKey(slotHostKey), $isElementNode), + 'title', + ); + }, + {discrete: true}, + ); + expect(directBr(editor.getElementByKey(slotHostKey)!)).not.toBe(undefined); + + editor.update( + () => { + $setSlot( + $assertNodeType($getNodeByKey(slotHostKey), $isElementNode), + 'title', + $slotContainer('New title'), + ); + }, + {discrete: true}, + ); + expect(directBr(editor.getElementByKey(slotHostKey)!)).toBeUndefined(); }); test('descendant navigation stays children-only (slots stay out of selection)', () => {