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)', () => {