diff --git a/src/adapters/chrome/content-script/suggestions/ContentEditableAdapter.ts b/src/adapters/chrome/content-script/suggestions/ContentEditableAdapter.ts index 7e3fccf3..5da3bb96 100644 --- a/src/adapters/chrome/content-script/suggestions/ContentEditableAdapter.ts +++ b/src/adapters/chrome/content-script/suggestions/ContentEditableAdapter.ts @@ -143,6 +143,12 @@ export class ContentEditableAdapter { if (shouldTryNativeReplacement) { const nativeReplacementResult = this.tryNativeReplacement(elem, replacementText); if (nativeReplacementResult.didMutateDom) { + // execCommand leaves the caret at the end of the inserted text. Plain + // contenteditable has no async host reconciliation to override us, so + // place the caret at the final offset synchronously. This prevents a + // race where a fast follow-up keystroke (e.g. auto-close "()" then an + // immediate "x") lands before a deferred caret correction runs. + this.setCaret(editScope, cursorAfter); logger.debug("Contenteditable replacement handled by execCommand fallback", { didDispatchInput: nativeReplacementResult.didDispatchInput, editorTextLength: (elem.textContent ?? "").length, diff --git a/src/adapters/chrome/content-script/suggestions/SuggestionTextEditService.ts b/src/adapters/chrome/content-script/suggestions/SuggestionTextEditService.ts index dbbf7c9c..edd9ab61 100644 --- a/src/adapters/chrome/content-script/suggestions/SuggestionTextEditService.ts +++ b/src/adapters/chrome/content-script/suggestions/SuggestionTextEditService.ts @@ -706,7 +706,19 @@ export class SuggestionTextEditService { // DOM asynchronously via microtask. This means didMutateDom is false at check // time even though the edit WILL be applied. The deferred callback validates // that the expected text appeared before moving the cursor. - if (edit.cursorOffset !== undefined && !TextTargetAdapter.isTextValue(entry.elem)) { + // + // Plain contenteditable applied via the DOM path ("fallback-dom") already had + // its caret placed synchronously at the final offset by the adapter, so skip + // the deferred relative move-back there — running it would double-correct the + // caret. Only host editors (React via beforeinput, CKEditor via host session) + // reconcile asynchronously and still need the deferred reposition. + const caretPlacedSynchronously = + "appliedBy" in applyResult && applyResult.appliedBy === "fallback-dom"; + if ( + edit.cursorOffset !== undefined && + !TextTargetAdapter.isTextValue(entry.elem) && + !caretPlacedSynchronously + ) { const moveBackCount = replacement.length - edit.cursorOffset; if (moveBackCount > 0) { const targetElem = entry.elem as HTMLElement; diff --git a/tests/e2e/full.e2e.test.ts b/tests/e2e/full.e2e.test.ts index 4fbc7963..714e1c01 100644 --- a/tests/e2e/full.e2e.test.ts +++ b/tests/e2e/full.e2e.test.ts @@ -6069,7 +6069,9 @@ describeE2E(`Extension E2E Test [${BROWSER_TYPE}]`, () => { await typeInInput(page, selector, "hello ("); await waitForInputContentMatch(page, selector, /^hello \(\)$/, browserTimeout(5000, 9000)); - // Verify cursor position: typing after auto-close should insert between brackets + // Verify cursor position: typing after auto-close should insert between + // brackets. Plain contenteditable repositions the caret synchronously, so + // an immediate keystroke must land inside the brackets (no settle needed). await typeInInput(page, selector, "x"); await waitForInputContentMatch(page, selector, /^hello \(x\)$/, browserTimeout(5000, 9000));