Repository: Automattic/harper (2026-04-04T03:48:14Z)

Issue:
Cursor jumps to start of editor after dismissing suggestion popup
**Describe the bug**
When you click on an underlined word to see the suggestion popup, then dismiss it without applying a suggestion, the cursor jumps to the beginning of the editor instead of staying where you were typing.

**To Reproduce**
1. Open a page with a Draft.js, Slate, or Lexical editor
2. Type something with a spelling error like "This is an test"
3. Click the underlined word to open the popup
4. Click Dismiss or Close (or press Escape on Lexical)
5. Cursor is now at position 0

**Expected behavior**
Cursor stays where it was before the popup opened.

**Platform**
Chrome extension. Affects Draft.js, Slate, and Lexical editors. Textareas and ProseMirror work fine.



Discussion:

Patch:
diff --git a/packages/lint-framework/src/lint/SuggestionBox.ts b/packages/lint-framework/src/lint/SuggestionBox.ts
index ab977dad6..990cba36e 100644
--- a/packages/lint-framework/src/lint/SuggestionBox.ts
+++ b/packages/lint-framework/src/lint/SuggestionBox.ts
@@ -17,7 +17,53 @@ function iconSvg(definition: IconDefinition): string {
 const settingsIconSvg = iconSvg(faSliders);
 const disableIconSvg = iconSvg(faToggleOff);
 
-let previouslyActiveElement: null | HTMLElement = null;
+/** Saved cursor restore function, captured when the popup steals focus. */
+let savedRestore: (() => void) | null = null;
+
+function saveCursorState() {
+	const el = document.activeElement;
+	if (!el || el === document.body || el.tagName.toLowerCase() === 'harper-render-box') return;
+
+	if (el instanceof HTMLTextAreaElement || el instanceof HTMLInputElement) {
+		const start = el.selectionStart;
+		const end = el.selectionEnd;
+		const dir = el.selectionDirection ?? undefined;
+		savedRestore =
+			start != null
+				? () => {
+						el.focus({ preventScroll: true });
+						el.setSelectionRange(start, end, dir);
+					}
+				: () => el.focus({ preventScroll: true });
+	} else if (el instanceof HTMLElement) {
+		const sel = window.getSelection();
+		const range = sel && sel.rangeCount > 0 ? sel.getRangeAt(0).cloneRange() : null;
+		savedRestore = () => {
+			el.focus({ preventScroll: true });
+			if (range) {
+				const s = window.getSelection();
+				if (s) {
+					s.removeAllRanges();
+					s.addRange(range);
+				}
+			}
+		};
+	}
+}
+
+/** Deferred so in-flight key events (e.g. Escape) finish before the editor regains focus. */
+function restoreCursorState() {
+	const restore = savedRestore;
+	if (!restore) return;
+	savedRestore = null;
+	setTimeout(() => {
+		try {
+			restore();
+		} catch {
+			// Range may reference nodes removed by a framework reconciler.
+		}
+	}, 0);
+}
 
 var FocusHook: any = function () {};
 FocusHook.prototype.hook = function (node: any, _propertyName: any, _previousValue: any) {
@@ -26,9 +72,7 @@ FocusHook.prototype.hook = function (node: any, _propertyName: any, _previousVal
 	}
 
 	requestAnimationFrame(() => {
-		if (document.activeElement?.tagName.toLowerCase() != 'harper-render-box') {
-			previouslyActiveElement = document.activeElement as HTMLElement;
-		}
+		saveCursorState();
 
 		node.focus();
 		Object.defineProperty(node, '__harperAutofocused', {
@@ -478,7 +522,7 @@ export default function SuggestionBox(
 	const ignoreLintCallback = box.ignoreLint;
 
 	const refocusClose = () => {
-		previouslyActiveElement?.focus();
+		restoreCursorState();
 		close();
 	};
 
@@ -503,7 +547,7 @@ export default function SuggestionBox(
 			footer(
 				suggestions(box.lint.lint_kind, box.lint.suggestions, (v) => {
 					box.applySuggestion(v);
-					refocusClose();
+					close();
 				}),
 				[
 					box.lint.lint_kind === 'Spelling' && actions.addToUserDictionary


Test patch:
diff --git a/packages/chrome-plugin/tests/testUtils.ts b/packages/chrome-plugin/tests/testUtils.ts
index 2a9397510..58795f01d 100644
--- a/packages/chrome-plugin/tests/testUtils.ts
+++ b/packages/chrome-plugin/tests/testUtils.ts
@@ -259,6 +259,16 @@ export async function testCanIgnoreSuggestion(
 		await assertEditorText(editor, cacheSalt);
 		expect(await clickHarperHighlight(page)).toBe(false);
 		await assertLocatorIsFocused(page, editor);
+
+		// Backspace at position 0 is a no-op; unchanged text means cursor jumped.
+		await page.waitForTimeout(300);
+		await page.keyboard.press('Backspace');
+		await page.waitForTimeout(300);
+		if (await isFormElement(editor)) {
+			await expect(editor).not.toHaveValue(cacheSalt);
+		} else {
+			await expect(editor).not.toHaveText(cacheSalt);
+		}
 	});
 }