From dbe2fcc66c8357c7cd816be91db9c27d7cadb405 Mon Sep 17 00:00:00 2001 From: Patrick Moody <166345262+patrick-atticus@users.noreply.github.com> Date: Fri, 16 Jan 2026 12:04:15 +1100 Subject: [PATCH] [lexical-link] Bug Fix: Toggle links with nested children (#8078) --- packages/lexical-link/src/LexicalLinkNode.ts | 29 +++++++---- .../__tests__/unit/LexicalLinkNode.test.ts | 52 +++++++++++++++++++ 2 files changed, 70 insertions(+), 11 deletions(-) diff --git a/packages/lexical-link/src/LexicalLinkNode.ts b/packages/lexical-link/src/LexicalLinkNode.ts index 195e8bc7feb..72aed26d077 100644 --- a/packages/lexical-link/src/LexicalLinkNode.ts +++ b/packages/lexical-link/src/LexicalLinkNode.ts @@ -549,9 +549,16 @@ function $splitLinkAtSelection( ); const allChildren = parentLink.getChildren(); - const extractedChildren = allChildren.filter((child) => - extractedKeys.has(child.getKey()), - ); + // Check if a child is an extracted node OR contains an extracted node + // This handles nested structures like LinkNode > HeadingNode > TextNode + const isExtractedChild = (child: LexicalNode): boolean => + extractedKeys.has(child.getKey()) || + ($isElementNode(child) && + extractedNodes.some( + (n) => parentLink.isParentOf(n) && child.isParentOf(n), + )); + + const extractedChildren = allChildren.filter(isExtractedChild); if (extractedChildren.length === allChildren.length) { allChildren.forEach((child) => parentLink.insertBefore(child)); @@ -559,12 +566,8 @@ function $splitLinkAtSelection( return; } - const firstExtractedIndex = allChildren.findIndex((child) => - extractedKeys.has(child.getKey()), - ); - const lastExtractedIndex = allChildren.findLastIndex((child) => - extractedKeys.has(child.getKey()), - ); + const firstExtractedIndex = allChildren.findIndex(isExtractedChild); + const lastExtractedIndex = allChildren.findLastIndex(isExtractedChild); const isAtStart = firstExtractedIndex === 0; const isAtEnd = lastExtractedIndex === allChildren.length - 1; @@ -676,9 +679,13 @@ export function $toggleLink( const processedLinks = new Set(); nodes.forEach((node) => { - const parentLink = node.getParent(); + const parentLink = $findMatchingParent( + node, + (parent): parent is LinkNode => + !$isAutoLinkNode(parent) && $isLinkNode(parent), + ); - if ($isLinkNode(parentLink) && !$isAutoLinkNode(parentLink)) { + if (parentLink !== null) { const linkKey = parentLink.getKey(); if (processedLinks.has(linkKey)) { diff --git a/packages/lexical-link/src/__tests__/unit/LexicalLinkNode.test.ts b/packages/lexical-link/src/__tests__/unit/LexicalLinkNode.test.ts index 33dab44d202..a67fd202420 100644 --- a/packages/lexical-link/src/__tests__/unit/LexicalLinkNode.test.ts +++ b/packages/lexical-link/src/__tests__/unit/LexicalLinkNode.test.ts @@ -15,6 +15,7 @@ import { SerializedLinkNode, } from '@lexical/link'; import {$createMarkNode, $isMarkNode} from '@lexical/mark'; +import {$createHeadingNode} from '@lexical/rich-text'; import { $createLineBreakNode, $createParagraphNode, @@ -525,6 +526,57 @@ describe('LexicalLinkNode tests', () => { }); }); + test('$toggleLink correctly removes link when link contains heading', async () => { + // This tests the structure: link > heading > text + const {editor} = testEnv; + await editor.update(() => { + const paragraph = $createParagraphNode(); + const linkNode = $createLinkNode('https://example.com/foo'); + const headingNode = $createHeadingNode('h3'); + const textNode = $createTextNode('Example Link'); + + headingNode.append(textNode); + linkNode.append(headingNode); + paragraph.append(linkNode); + $getRoot().append(paragraph); + }); + + // Verify initial structure: paragraph > link > heading > text + editor.read(() => { + const paragraph = $getRoot().getFirstChild() as ParagraphNode; + const linkNode = paragraph.getFirstChild(); + + expect($isLinkNode(linkNode)).toBe(true); + if ($isLinkNode(linkNode)) { + expect(linkNode.getURL()).toBe('https://example.com/foo'); + const headingNode = linkNode.getFirstChild(); + expect(headingNode?.getType()).toBe('heading'); + expect(headingNode?.getTextContent()).toBe('Example Link'); + } + }); + + // Select all and remove link + await editor.update(() => { + $selectAll(); + $toggleLink(null); + }); + + // Verify structure after link removal: paragraph > heading > text + editor.read(() => { + const paragraph = $getRoot().getFirstChild() as ParagraphNode; + const children = paragraph.getChildren(); + + // Link should be removed, heading should be moved up to paragraph level + expect(children.length).toBe(1); + const headingNode = children[0]; + expect(headingNode.getType()).toBe('heading'); + expect(headingNode.getTextContent()).toBe('Example Link'); + + // Verify no link nodes remain + expect($isLinkNode(headingNode)).toBe(false); + }); + }); + test('$toggleLink adds link with embedded LineBreakNode', async () => { const {editor} = testEnv; await editor.update(() => {