Commit 93640867290 for woocommerce

commit 9364086729095afe8b1cc6c469bc717b257f1d9d
Author: Pavel Dohnal <pavel.dohnal@automattic.com>
Date:   Tue Oct 20 07:27:58 2026 +0200

    Insert personalization tags at the cursor in the email editor (#69794)

    * Insert personalization tags at the caret

    The block toolbar worked out the caret by walking the DOM next to the
    selection and then rebuilt the value from the stored HTML. After bold
    text, a link or another tag the walk failed, so the tag was appended to
    the end of the block. In templates whose HTML has leading whitespace
    the offset was off by one, and the result was always written to the
    content or text attribute, even when the caret was in an image caption.

    The format button already receives the RichText value with its
    selection and an onChange handler, the same way the core link format
    uses them. Insert into that value and save through onChange, so the
    offsets, the whitespace handling and the target attribute all match
    what the editor shows.

    * Track the sidebar field caret on selection change

    The subject and preheader fields worked out the caret with the same
    hand-written DOM walk as the block toolbar, so a tag inserted after
    bold text, a link or another tag went to the end of the field. The
    caret was also stored only on focus, keyup and click, which missed
    drag selections and stored the previous caret on focus.

    Read the offsets with rich-text create() from the live editable DOM,
    the way RichText itself tracks its selection, and store them on
    selectionchange while the field has focus. A selection made elsewhere
    in the panel no longer overwrites the stored caret, and a second
    insert lands right after the first tag.

    * Edit personalization tags through the RichText value

    Updating a tag or a tag link from its popover searched the stored HTML
    for the old tag with a string replace and wrote the result to the
    content attribute. With the same tag twice in a block the first one
    changed instead of the clicked one, captions and button text were
    never updated, and whitespace in the stored HTML could break the
    match.

    Resolve the clicked tag or link to its position in the RichText value
    and save the change through onChange, as inserting now does. Template
    links without the editor's class are handled as well, and the stored
    value is copied rather than changed in place so undo keeps working.

    * Keep link text and formatting when editing a tag link

    Rebuilding a tag link from its popover inserted plain text, so bold
    or italic around the link was dropped. Clearing the link text also
    sent the edit down the plain tag path and replaced the link with a
    tag.

    Re-apply the formats the old link text had, and keep the existing
    text when the new text is empty, so only the tag changes.

    * Edit the clicked tag in the sidebar fields

    The subject and preheader fields still updated a tag by replacing the
    first matching text, so with the same tag twice the first one changed
    instead of the clicked one. Resolve the clicked tag to its position,
    the way the block editor now does, and share the rename step between
    the two.

    The sidebar also re-rendered on every caret move just to remember the
    position, which is only read on insert. Keep it in a ref instead.

    * Fall back to text replace when a sidebar tag can't be located

    If the clicked tag could not be matched to a position in the field,
    the rename did nothing. Fall back to renaming the first matching tag,
    as before this branch. Also drop a clamp on the selection end that a
    DOM range can never reach.

    * Describe tag editing correctly in the changelog entry

diff --git a/packages/js/email-editor/changelog/fix-WOOPRD-3671-personalization-tag-insert-position b/packages/js/email-editor/changelog/fix-WOOPRD-3671-personalization-tag-insert-position
new file mode 100644
index 00000000000..bb0d82340ee
--- /dev/null
+++ b/packages/js/email-editor/changelog/fix-WOOPRD-3671-personalization-tag-insert-position
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Insert personalization tags at the cursor instead of the end of the text, and edit the tag you clicked, including in captions and button text.
diff --git a/packages/js/email-editor/src/blocks/core/rich-text.tsx b/packages/js/email-editor/src/blocks/core/rich-text.tsx
index 9bf3f9d68c3..6c0e2aa99a1 100644
--- a/packages/js/email-editor/src/blocks/core/rich-text.tsx
+++ b/packages/js/email-editor/src/blocks/core/rich-text.tsx
@@ -1,16 +1,15 @@
 /**
  * External dependencies
  */
-import {
-	applyFormat,
-	insert,
-	create,
-	toHTMLString,
-} from '@wordpress/rich-text';
+import { applyFormat } from '@wordpress/rich-text';
+import type {
+	RichTextValue,
+	RichTextFormat as WPFormat,
+} from '@wordpress/rich-text/build-types/types';
 import { __ } from '@wordpress/i18n';
 import { BlockControls } from '@wordpress/block-editor';
 import { ToolbarButton, ToolbarGroup } from '@wordpress/components';
-import { useSelect, useDispatch } from '@wordpress/data';
+import { useSelect } from '@wordpress/data';
 import { useCallback, useState } from '@wordpress/element';
 import { createHigherOrderComponent } from '@wordpress/compose';
 import * as React from '@wordpress/element';
@@ -19,7 +18,9 @@ import * as React from '@wordpress/element';
  * Internal dependencies
  */
 import {
-	getCursorPosition,
+	getElementRange,
+	renameTagComment,
+	insertPersonalizationTag,
 	replacePersonalizationTagsWithHTMLComments,
 } from '../../components/personalization-tags/rich-text-utils';
 import { PersonalizationTagsModal } from '../../components/personalization-tags/personalization-tags-modal';
@@ -51,8 +52,17 @@ function disableCertainRichTextFormats() {
 	} );
 }

+// The bundled RichText types omit these properties.
+type LooseFormat = {
+	type: string;
+	attributes?: Record< string, string >;
+	unregisteredAttributes?: Record< string, string >;
+};
+
 type Props = {
 	contentRef: React.RefObject< HTMLElement >;
+	value: RichTextValue;
+	onChange: ( value: RichTextValue ) => void;
 };

 /**
@@ -60,83 +70,15 @@ type Props = {
  *
  * @param root0
  * @param root0.contentRef
+ * @param root0.value
+ * @param root0.onChange
  */
-function PersonalizationTagsButton( { contentRef }: Props ) {
+function PersonalizationTagsButton( { contentRef, value, onChange }: Props ) {
 	const [ isModalOpened, setIsModalOpened ] = useState( false );
-	const selectedBlockId = useSelect( ( select ) =>
-		select( 'core/block-editor' ).getSelectedBlockClientId()
-	);
-
-	const { updateBlockAttributes } = useDispatch( 'core/block-editor' );
-
-	// Get the current block attributes
-	const blockAttributes: object = useSelect( ( select ) => {
-		const attributes =
-			select( 'core/block-editor' ).getBlockAttributes( selectedBlockId );
-
-		return attributes;
-	} );
-
-	// Some blocks, such as the Button block, store the content in `text` attribute.
-	const blockContentKey = 'text' in blockAttributes ? 'text' : 'content';
-
-	// After first saving the content does not have property originalHTML, so we need to check for content as well
-	const blockContent =
-		blockAttributes?.[ blockContentKey ]?.originalHTML ||
-		blockAttributes?.[ blockContentKey ] ||
-		'';
-
 	const handleInsert = useCallback(
-		( tag: string, linkText: string | null ) => {
-			let { start, end } = getCursorPosition( contentRef, blockContent );
-
-			let updatedContent = '';
-			// When we pass linkText, we want to insert the tag as a link
-			if ( linkText ) {
-				let richTextValue = create( { html: blockContent } );
-
-				// Insert the new text into the current selection or at the cursor
-				richTextValue = insert( richTextValue, linkText, start, end );
-
-				end = start + linkText.length;
-				// The link is inserted via registered format type to avoid breaking the content
-				richTextValue = applyFormat(
-					richTextValue,
-					{
-						type: 'woocommerce-email-editor/link-shortcode',
-						// @ts-expect-error attributes property is missing in build type for WPFormat type
-						attributes: {
-							'data-link-href': tag,
-							contenteditable: 'false',
-							style: 'text-decoration: underline;',
-						},
-					},
-					start,
-					end
-				);
-				updatedContent = toHTMLString( { value: richTextValue } );
-			} else {
-				let richTextValue = create( { html: blockContent } );
-				richTextValue = insert(
-					richTextValue,
-					create( { html: `<!--${ tag }-->&nbsp;` } ), // Add a non-breaking space to avoid an issue when WP renderer removes blog containing only a comment
-					start,
-					end
-				);
-				updatedContent = toHTMLString( { value: richTextValue } );
-			}
-
-			void updateBlockAttributes( selectedBlockId, {
-				[ blockContentKey ]: updatedContent,
-			} );
-		},
-		[
-			blockContent,
-			blockContentKey,
-			contentRef,
-			selectedBlockId,
-			updateBlockAttributes,
-		]
+		( tag: string, linkText: string | null ) =>
+			onChange( insertPersonalizationTag( value, tag, linkText ) ),
+		[ value, onChange ]
 	);

 	return (
@@ -154,45 +96,73 @@ function PersonalizationTagsButton( { contentRef }: Props ) {
 				/>
 				<PersonalizationTagsPopover
 					contentRef={ contentRef }
-					onUpdate={ ( originalTag, updatedTag ) => {
-						// When we update the tag, we need to add brackets to the tag, because the popover removes them
-						const updatedContent = blockContent.replace(
-							`<!--[${ originalTag }]-->`,
-							`<!--[${ updatedTag }]-->`
-						);
-						void updateBlockAttributes( selectedBlockId, {
-							[ blockContentKey ]: updatedContent,
-						} );
+					onUpdate={ ( _originalTag, updatedTag, anchor ) => {
+						const updated =
+							anchor &&
+							renameTagComment(
+								value,
+								getElementRange( contentRef.current, anchor ),
+								updatedTag
+							);
+						if ( updated ) {
+							onChange( updated );
+						}
 					} }
 				/>
 				<PersonalizationTagsLinkPopover
 					contentRef={ contentRef }
-					onUpdate={ ( htmlElement, newTag, newText ) => {
-						const oldTag = htmlElement
-							.getAttribute( 'data-link-href' )
-							.replace( /[.*+?^${}()|[\]\\]/g, '\\$&' );
-						const regex = new RegExp(
-							`<a([^>]*?)data-link-href="${ oldTag }"([^>]*?)>${ htmlElement.textContent }</a>`,
-							'gi'
+					onUpdate={ ( linkElement, newTag, newText ) => {
+						const range = getElementRange(
+							contentRef.current,
+							linkElement,
+							true
 						);
-
-						// Replace the matched link with the new link
-						const updatedContent = blockContent.replace(
-							regex,
-							( _, beforeAttrs, afterAttrs ) => {
-								// Construct the new <a> tag
-								return `<a${ beforeAttrs }data-link-href="${ newTag }"${ afterAttrs }>${ newText }</a>`;
-							}
+						const formats = ( ( range &&
+							value.formats[ range.start ] ) ||
+							[] ) as LooseFormat[];
+						const isLinkFormat = ( format: LooseFormat ) =>
+							format.attributes?.[ 'data-link-href' ] !==
+								undefined ||
+							format.unregisteredAttributes?.[
+								'data-link-href'
+							] !== undefined;
+						if ( ! range || ! formats.some( isLinkFormat ) ) {
+							return;
+						}
+						// Never turn the link into a plain tag: empty text keeps the old text.
+						const text = newText.trim()
+							? newText
+							: value.text.slice( range.start, range.end );
+						let result = insertPersonalizationTag(
+							{
+								...value,
+								start: range.start,
+								end: range.end,
+							},
+							newTag,
+							text
 						);
-						void updateBlockAttributes( selectedBlockId, {
-							content: updatedContent,
-						} );
+						// Re-apply the formatting that surrounded the old link.
+						formats
+							.filter( ( format ) => ! isLinkFormat( format ) )
+							.forEach( ( format ) => {
+								result = {
+									...applyFormat(
+										result,
+										format as unknown as WPFormat,
+										range.start,
+										range.start + text.length
+									),
+									activeFormats: [],
+								} as RichTextValue;
+							} );
+						onChange( result );
 					} }
 				/>
 				<PersonalizationTagsModal
 					isOpened={ isModalOpened }
-					onInsert={ ( value, linkText ) => {
-						handleInsert( value, linkText );
+					onInsert={ ( tag, linkText ) => {
+						handleInsert( tag, linkText );
 						setIsModalOpened( false );
 					} }
 					closeCallback={ () => setIsModalOpened( false ) }
diff --git a/packages/js/email-editor/src/blocks/core/test/rich-text.spec.tsx b/packages/js/email-editor/src/blocks/core/test/rich-text.spec.tsx
index bab03b3056e..64968d1f92c 100644
--- a/packages/js/email-editor/src/blocks/core/test/rich-text.spec.tsx
+++ b/packages/js/email-editor/src/blocks/core/test/rich-text.spec.tsx
@@ -1,23 +1,53 @@
-import '../../../components/test/__mocks__/setup-shared-mocks';
+/* global HTMLElement */
+import * as React from 'react';

 /**
  * External dependencies
  */
-import { registerFormatType } from '@wordpress/rich-text';
+import { render, screen, fireEvent } from '@testing-library/react';
+import { create, registerFormatType, RichTextData } from '@wordpress/rich-text';
+import type { RichTextValue } from '@wordpress/rich-text/build-types/types';
+import { useSelect, useDispatch } from '@wordpress/data';

 /**
  * Internal dependencies
  */
 import { extendRichTextFormats } from '../rich-text';

+let capturedTagPopover: {
+	onUpdate: ( o: string, u: string, anchor?: HTMLElement ) => void;
+};
+let capturedLinkPopover: {
+	onUpdate: ( el: HTMLElement, tag: string, text: string ) => void;
+};
+
 jest.mock( '@wordpress/rich-text', () => ( {
+	...jest.requireActual( '@wordpress/rich-text' ),
 	registerFormatType: jest.fn(),
 	unregisterFormatType: jest.fn(),
 } ) );

+jest.mock( '@wordpress/data', () => ( {
+	...jest.requireActual( '@wordpress/data' ),
+	useSelect: jest.fn(),
+	useDispatch: jest.fn(),
+} ) );
+
+jest.mock( '@wordpress/block-editor', () => ( {
+	store: {},
+	privateApis: {},
+	BlockControls: ( { children }: { children: React.ReactNode } ) => children,
+} ) );
+
 jest.mock( '@wordpress/components', () => ( {
-	ToolbarButton: () => null,
-	ToolbarGroup: () => null,
+	ToolbarButton: ( {
+		onClick,
+		title,
+	}: {
+		onClick: () => void;
+		title: string;
+	} ) => <button onClick={ onClick }>{ title }</button>,
+	ToolbarGroup: ( { children }: { children: React.ReactNode } ) => children,
 } ) );

 jest.mock( '../../../store', () => ( {
@@ -31,21 +61,57 @@ jest.mock( '../../../events', () => ( {
 jest.mock(
 	'../../../components/personalization-tags/personalization-tags-modal',
 	() => ( {
-		PersonalizationTagsModal: () => null,
+		PersonalizationTagsModal: ( {
+			isOpened,
+			onInsert,
+		}: {
+			isOpened: boolean;
+			onInsert: ( tag: string, linkText: string | null ) => void;
+		} ) =>
+			isOpened ? (
+				<>
+					<button
+						onClick={ () =>
+							onInsert(
+								'[woocommerce/customer-first-name]',
+								null
+							)
+						}
+					>
+						insert-tag
+					</button>
+					<button
+						onClick={ () =>
+							onInsert(
+								'[woocommerce/customer-first-name]',
+								'Jane'
+							)
+						}
+					>
+						insert-link
+					</button>
+				</>
+			) : null,
 	} )
 );

 jest.mock(
 	'../../../components/personalization-tags/personalization-tags-popover',
 	() => ( {
-		PersonalizationTagsPopover: () => null,
+		PersonalizationTagsPopover: ( props: unknown ) => {
+			capturedTagPopover = props as typeof capturedTagPopover;
+			return null;
+		},
 	} )
 );

 jest.mock(
 	'../../../components/personalization-tags/personalization-tags-link-popover',
 	() => ( {
-		PersonalizationTagsLinkPopover: () => null,
+		PersonalizationTagsLinkPopover: ( props: unknown ) => {
+			capturedLinkPopover = props as typeof capturedLinkPopover;
+			return null;
+		},
 	} )
 );

@@ -80,3 +146,477 @@ describe( 'extendRichTextFormats', () => {
 		);
 	} );
 } );
+
+type EditProps = {
+	contentRef: { current: unknown };
+	value: RichTextValue;
+	onChange: ( value: RichTextValue ) => void;
+};
+
+describe( 'PersonalizationTagsButton insertion', () => {
+	const TAG = '[woocommerce/customer-first-name]';
+	const updateBlockAttributes = jest.fn();
+	let Edit: React.ComponentType< EditProps >;
+
+	const valueFromHTML = ( html: string, start?: number, end?: number ) => {
+		const p = document.createElement( 'p' );
+		p.innerHTML = html;
+		const data = RichTextData.fromHTMLElement( p );
+		return {
+			data,
+			value: {
+				text: data.text,
+				formats: data.formats,
+				replacements: data.replacements,
+				start,
+				end,
+			},
+		};
+	};
+
+	const setup = (
+		attributes: object,
+		value: RichTextValue,
+		onChange = jest.fn()
+	) => {
+		( useSelect as jest.Mock ).mockImplementation( () => attributes );
+		( useDispatch as jest.Mock ).mockReturnValue( {
+			updateBlockAttributes,
+		} );
+		render(
+			<Edit
+				contentRef={ { current: document.createElement( 'p' ) } }
+				value={ value }
+				onChange={ onChange }
+			/>
+		);
+		return onChange;
+	};
+
+	const clickInsert = ( which: 'insert-tag' | 'insert-link' ) => {
+		fireEvent.click( screen.getByText( 'Personalization Tags' ) );
+		fireEvent.click( screen.getByText( which ) );
+	};
+
+	beforeEach( () => {
+		updateBlockAttributes.mockClear();
+		registerFormatTypeMock.mockClear();
+		extendRichTextFormats();
+		Edit = registerFormatTypeMock.mock.calls.find(
+			( c ) => c[ 0 ] === 'woocommerce-email-editor/shortcode'
+		)[ 1 ].edit;
+	} );
+
+	it( 'inserts at the caret in whitespace-padded content', () => {
+		const { data, value } = valueFromHTML( ' We’d love ', 4, 4 );
+		const onChange = setup(
+			{ content: { originalHTML: ' We’d love ', toString: () => data } },
+			value
+		);
+		clickInsert( 'insert-tag' );
+		expect( onChange ).toHaveBeenCalledTimes( 1 );
+		const result = onChange.mock.calls[ 0 ][ 0 ];
+		expect( result.text ).toBe( 'We’d\ufffc\u00a0 love ' );
+		expect( result.start ).toBe( 6 );
+		expect( result.end ).toBe( 6 );
+		expect( result.activeFormats ).toEqual( [] );
+		expect( updateBlockAttributes ).not.toHaveBeenCalled();
+	} );
+
+	it( 'writes through onChange for captions', () => {
+		const { value } = valueFromHTML( 'Caption', 3, 3 );
+		const onChange = setup( { url: 'x', caption: 'Caption' }, value );
+		clickInsert( 'insert-tag' );
+		expect( onChange ).toHaveBeenCalledTimes( 1 );
+		expect( updateBlockAttributes ).not.toHaveBeenCalled();
+	} );
+
+	it( 'writes through onChange for the Button block', () => {
+		const { value } = valueFromHTML( 'Buy', 1, 1 );
+		const onChange = setup( { text: 'Buy' }, value );
+		clickInsert( 'insert-tag' );
+		expect( onChange ).toHaveBeenCalledTimes( 1 );
+		expect( updateBlockAttributes ).not.toHaveBeenCalled();
+	} );
+
+	it( 'inserts a link at the caret after bold text', () => {
+		const { value } = valueFromHTML( '<strong>Hi</strong> there', 3, 3 );
+		const onChange = setup( { content: '' }, value );
+		clickInsert( 'insert-link' );
+		const result = onChange.mock.calls[ 0 ][ 0 ];
+		expect( result.text ).toBe( 'Hi Janethere' );
+		const linked = result.formats.map(
+			( formats: Array< { type: string; attributes: object } > ) =>
+				( formats || [] ).find(
+					( f ) =>
+						f.type === 'woocommerce-email-editor/link-shortcode'
+				)?.attributes[ 'data-link-href' ]
+		);
+		expect( linked ).toEqual(
+			[
+				undefined,
+				undefined,
+				undefined,
+				TAG,
+				TAG,
+				TAG,
+				TAG,
+				undefined,
+			].slice( 0, result.formats.length )
+		);
+	} );
+
+	it( 'appends at the end without a caret', () => {
+		const { value } = valueFromHTML( 'Hello' );
+		const onChange = setup( { content: 'Hello' }, value );
+		clickInsert( 'insert-tag' );
+		expect( onChange.mock.calls[ 0 ][ 0 ].text ).toBe(
+			'Hello\ufffc\u00a0'
+		);
+	} );
+} );
+
+describe( 'PersonalizationTagsButton editing', () => {
+	const TAG_SPAN = ( tag: string ) =>
+		`<span contenteditable="false" data-rich-text-comment="${ tag }"><span>${ tag }</span></span>`;
+	const FIRST = '[woocommerce/customer-first-name]';
+	const LAST = '[woocommerce/customer-last-name]';
+	const updateBlockAttributes = jest.fn();
+	let Edit: React.ComponentType< EditProps >;
+	let p: HTMLElement;
+
+	const mountValue = ( html: string ) => {
+		p = document.createElement( 'p' );
+		p.innerHTML = html;
+		document.body.appendChild( p );
+		return create( {
+			element: p,
+			__unstableIsEditableTree: true,
+		} ) as unknown as RichTextValue;
+	};
+
+	const setup = (
+		attributes: object,
+		value: RichTextValue,
+		container: unknown = p
+	) => {
+		const onChange = jest.fn();
+		( useSelect as jest.Mock ).mockImplementation( () => attributes );
+		( useDispatch as jest.Mock ).mockReturnValue( {
+			updateBlockAttributes,
+		} );
+		render(
+			<Edit
+				contentRef={ { current: container } }
+				value={ value }
+				onChange={ onChange }
+			/>
+		);
+		return onChange;
+	};
+
+	const twoTags = () =>
+		`Hi ${ TAG_SPAN( FIRST ) }&nbsp;and ${ TAG_SPAN( FIRST ) } end`;
+	const tagOf = ( value: RichTextValue, i: number ) =>
+		(
+			value.replacements[ i ] as unknown as {
+				attributes: Record< string, string >;
+			}
+		 )?.attributes[ 'data-rich-text-comment' ];
+
+	beforeEach( () => {
+		updateBlockAttributes.mockClear();
+		registerFormatTypeMock.mockClear();
+		extendRichTextFormats();
+		Edit = registerFormatTypeMock.mock.calls.find(
+			( c ) => c[ 0 ] === 'woocommerce-email-editor/shortcode'
+		)[ 1 ].edit;
+	} );
+
+	afterEach( () => {
+		p?.remove();
+		document.body.innerHTML = '';
+	} );
+
+	it( 'edits the clicked tag when identical tags exist', () => {
+		const value = mountValue( twoTags() );
+		const snapshot = value.replacements.slice();
+		const snapshotObjects = JSON.stringify( value.replacements );
+		const onChange = setup( { content: 'x' }, value );
+		const second = p.querySelectorAll< HTMLElement >(
+			'[data-rich-text-comment]'
+		)[ 1 ];
+		capturedTagPopover.onUpdate(
+			'woocommerce/customer-first-name',
+			'woocommerce/customer-last-name',
+			second
+		);
+		expect( onChange ).toHaveBeenCalledTimes( 1 );
+		const result = onChange.mock.calls[ 0 ][ 0 ];
+		const idx = [ ...value.text ]
+			.map( ( c, i ) => ( c === '\ufffc' ? i : -1 ) )
+			.filter( ( i ) => i >= 0 );
+		expect( tagOf( result, idx[ 0 ] ) ).toBe( FIRST );
+		expect( tagOf( result, idx[ 1 ] ) ).toBe( LAST );
+		expect( result.text ).toBe( value.text );
+		expect( result.activeFormats ).toEqual( [] );
+		expect( value.replacements ).toEqual( snapshot );
+		value.replacements.forEach( ( r, i ) => {
+			expect( r ).toBe( snapshot[ i ] );
+		} );
+		expect( JSON.stringify( value.replacements ) ).toBe( snapshotObjects );
+		expect( updateBlockAttributes ).not.toHaveBeenCalled();
+	} );
+
+	it( 'edits a tag in a caption field through onChange', () => {
+		const value = mountValue( `Cap ${ TAG_SPAN( FIRST ) }` );
+		const onChange = setup( { url: 'x', caption: 'Cap' }, value );
+		capturedTagPopover.onUpdate(
+			'a',
+			'woocommerce/customer-last-name',
+			p.querySelector< HTMLElement >( '[data-rich-text-comment]' )
+		);
+		expect( onChange ).toHaveBeenCalledTimes( 1 );
+		expect( updateBlockAttributes ).not.toHaveBeenCalled();
+	} );
+
+	it( 'edits a tag in whitespace-collapsed content', () => {
+		const source = document.createElement( 'p' );
+		source.innerHTML = ' Hi <!--[woocommerce/customer-first-name]--> ';
+		const data = RichTextData.fromHTMLElement( source );
+		const value = {
+			text: data.text,
+			formats: data.formats,
+			replacements: data.replacements,
+		} as RichTextValue;
+		p = document.createElement( 'p' );
+		p.innerHTML = `Hi ${ TAG_SPAN( FIRST ) }`;
+		document.body.appendChild( p );
+		const onChange = setup( { content: ' Hi ' }, value );
+		capturedTagPopover.onUpdate(
+			'a',
+			'woocommerce/customer-last-name',
+			p.querySelector< HTMLElement >( '[data-rich-text-comment]' )
+		);
+		expect( onChange ).toHaveBeenCalledTimes( 1 );
+		const result = onChange.mock.calls[ 0 ][ 0 ];
+		expect( result.text ).toBe( data.text );
+		expect( tagOf( result, data.text.indexOf( '\ufffc' ) ) ).toBe( LAST );
+		expect( updateBlockAttributes ).not.toHaveBeenCalled();
+	} );
+
+	it( 'ignores stale anchors and non-comment positions', () => {
+		const value = mountValue( `Hi ${ TAG_SPAN( FIRST ) } <b>bold</b>` );
+		const onChange = setup( { content: 'x' }, value );
+		const detached = document.createElement( 'span' );
+		expect( () =>
+			capturedTagPopover.onUpdate( 'a', 'b', detached )
+		).not.toThrow();
+		capturedTagPopover.onUpdate(
+			'a',
+			'b',
+			p.querySelector< HTMLElement >( 'b' )
+		);
+		expect( onChange ).not.toHaveBeenCalled();
+	} );
+
+	it( 'ignores a single-character replacement that is not a tag', () => {
+		const value = mountValue( `Hi <img src="x.png"> there` );
+		const onChange = setup( { content: 'x' }, value );
+		capturedTagPopover.onUpdate(
+			'a',
+			'b',
+			p.querySelector< HTMLElement >( 'img' )
+		);
+		expect( onChange ).not.toHaveBeenCalled();
+	} );
+
+	describe( 'link editing', () => {
+		const LINK = '[woocommerce/shop-url]';
+		const NEW = '[woocommerce/my-account-url]';
+		const linkFormats = ( result: RichTextValue ) =>
+			result.formats.map( ( f ) =>
+				(
+					( f || [] ) as unknown as Array< {
+						type: string;
+						attributes?: Record< string, string >;
+						unregisteredAttributes?: Record< string, string >;
+					} >
+				 ).map( ( x ) => x.type )
+			);
+
+		const expectUpdated = ( result: RichTextValue ) => {
+			expect( result.text ).toBe( 'Go to my account now' );
+			const types = linkFormats( result );
+			expect( types.flat() ).toEqual(
+				Array( 13 ).fill( 'woocommerce-email-editor/link-shortcode' )
+			);
+			for ( let i = 0; i < result.formats.length; i++ ) {
+				const f = ( result.formats[ i ] || [] ) as unknown as Array< {
+					attributes: Record< string, string >;
+				} >;
+				expect( f.length ).toBe( i >= 3 && i < 16 ? 1 : 0 );
+				if ( f.length ) {
+					expect( f[ 0 ].attributes[ 'data-link-href' ] ).toBe( NEW );
+				}
+			}
+			expect( result.activeFormats ).toEqual( [] );
+		};
+
+		afterEach( () => {
+			(
+				jest.requireActual( '@wordpress/rich-text' ) as {
+					unregisterFormatType: ( n: string ) => void;
+				}
+			 ).unregisterFormatType( 'core/link' );
+		} );
+
+		it.each( [ [ true ], [ false ] ] )(
+			'edits a class-less template link (core/link registered: %s)',
+			( withCoreLink ) => {
+				if ( withCoreLink ) {
+					(
+						jest.requireActual( '@wordpress/rich-text' ) as {
+							registerFormatType: (
+								n: string,
+								s: object
+							) => void;
+						}
+					 ).registerFormatType( 'core/link', {
+						name: 'core/link',
+						title: 'Link',
+						tagName: 'a',
+						className: null,
+						attributes: { url: 'href', target: 'target' },
+						edit: () => null,
+					} );
+				}
+				const value = mountValue(
+					`Go <a data-link-href="${ LINK }" contenteditable="false" style="text-decoration: underline;">here</a> now`
+				);
+				expect(
+					JSON.stringify( value.formats[ 3 ] ).includes(
+						'unregisteredAttributes'
+					)
+				).toBe( withCoreLink );
+				const onChange = setup( { content: 'x' }, value );
+				capturedLinkPopover.onUpdate(
+					p.querySelector< HTMLElement >( 'a' ),
+					NEW,
+					'to my account'
+				);
+				expect( onChange ).toHaveBeenCalledTimes( 1 );
+				expectUpdated( onChange.mock.calls[ 0 ][ 0 ] );
+				expect( updateBlockAttributes ).not.toHaveBeenCalled();
+			}
+		);
+
+		it( 'edits a link created by insertPersonalizationTag', () => {
+			const { insertPersonalizationTag } = jest.requireActual(
+				'../../../components/personalization-tags/rich-text-utils'
+			);
+			const base = create( { html: 'Go  now' } ) as RichTextValue;
+			const linked = insertPersonalizationTag(
+				{ ...base, start: 3, end: 3 },
+				LINK,
+				'here'
+			) as RichTextValue;
+			p = document.createElement( 'p' );
+			p.innerHTML = `Go <a data-link-href="${ LINK }" contenteditable="false" style="text-decoration: underline;">here</a> now`;
+			document.body.appendChild( p );
+			const onChange = setup( { content: 'x' }, linked );
+			capturedLinkPopover.onUpdate(
+				p.querySelector< HTMLElement >( 'a' ),
+				NEW,
+				'to my account'
+			);
+			expect( onChange ).toHaveBeenCalledTimes( 1 );
+			expectUpdated( onChange.mock.calls[ 0 ][ 0 ] );
+		} );
+
+		it( 'edits a link on the Button block through onChange', () => {
+			const value = mountValue(
+				`Go <a data-link-href="${ LINK }" contenteditable="false">here</a> now`
+			);
+			const onChange = setup( { text: 'x' }, value );
+			capturedLinkPopover.onUpdate(
+				p.querySelector< HTMLElement >( 'a' ),
+				NEW,
+				'to my account'
+			);
+			expect( onChange ).toHaveBeenCalledTimes( 1 );
+			expect( updateBlockAttributes ).not.toHaveBeenCalled();
+		} );
+
+		it( 'keeps the link text when the new text is empty', () => {
+			const value = mountValue(
+				`Go <a data-link-href="${ LINK }" contenteditable="false">here</a> now`
+			);
+			const onChange = setup( { content: 'x' }, value );
+			capturedLinkPopover.onUpdate(
+				p.querySelector< HTMLElement >( 'a' ),
+				NEW,
+				'  '
+			);
+			const result = onChange.mock.calls[ 0 ][ 0 ];
+			expect( result.text ).toBe( 'Go here now' );
+			for ( let i = 3; i < 7; i++ ) {
+				const f = result.formats[ i ] as unknown as Array< {
+					attributes: Record< string, string >;
+				} >;
+				expect( f[ 0 ].attributes[ 'data-link-href' ] ).toBe( NEW );
+			}
+		} );
+
+		it.each( [ [ 'to my account' ], [ 'here' ] ] )(
+			'keeps surrounding bold when editing a link (text: %s)',
+			( text ) => {
+				const value = mountValue(
+					`<strong>Visit <a data-link-href="${ LINK }" contenteditable="false">our shop</a></strong> now`
+				);
+				const snapshot = JSON.stringify( value.formats );
+				const onChange = setup( { content: 'x' }, value );
+				capturedLinkPopover.onUpdate(
+					p.querySelector< HTMLElement >( 'a' ),
+					NEW,
+					text
+				);
+				const result = onChange.mock.calls[ 0 ][ 0 ];
+				expect( result.text ).toBe( `Visit ${ text } now` );
+				for ( let i = 0; i < 6 + text.length; i++ ) {
+					const types = (
+						( result.formats[ i ] || [] ) as unknown as Array< {
+							type: string;
+						} >
+					 ).map( ( f ) => f.type );
+					expect( types ).toContain( 'strong' );
+					expect(
+						types.includes(
+							'woocommerce-email-editor/link-shortcode'
+						)
+					).toBe( i >= 6 );
+				}
+				expect( result.formats[ 6 + text.length ] ).toBeUndefined();
+				expect( JSON.stringify( value.formats ) ).toBe( snapshot );
+			}
+		);
+
+		it( 'ignores stale link anchors', () => {
+			const value = mountValue( 'Go <b>here</b> now' );
+			const onChange = setup( { content: 'x' }, value );
+			expect( () =>
+				capturedLinkPopover.onUpdate(
+					document.createElement( 'a' ),
+					NEW,
+					'x'
+				)
+			).not.toThrow();
+			capturedLinkPopover.onUpdate(
+				p.querySelector< HTMLElement >( 'b' ),
+				NEW,
+				'x'
+			);
+			expect( onChange ).not.toHaveBeenCalled();
+		} );
+	} );
+} );
diff --git a/packages/js/email-editor/src/components/personalization-tags/personalization-tags-popover.tsx b/packages/js/email-editor/src/components/personalization-tags/personalization-tags-popover.tsx
index 01571bdaa17..aac679f1500 100644
--- a/packages/js/email-editor/src/components/personalization-tags/personalization-tags-popover.tsx
+++ b/packages/js/email-editor/src/components/personalization-tags/personalization-tags-popover.tsx
@@ -7,7 +7,11 @@ import { __ } from '@wordpress/i18n';

 type PersonalizationTagsPopoverProps = {
 	contentRef: React.RefObject< HTMLElement >;
-	onUpdate: ( originalValue: string, updatedValue: string ) => void;
+	onUpdate: (
+		originalValue: string,
+		updatedValue: string,
+		anchor?: HTMLElement
+	) => void;
 };

 /**
@@ -95,7 +99,11 @@ const PersonalizationTagsPopover = ( {
 							<Button
 								isPrimary
 								onClick={ () => {
-									onUpdate( originalValue, updatedValue );
+									onUpdate(
+										originalValue,
+										updatedValue,
+										anchor
+									);
 									setIsPopoverVisible( false );
 								} }
 							>
diff --git a/packages/js/email-editor/src/components/personalization-tags/rich-text-utils.ts b/packages/js/email-editor/src/components/personalization-tags/rich-text-utils.ts
index ac751a6e887..8329b0e1cff 100644
--- a/packages/js/email-editor/src/components/personalization-tags/rich-text-utils.ts
+++ b/packages/js/email-editor/src/components/personalization-tags/rich-text-utils.ts
@@ -2,155 +2,133 @@
  * External dependencies
  */
 import * as React from '@wordpress/element';
-import { create } from '@wordpress/rich-text';
-import { RichTextFormatList } from '@wordpress/rich-text/build-types/types';
+import { applyFormat, create, insert } from '@wordpress/rich-text';
+import type { RichTextValue } from '@wordpress/rich-text/build-types/types';

 /**
  * Internal dependencies
  */
 import { PersonalizationTag } from '../../store';

-type Replacement = {
-	attributes: Record< string, string >;
-	type: string;
-};
-
-function getChildElement( rootElement: HTMLElement ): HTMLElement | null {
-	let currentElement: HTMLElement | null = rootElement;
+/**
+ * Read the selection offsets of a RichText field from its live editable DOM.
+ * Returns null when the selection is not in the field.
+ *
+ * @param richTextRef - Reference to the editable element.
+ */
+const getCursorPosition = (
+	richTextRef: React.RefObject< HTMLElement >
+): { start: number; end: number } | null => {
+	const element = richTextRef.current;
+	const selection = element?.ownerDocument.defaultView?.getSelection();

-	while ( currentElement && currentElement?.children?.length > 0 ) {
-		// Traverse into the first child element
-		currentElement = currentElement.children[ 0 ] as HTMLElement;
+	if ( ! element || ! selection?.rangeCount ) {
+		return null;
 	}

-	return currentElement;
-}
+	const range = selection.getRangeAt( 0 ).cloneRange();

-function findReplacementIndex(
-	element: HTMLElement,
-	replacements: ( null | Replacement )[]
-): number | null {
-	// Iterate over the replacements array
-	for ( const [ index, replacement ] of replacements.entries() ) {
-		if ( ! replacement ) {
-			continue;
-		}
+	if ( ! range.intersectsNode( element ) ) {
+		return null;
+	}

-		const { attributes } = replacement;
+	if ( ! element.contains( range.endContainer ) ) {
+		range.setEnd( element, element.childNodes.length );
+	}

-		if (
-			element.getAttribute( 'data-rich-text-comment' ) ===
-			attributes[ 'data-rich-text-comment' ]
-		) {
-			return index;
-		}
+	// A caret inside a tag comment counts as right after it.
+	const getCommentSpan = ( container: Node ) => {
+		const node =
+			container.nodeType === container.TEXT_NODE
+				? container.parentElement
+				: ( container as Element );
+		const span = node?.closest( '[data-rich-text-comment]' );
+		return span && element.contains( span ) ? span : null;
+	};
+	const startSpan = getCommentSpan( range.startContainer );
+	if ( startSpan ) {
+		range.setStartAfter( startSpan );
+	}
+	const endSpan = getCommentSpan( range.endContainer );
+	if ( endSpan ) {
+		range.setEndAfter( endSpan );
 	}

-	return null; // Return null if no match is found
-}
+	const { start, end } = create( {
+		element,
+		range,
+		__unstableIsEditableTree: true,
+	} );
+
+	return {
+		start: start ?? 0,
+		end: end ?? start ?? 0,
+	};
+};

 /**
- * Find the latest index of the format that matches the element.
+ * Resolve a DOM element inside a RichText container to its offsets in the RichText value.
  *
- * @param element
- * @param formats
+ * @param container    The editable element.
+ * @param element      The element to locate.
+ * @param contentsOnly Only select the element's contents.
  */
-function findLatestFormatIndex(
+const getElementRange = (
+	container: HTMLElement | null | undefined,
 	element: HTMLElement,
-	formats: RichTextFormatList[]
-): number | null {
-	let latestFormatIndex = null;
-
-	for ( const [ index, formatList ] of formats.entries() ) {
-		if ( ! formatList ) {
-			continue;
-		}
-
-		// Check each format within the format list at the current index
-		for ( const format of formatList ) {
-			if (
-				// @ts-expect-error attributes property is missing in build type for RichTextFormatList type
-				format?.attributes &&
-				element.tagName.toLowerCase() ===
-					// @ts-expect-error tagName property is missing in build type for RichTextFormatList type
-					format.tagName?.toLowerCase() &&
-				element.getAttribute( 'data-link-href' ) ===
-					// @ts-expect-error attributes property is missing in build type for RichTextFormatList type
-					format?.attributes[ 'data-link-href' ]
-			) {
-				latestFormatIndex = index;
-			}
-		}
+	contentsOnly = false
+): { start: number; end: number } | null => {
+	if ( ! container?.contains( element ) ) {
+		return null;
 	}
-
-	return latestFormatIndex;
-}
+	const range = container.ownerDocument.createRange();
+	if ( contentsOnly ) {
+		range.selectNodeContents( element );
+	} else {
+		range.selectNode( element );
+	}
+	const { start, end } = create( {
+		element: container,
+		range,
+		__unstableIsEditableTree: true,
+	} );
+	return start === undefined || end === undefined ? null : { start, end };
+};

 /**
- * Retrieves the cursor position within a RichText component.
- * Calculates the offset in plain text while accounting for HTML tags and comments.
+ * Rename the tag comment at the given range of a RichText value.
+ * Returns null when the range is not a single tag comment.
  *
- * @param {React.RefObject<HTMLElement>} richTextRef - Reference to the RichText component.
- * @param {string}                       content     - The plain text content of the block.
- * @return {{ start: number, end: number } | null} - The cursor position as start and end offsets.
+ * @param value      The RichText value.
+ * @param range      Offsets of the tag, from getElementRange.
+ * @param updatedTag The new tag, without brackets.
  */
-const getCursorPosition = (
-	richTextRef: React.RefObject< HTMLElement >,
-	content: string
-): { start: number; end: number } => {
-	const selection =
-		richTextRef.current.ownerDocument.defaultView.getSelection();
-
-	if ( ! selection.rangeCount ) {
-		return {
-			start: 0,
-			end: 0,
-		};
-	}
-
-	const range = selection.getRangeAt( 0 );
-
-	if ( selection.anchorNode.previousSibling === null ) {
-		return {
-			start: selection.anchorOffset,
-			end: selection.anchorOffset + range.toString().length,
-		};
-	}
-
-	const richTextValue = create( { html: content } );
-	let previousSibling = selection.anchorNode.previousSibling as HTMLElement;
-	previousSibling = getChildElement( previousSibling );
-
-	const formatIndex = findLatestFormatIndex(
-		previousSibling,
-		richTextValue.formats
-	);
-	if ( formatIndex !== null ) {
-		return {
-			start: formatIndex + selection.anchorOffset + 1, // We need to add 1 for the format length
-			end: formatIndex + selection.anchorOffset + range.toString().length,
-		};
-	}
-
-	const replacementIndex = findReplacementIndex(
-		previousSibling,
-		richTextValue.replacements as Replacement[]
-	);
-	if ( replacementIndex !== null ) {
-		return {
-			start: replacementIndex + selection.anchorOffset + 1, // We need to add 1 for the replacement length
-			end:
-				replacementIndex +
-				selection.anchorOffset +
-				range.toString().length,
-		};
+const renameTagComment = (
+	value: RichTextValue,
+	range: { start: number; end: number } | null,
+	updatedTag: string
+): RichTextValue | null => {
+	const replacement = ( range && value.replacements[ range.start ] ) as
+		{ type: string; attributes?: Record< string, string > } | undefined;
+	if (
+		! range ||
+		! replacement ||
+		range.end - range.start !== 1 ||
+		value.text[ range.start ] !== '\ufffc' ||
+		replacement.type !== '#comment'
+	) {
+		return null;
 	}
-
-	// fallback for placing the value at the end of the rich text
-	return {
-		start: richTextValue.text.length,
-		end: richTextValue.text.length + range.toString().length,
-	};
+	const replacements = value.replacements.slice();
+	replacements[ range.start ] = {
+		...replacement,
+		attributes: {
+			...replacement.attributes,
+			// The popover removes the brackets, so add them back.
+			'data-rich-text-comment': `[${ updatedTag }]`,
+		},
+	} as ( typeof replacements )[ number ];
+	return { ...value, replacements, activeFormats: [] } as RichTextValue;
 };

 /**
@@ -188,4 +166,59 @@ const replacePersonalizationTagsWithHTMLComments = (
 	return content;
 };

-export { getCursorPosition, replacePersonalizationTagsWithHTMLComments };
+/**
+ * Insert a personalization tag (or a tag link when linkText is set) into a RichText value
+ * at its selection, or at the end when there is no selection.
+ *
+ * @param value
+ * @param tag
+ * @param linkText
+ */
+const insertPersonalizationTag = (
+	value: RichTextValue,
+	tag: string,
+	linkText?: string | null
+): RichTextValue => {
+	const start = value.start ?? value.text.length;
+	const end = value.end ?? start;
+
+	if ( ! linkText ) {
+		return {
+			// Non-breaking space avoids the WP renderer removing a block containing only a comment
+			...insert(
+				value,
+				create( { html: `<!--${ tag }-->&nbsp;` } ),
+				start,
+				end
+			),
+			activeFormats: [],
+		} as RichTextValue;
+	}
+
+	const inserted = insert( value, linkText, start, end );
+	return {
+		...applyFormat(
+			inserted,
+			{
+				type: 'woocommerce-email-editor/link-shortcode',
+				// @ts-expect-error attributes property is missing in build type for WPFormat type
+				attributes: {
+					'data-link-href': tag,
+					contenteditable: 'false',
+					style: 'text-decoration: underline;',
+				},
+			},
+			start,
+			start + linkText.length
+		),
+		activeFormats: [],
+	} as RichTextValue;
+};
+
+export {
+	getCursorPosition,
+	getElementRange,
+	insertPersonalizationTag,
+	renameTagComment,
+	replacePersonalizationTagsWithHTMLComments,
+};
diff --git a/packages/js/email-editor/src/components/personalization-tags/rich-text-with-button.tsx b/packages/js/email-editor/src/components/personalization-tags/rich-text-with-button.tsx
index 0240562ceab..ad543681c05 100644
--- a/packages/js/email-editor/src/components/personalization-tags/rich-text-with-button.tsx
+++ b/packages/js/email-editor/src/components/personalization-tags/rich-text-with-button.tsx
@@ -4,7 +4,7 @@
 import type { ReactNode } from 'react';
 import { BaseControl, Button } from '@wordpress/components';
 import { useSelect } from '@wordpress/data';
-import { useCallback, useRef, useState } from '@wordpress/element';
+import { useCallback, useEffect, useRef, useState } from '@wordpress/element';
 import { create, insert, toHTMLString } from '@wordpress/rich-text';
 import { RichText } from '@wordpress/block-editor';
 import { __ } from '@wordpress/i18n';
@@ -15,6 +15,8 @@ import { __ } from '@wordpress/i18n';
 import { PersonalizationTagsModal } from './personalization-tags-modal';
 import {
 	getCursorPosition,
+	getElementRange,
+	renameTagComment,
 	replacePersonalizationTagsWithHTMLComments,
 } from './rich-text-utils';
 import { storeName } from '../../store';
@@ -43,7 +45,9 @@ export function RichTextWithButton( {
 	attributeValue,
 	updateProperty = () => {},
 }: Props ) {
-	const [ selectionRange, setSelectionRange ] = useState( null );
+	const selectionRange = useRef< { start: number; end: number } | null >(
+		null
+	);
 	const [ isModalOpened, setIsModalOpened ] = useState( false );
 	const list = useSelect(
 		( select ) => select( storeName ).getPersonalizationTagsList(),
@@ -52,13 +56,32 @@ export function RichTextWithButton( {

 	const richTextRef = useRef( null );

+	useEffect( () => {
+		const doc = richTextRef.current?.ownerDocument ?? document;
+		const onSelectionChange = () => {
+			if (
+				! richTextRef.current ||
+				doc.activeElement !== richTextRef.current
+			) {
+				return;
+			}
+			const position = getCursorPosition( richTextRef );
+			if ( position ) {
+				selectionRange.current = position;
+			}
+		};
+		doc.addEventListener( 'selectionchange', onSelectionChange );
+		return () =>
+			doc.removeEventListener( 'selectionchange', onSelectionChange );
+	}, [] );
+
 	const handleInsertPersonalizationTag = useCallback(
 		( tagName, currentValue, currentSelectionRange ) => {
-			// Ensure selection range is within bounds
-			const start = currentSelectionRange?.start ?? currentValue.length;
-			const end = currentSelectionRange?.end ?? currentValue.length;
-
 			let richTextValue = create( { html: currentValue } );
+			const start =
+				currentSelectionRange?.start ?? richTextValue.text.length;
+			const end = currentSelectionRange?.end ?? start;
+
 			richTextValue = insert(
 				richTextValue,
 				create( { html: `<!--${ tagName }-->` } ),
@@ -70,7 +93,7 @@ export function RichTextWithButton( {
 			// Update the corresponding property
 			updateProperty( attributeName, updatedValue );

-			setSelectionRange( null );
+			selectionRange.current = { start: start + 1, end: start + 1 };
 		},
 		[ attributeName, updateProperty ]
 	);
@@ -115,7 +138,7 @@ export function RichTextWithButton( {
 					handleInsertPersonalizationTag(
 						value,
 						attributeValue ?? '',
-						selectionRange
+						selectionRange.current
 					);
 					setIsModalOpened( false );
 					recordEvent(
@@ -131,35 +154,36 @@ export function RichTextWithButton( {
 			/>
 			<PersonalizationTagsPopover
 				contentRef={ richTextRef }
-				onUpdate={ ( originalTag, updatedTag ) => {
+				onUpdate={ ( originalTag, updatedTag, anchor ) => {
 					const currentValue = attributeValue ?? '';
+					if ( anchor ) {
+						const updated = renameTagComment(
+							create( { html: currentValue } ),
+							getElementRange( richTextRef.current, anchor ),
+							updatedTag
+						);
+						if ( updated ) {
+							updateProperty(
+								attributeName,
+								toHTMLString( { value: updated } )
+							);
+							return;
+						}
+					}
 					// When we update the tag, we need to add brackets to the tag, because the popover removes them
-					const updatedContent = currentValue.replace(
-						`<!--[${ originalTag }]-->`,
-						`<!--[${ updatedTag }]-->`
+					updateProperty(
+						attributeName,
+						currentValue.replace(
+							`<!--[${ originalTag }]-->`,
+							`<!--[${ updatedTag }]-->`
+						)
 					);
-					updateProperty( attributeName, updatedContent );
 				} }
 			/>
 			<RichText
 				ref={ richTextRef }
 				className="woocommerce-settings-panel-richtext"
 				placeholder={ placeholder }
-				onFocus={ () => {
-					setSelectionRange(
-						getCursorPosition( richTextRef, attributeValue ?? '' )
-					);
-				} }
-				onKeyUp={ () => {
-					setSelectionRange(
-						getCursorPosition( richTextRef, attributeValue ?? '' )
-					);
-				} }
-				onClick={ () => {
-					setSelectionRange(
-						getCursorPosition( richTextRef, attributeValue ?? '' )
-					);
-				} }
 				onChange={ ( value ) => {
 					value = replacePersonalizationTagsWithHTMLComments(
 						value ?? '',
diff --git a/packages/js/email-editor/src/components/personalization-tags/test/rich-text-utils.spec.ts b/packages/js/email-editor/src/components/personalization-tags/test/rich-text-utils.spec.ts
new file mode 100644
index 00000000000..8273aac9518
--- /dev/null
+++ b/packages/js/email-editor/src/components/personalization-tags/test/rich-text-utils.spec.ts
@@ -0,0 +1,322 @@
+/* global Node, HTMLElement */
+/**
+ * External dependencies
+ */
+import {
+	create,
+	registerFormatType,
+	unregisterFormatType,
+	toHTMLString,
+} from '@wordpress/rich-text';
+
+/**
+ * Internal dependencies
+ */
+import {
+	getCursorPosition,
+	insertPersonalizationTag,
+} from '../rich-text-utils';
+
+const TAG = '[woocommerce/customer-first-name]';
+const OBJ = '';
+const NBSP = ' ';
+const LINK_TYPE = 'woocommerce-email-editor/link-shortcode';
+
+const withCaret = ( html: string, start?: number, end?: number ) => ( {
+	...create( { html } ),
+	start,
+	end,
+} );
+
+describe( 'insertPersonalizationTag', () => {
+	beforeAll( () => {
+		registerFormatType( 'woocommerce-email-editor/shortcode', {
+			name: 'woocommerce-email-editor/shortcode',
+			title: 'Personalization Tags',
+			className: 'woocommerce-email-editor-personalization-tags',
+			tagName: 'span',
+			attributes: {},
+			interactive: false,
+			edit: () => null,
+		} as never );
+		registerFormatType( LINK_TYPE, {
+			name: LINK_TYPE,
+			title: 'Personalization Tags Link',
+			className: 'woocommerce-email-editor-personalization-tags-link',
+			tagName: 'a',
+			attributes: {
+				'data-link-href': 'data-link-href',
+				contenteditable: 'contenteditable',
+				style: 'style',
+			},
+			interactive: true,
+			edit: null,
+		} as never );
+		registerFormatType( 'core/bold', {
+			name: 'core/bold',
+			title: 'Bold',
+			tagName: 'strong',
+			className: null,
+			edit: () => null,
+		} as never );
+		registerFormatType( 'core/italic', {
+			name: 'core/italic',
+			title: 'Italic',
+			tagName: 'em',
+			className: null,
+			edit: () => null,
+		} as never );
+	} );
+
+	afterAll( () => {
+		[
+			'woocommerce-email-editor/shortcode',
+			LINK_TYPE,
+			'core/bold',
+			'core/italic',
+		].forEach( ( name ) => unregisterFormatType( name ) );
+	} );
+
+	it( 'inserts the tag at a collapsed caret', () => {
+		const result = insertPersonalizationTag(
+			withCaret( 'Hello world', 5, 5 ),
+			TAG
+		);
+		expect( result.text ).toBe( `Hello${ OBJ }${ NBSP } world` );
+		expect( toHTMLString( { value: result } ) ).toContain(
+			`Hello<!--${ TAG }-->${ NBSP } world`
+		);
+	} );
+
+	it( 'replaces a non-collapsed selection', () => {
+		const result = insertPersonalizationTag(
+			withCaret( 'Hello world', 0, 5 ),
+			TAG
+		);
+		expect( result.text ).toBe( `${ OBJ }${ NBSP } world` );
+	} );
+
+	it( 'appends at the end when there is no selection', () => {
+		const result = insertPersonalizationTag( withCaret( 'Hello' ), TAG );
+		expect( result.text ).toBe( `Hello${ OBJ }${ NBSP }` );
+	} );
+
+	it( 'places the caret right after the inserted content', () => {
+		const result = insertPersonalizationTag(
+			withCaret( 'Hello world', 5, 5 ),
+			TAG
+		);
+		expect( result.start ).toBe( 7 );
+		expect( result.end ).toBe( 7 );
+		expect( result.activeFormats ).toEqual( [] );
+	} );
+
+	it( 'inserts after bold text with formats registered', () => {
+		const result = insertPersonalizationTag(
+			withCaret( '<strong>Hi</strong> there', 2, 2 ),
+			TAG
+		);
+		expect( result.text ).toBe( `Hi${ OBJ }${ NBSP } there` );
+		expect( toHTMLString( { value: result } ) ).toContain(
+			`<strong>Hi</strong><!--${ TAG }-->${ NBSP } there`
+		);
+	} );
+
+	it( 'inserts a link only over the link text', () => {
+		const result = insertPersonalizationTag(
+			withCaret( '<strong>Hi</strong> there', 3, 3 ),
+			TAG,
+			'Jane'
+		);
+		expect( result.text ).toBe( 'Hi Janethere' );
+		expect( result.activeFormats ).toEqual( [] );
+		expect( result.start ).toBe( 7 );
+		expect( result.end ).toBe( 7 );
+		const links = result.formats.map( ( formats ) =>
+			( formats || [] ).find( ( format ) => format.type === LINK_TYPE )
+		);
+		const linkedIndexes = links.flatMap( ( link, index ) =>
+			link ? [ index ] : []
+		);
+		expect( linkedIndexes ).toEqual( [ 3, 4, 5, 6 ] );
+		expect( links[ 3 ].attributes ).toEqual( {
+			'data-link-href': TAG,
+			contenteditable: 'false',
+			style: 'text-decoration: underline;',
+		} );
+		expect( toHTMLString( { value: result } ) ).toMatch(
+			new RegExp(
+				`<a [^>]*data-link-href="\\[${ 'woocommerce/customer-first-name' }\\]"[^>]*>Jane</a>`
+			)
+		);
+	} );
+} );
+
+describe( 'getCursorPosition', () => {
+	const COMMENT = `<span contenteditable="false" data-rich-text-comment="${ TAG }"><span>${ TAG }</span></span>`;
+	const LINK =
+		'<a data-link-href="[x]" contenteditable="false" style="text-decoration: underline;">link</a>';
+	const FULL = `${ COMMENT }&nbsp;text <strong>bo<em>ld</em></strong> after ${ LINK } tail`;
+	const created: HTMLElement[] = [];
+
+	const makeField = ( innerHTML: string ) => {
+		const el = document.createElement( 'p' );
+		el.innerHTML = innerHTML;
+		document.body.appendChild( el );
+		created.push( el );
+		return { el, ref: { current: el } };
+	};
+
+	const select = (
+		startNode: Node,
+		startOffset: number,
+		endNode: Node = startNode,
+		endOffset: number = startOffset
+	) => {
+		const range = document.createRange();
+		range.setStart( startNode, startOffset );
+		range.setEnd( endNode, endOffset );
+		const selection = document.defaultView.getSelection();
+		selection.removeAllRanges();
+		selection.addRange( range );
+	};
+
+	const textNodeAfter = ( el: HTMLElement, index: number ) =>
+		el.childNodes[ index ];
+
+	beforeAll( () => {
+		registerFormatType( 'core/bold', {
+			name: 'core/bold',
+			title: 'Bold',
+			tagName: 'strong',
+			className: null,
+			edit: () => null,
+		} as never );
+		registerFormatType( 'core/italic', {
+			name: 'core/italic',
+			title: 'Italic',
+			tagName: 'em',
+			className: null,
+			edit: () => null,
+		} as never );
+		registerFormatType( LINK_TYPE, {
+			name: LINK_TYPE,
+			title: 'Personalization Tags Link',
+			className: 'woocommerce-email-editor-personalization-tags-link',
+			tagName: 'a',
+			attributes: {
+				'data-link-href': 'data-link-href',
+				contenteditable: 'contenteditable',
+				style: 'style',
+			},
+			interactive: true,
+			edit: null,
+		} as never );
+	} );
+
+	afterAll( () => {
+		[ 'core/bold', 'core/italic', LINK_TYPE ].forEach( ( name ) =>
+			unregisterFormatType( name )
+		);
+	} );
+
+	afterEach( () => {
+		created.splice( 0 ).forEach( ( node ) => node.remove() );
+		document.defaultView.getSelection().removeAllRanges();
+	} );
+
+	// Children of FULL: 0 comment, 1 " text ", 2 strong, 3 " after ", 4 a, 5 " tail".
+	it.each( [
+		[ 'after the comment', 1, 3, 4 ],
+		[ 'after the bold element', 3, 3, 14 ],
+		[ 'after the link', 5, 2, 24 ],
+	] )( 'reads the offset %s', ( _name, childIndex, offset, expected ) => {
+		const { el, ref } = makeField( FULL );
+		select( textNodeAfter( el, childIndex ), offset );
+		expect( getCursorPosition( ref ) ).toEqual( {
+			start: expected,
+			end: expected,
+		} );
+	} );
+
+	it( 'reads the offset inside bold and nested italic text', () => {
+		const { el, ref } = makeField( FULL );
+		const strong = el.childNodes[ 2 ];
+		select( strong.childNodes[ 0 ], 2 );
+		expect( getCursorPosition( ref ) ).toEqual( { start: 9, end: 9 } );
+		select( strong.childNodes[ 1 ].childNodes[ 0 ], 1 );
+		expect( getCursorPosition( ref ) ).toEqual( { start: 10, end: 10 } );
+	} );
+
+	it( 'reads the offset after an italic-only lead', () => {
+		const { el, ref } = makeField( '<em>lead</em> rest' );
+		select( el.childNodes[ 1 ], 1 );
+		expect( getCursorPosition( ref ) ).toEqual( { start: 5, end: 5 } );
+	} );
+
+	it( 'reads element-container ranges', () => {
+		const { el, ref } = makeField( FULL );
+		select( el, 0 );
+		expect( getCursorPosition( ref ) ).toEqual( { start: 0, end: 0 } );
+		select( el, 1 );
+		expect( getCursorPosition( ref ) ).toEqual( { start: 1, end: 1 } );
+		select( el, 5 );
+		expect( getCursorPosition( ref ) ).toEqual( { start: 22, end: 22 } );
+		select( el, el.childNodes.length );
+		expect( getCursorPosition( ref ) ).toEqual( { start: 27, end: 27 } );
+	} );
+
+	it( 'treats a caret inside the comment as right after it', () => {
+		const { el, ref } = makeField( FULL );
+		select( el.childNodes[ 0 ].childNodes[ 0 ].childNodes[ 0 ], 5 );
+		expect( getCursorPosition( ref ) ).toEqual( { start: 1, end: 1 } );
+	} );
+
+	it( 'reads a non-collapsed selection', () => {
+		const { el, ref } = makeField( FULL );
+		select( el.childNodes[ 2 ].childNodes[ 0 ], 1, el.childNodes[ 5 ], 2 );
+		expect( getCursorPosition( ref ) ).toEqual( { start: 8, end: 24 } );
+	} );
+
+	it( 'counts a selected comment as one character', () => {
+		const { el, ref } = makeField( FULL );
+		select( el, 0, el.childNodes[ 1 ], 2 );
+		expect( getCursorPosition( ref ) ).toEqual( { start: 0, end: 3 } );
+	} );
+
+	it( 'clamps a selection that starts before the field', () => {
+		const { el, ref } = makeField( FULL );
+		const button = document.createElement( 'button' );
+		document.body.insertBefore( button, el );
+		created.push( button );
+		select( button, 0, el.childNodes[ 3 ], 3 );
+		expect( getCursorPosition( ref ) ).toEqual( { start: 0, end: 14 } );
+	} );
+
+	it( 'clamps a selection that ends after the field', () => {
+		const { el, ref } = makeField( FULL );
+		const button = document.createElement( 'button' );
+		document.body.appendChild( button );
+		created.push( button );
+		select( el.childNodes[ 2 ].childNodes[ 0 ], 1, button, 0 );
+		expect( getCursorPosition( ref ) ).toEqual( { start: 8, end: 27 } );
+	} );
+
+	it( 'returns null when the selection is outside the field', () => {
+		const { ref } = makeField( FULL );
+		const other = makeField( 'other' );
+		select( other.el.childNodes[ 0 ], 2 );
+		expect( getCursorPosition( ref ) ).toBeNull();
+	} );
+
+	it( 'returns null when there is no selection', () => {
+		const { ref } = makeField( FULL );
+		expect( getCursorPosition( ref ) ).toBeNull();
+	} );
+
+	it( 'keeps plain text before formatting correct', () => {
+		const { el, ref } = makeField( 'abc <strong>bold</strong>' );
+		select( el.childNodes[ 0 ], 2 );
+		expect( getCursorPosition( ref ) ).toEqual( { start: 2, end: 2 } );
+	} );
+} );
diff --git a/packages/js/email-editor/src/components/personalization-tags/test/rich-text-with-button.spec.tsx b/packages/js/email-editor/src/components/personalization-tags/test/rich-text-with-button.spec.tsx
new file mode 100644
index 00000000000..58201bf72d5
--- /dev/null
+++ b/packages/js/email-editor/src/components/personalization-tags/test/rich-text-with-button.spec.tsx
@@ -0,0 +1,289 @@
+/* global Node, HTMLDivElement, HTMLElement */
+/**
+ * External dependencies
+ */
+import * as React from 'react';
+import { act, fireEvent, render, screen } from '@testing-library/react';
+import '@testing-library/jest-dom';
+
+/**
+ * Internal dependencies
+ */
+import { RichTextWithButton } from '../rich-text-with-button';
+
+const TAG = '[woocommerce/customer-first-name]';
+
+const mockRenderCount = { count: 0 };
+const mockPopover: {
+	onUpdate?: (
+		original: string,
+		updated: string,
+		anchor?: HTMLElement
+	) => void;
+} = {};
+
+jest.mock( '@wordpress/data', () => ( {
+	...jest.requireActual( '@wordpress/data' ),
+	useSelect: () => [],
+} ) );
+
+jest.mock( '@wordpress/i18n', () => ( {
+	__: ( text: string ) => text,
+} ) );
+
+jest.mock( '@wordpress/components', () => ( {
+	BaseControl: ( {
+		label,
+		children,
+	}: {
+		label: React.ReactNode;
+		children: React.ReactNode;
+	} ) => (
+		<div>
+			{ label }
+			{ children }
+		</div>
+	),
+	Button: ( { onClick, title }: React.ComponentProps< 'button' > ) => (
+		<button onClick={ onClick }>{ title }</button>
+	),
+} ) );
+
+jest.mock( '@wordpress/block-editor', () => {
+	const { forwardRef } = jest.requireActual( 'react' );
+	return {
+		RichText: forwardRef(
+			(
+				{ value }: { value: string },
+				ref: React.Ref< HTMLDivElement >
+			) => {
+				mockRenderCount.count++;
+				const html = value.replace(
+					/<!--(.*?)-->/g,
+					( _match, tag ) =>
+						`<span contenteditable="false" data-rich-text-comment="${ tag }"><span>${ tag }</span></span>`
+				);
+				return (
+					<div
+						ref={ ref }
+						contentEditable
+						suppressContentEditableWarning
+						tabIndex={ -1 }
+						data-testid="field"
+						dangerouslySetInnerHTML={ { __html: html } }
+					/>
+				);
+			}
+		),
+	};
+} );
+
+jest.mock( '../personalization-tags-modal', () => ( {
+	PersonalizationTagsModal: ( {
+		onInsert,
+	}: {
+		onInsert: ( tag: string, linkText: string | null ) => void;
+	} ) => (
+		<button
+			onClick={ () =>
+				onInsert( '[woocommerce/customer-first-name]', null )
+			}
+		>
+			insert-tag
+		</button>
+	),
+} ) );
+
+jest.mock( '../personalization-tags-popover', () => ( {
+	PersonalizationTagsPopover: ( props: {
+		onUpdate: typeof mockPopover.onUpdate;
+	} ) => {
+		mockPopover.onUpdate = props.onUpdate;
+		return null;
+	},
+} ) );
+
+jest.mock( '../../../store', () => ( { storeName: 'email-editor' } ) );
+jest.mock( '../../../events', () => ( {
+	recordEvent: jest.fn(),
+	recordEventOnce: jest.fn(),
+} ) );
+
+const NBSP = ' ';
+
+function Harness( {
+	initial,
+	onWrite,
+}: {
+	initial: string;
+	onWrite: ( value: string ) => void;
+} ) {
+	const [ value, setValue ] = React.useState( initial );
+	return (
+		<>
+			<input data-testid="outside-input" />
+			<button data-testid="outside-button">outside</button>
+			<button onClick={ () => setValue( 'Hi' ) }>shrink</button>
+			<RichTextWithButton
+				label="Subject"
+				placeholder=""
+				attributeName="subject"
+				attributeValue={ value }
+				updateProperty={ ( _name, updated ) => {
+					onWrite( updated );
+					setValue( updated );
+				} }
+			/>
+		</>
+	);
+}
+
+const placeCaret = ( node: Node, offset: number ) => {
+	const range = document.createRange();
+	range.setStart( node, offset );
+	range.collapse( true );
+	const selection = document.defaultView.getSelection();
+	selection.removeAllRanges();
+	selection.addRange( range );
+	act( () => {
+		document.dispatchEvent( new Event( 'selectionchange' ) );
+	} );
+};
+
+describe( 'RichTextWithButton caret tracking', () => {
+	let written: string[];
+
+	const setup = ( initial: string ) => {
+		written = [];
+		render(
+			<Harness
+				initial={ initial }
+				onWrite={ ( value ) => written.push( value ) }
+			/>
+		);
+		return screen.getByTestId( 'field' );
+	};
+
+	const insertTag = () => fireEvent.click( screen.getByText( 'insert-tag' ) );
+
+	afterEach( () => {
+		document.defaultView.getSelection().removeAllRanges();
+	} );
+
+	it( 'inserts at the caret placed after formatted text', () => {
+		const field = setup( 'Hello <strong>there</strong> friend' );
+		act( () => field.focus() );
+		placeCaret( field.childNodes[ 2 ], 3 );
+
+		insertTag();
+
+		expect( written ).toEqual( [
+			`Hello <strong>there</strong> fr<!--${ TAG }-->iend`,
+		] );
+	} );
+
+	it( 'keeps the stored position when focus moves to another input', () => {
+		const field = setup( 'Hello <strong>there</strong> friend' );
+		act( () => field.focus() );
+		placeCaret( field.childNodes[ 2 ], 3 );
+
+		const input = screen.getByTestId( 'outside-input' );
+		act( () => input.focus() );
+		placeCaret( input.parentElement, 0 );
+
+		insertTag();
+
+		expect( written[ 0 ] ).toBe(
+			`Hello <strong>there</strong> fr<!--${ TAG }-->iend`
+		);
+	} );
+
+	it( 'ignores selection changes while the field is not focused', () => {
+		const field = setup( 'Hello <strong>there</strong> friend' );
+		act( () => field.focus() );
+		placeCaret( field.childNodes[ 2 ], 3 );
+
+		act( () => screen.getByTestId( 'outside-button' ).focus() );
+		document.defaultView.getSelection().selectAllChildren( document.body );
+		act( () => {
+			document.dispatchEvent( new Event( 'selectionchange' ) );
+		} );
+
+		insertTag();
+
+		expect( written[ 0 ] ).toBe(
+			`Hello <strong>there</strong> fr<!--${ TAG }-->iend`
+		);
+	} );
+
+	it( 'places a second tag right after the first one', () => {
+		const field = setup( 'Hello <strong>there</strong> friend' );
+		act( () => field.focus() );
+		placeCaret( field.childNodes[ 2 ], 3 );
+
+		insertTag();
+		insertTag();
+
+		expect( written[ 1 ] ).toBe(
+			`Hello <strong>there</strong> fr<!--${ TAG }--><!--${ TAG }-->iend`
+		);
+	} );
+
+	it( 'appends at the end when no caret was stored', () => {
+		setup( 'Hello <strong>there</strong> friend' );
+
+		insertTag();
+
+		expect( written[ 0 ] ).toBe(
+			`Hello <strong>there</strong> friend<!--${ TAG }-->`
+		);
+		expect( NBSP ).toHaveLength( 1 );
+	} );
+
+	it( 'edits only the clicked tag when the same tag appears twice', () => {
+		const field = setup( `<!--[a/one]-->, hi <!--[a/one]-->` );
+		const spans = field.querySelectorAll( '[data-rich-text-comment]' );
+
+		act( () => {
+			mockPopover.onUpdate( 'a/one', 'a/two', spans[ 1 ] as HTMLElement );
+		} );
+
+		expect( written ).toEqual( [ '<!--[a/one]-->, hi <!--[a/two]-->' ] );
+	} );
+
+	it( 'inserts at the end when the stored position is past the new text', () => {
+		const field = setup( 'Hello there friend' );
+		act( () => field.focus() );
+		placeCaret( field.childNodes[ 0 ], 15 );
+
+		fireEvent.click( screen.getByText( 'shrink' ) );
+		insertTag();
+
+		expect( written ).toEqual( [ `Hi<!--${ TAG }-->` ] );
+	} );
+
+	it( 'does not re-render the field when the caret moves', () => {
+		const field = setup( 'Hello there friend' );
+		act( () => field.focus() );
+		const before = mockRenderCount.count;
+
+		placeCaret( field.childNodes[ 0 ], 3 );
+		placeCaret( field.childNodes[ 0 ], 5 );
+
+		expect( mockRenderCount.count ).toBe( before );
+	} );
+
+	it( 'falls back to renaming the first match when the anchor is not a tag', () => {
+		setup( `<!--[a/one]-->, hi <!--[a/one]-->` );
+
+		act( () => {
+			mockPopover.onUpdate(
+				'a/one',
+				'a/two',
+				document.createElement( 'span' )
+			);
+		} );
+
+		expect( written ).toEqual( [ '<!--[a/two]-->, hi <!--[a/one]-->' ] );
+	} );
+} );