From e22f7b5351d9d2da92c06ac9d7cfe8ad83fb72f1 Mon Sep 17 00:00:00 2001 From: Bartosz Tomczyk Date: Thu, 28 May 2026 07:41:43 +0200 Subject: [PATCH 1/2] fix(test): deflake contenteditable auto-bracket-close e2e The "#test-contenteditable" auto-close test typed "x" immediately after the bracket auto-close, racing the content script's deferred caret reposition (rAF + setTimeout) which moves the caret between the brackets for contenteditable targets. On fast CI the content poll matched "hello ()" before the reposition fired, so "x" landed after ")" giving a stable "hello ()x" that never matched /^hello \(x\)$/ -> 5000ms timeout. Mirror the already-stable Lexical test by waiting for the deferred reposition before typing. Plain #test-input is unaffected (synchronous caret), so it is left unchanged. Co-Authored-By: Claude Opus 4.7 --- tests/e2e/full.e2e.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/e2e/full.e2e.test.ts b/tests/e2e/full.e2e.test.ts index 4fbc7963..d29af400 100644 --- a/tests/e2e/full.e2e.test.ts +++ b/tests/e2e/full.e2e.test.ts @@ -6069,6 +6069,11 @@ describeE2E(`Extension E2E Test [${BROWSER_TYPE}]`, () => { await typeInInput(page, selector, "hello ("); await waitForInputContentMatch(page, selector, /^hello \(\)$/, browserTimeout(5000, 9000)); + // Wait for deferred cursor repositioning (rAF + setTimeout in content script). + // For contenteditable the caret is moved between the brackets asynchronously, + // so typing immediately can race and land "x" after ")" -> "hello ()x". + await sleep(200); + // Verify cursor position: typing after auto-close should insert between brackets await typeInInput(page, selector, "x"); await waitForInputContentMatch(page, selector, /^hello \(x\)$/, browserTimeout(5000, 9000)); From 83f634cf3fd8f49c49d2b3bc4f67a3493bd54726 Mon Sep 17 00:00:00 2001 From: Bartosz Tomczyk Date: Thu, 28 May 2026 08:04:03 +0200 Subject: [PATCH 2/2] fix: place auto-close caret synchronously on plain contenteditable Supersedes the test-only sleep(200) deflake with a product fix for the underlying race. After auto-closing a bracket, the caret must move between the brackets. For contenteditable this was done only via a deferred (rAF + setTimeout) bridge event, because React hosts (Lexical/Slate) reconcile asynchronously and override a synchronously-set caret. But the deferral was applied to ALL contenteditable, so a fast follow-up keystroke on a plain contenteditable could land before the reposition ran -> "hello ()x" instead of "hello (x)". This affected real fast/paste typing, not just the e2e test, and surfaced as a flaky CI timeout. Plain contenteditable applied via the DOM path ("fallback-dom") has no async reconciliation, so place the caret at the final offset synchronously in the adapter's execCommand branch and skip the deferred relative move-back for that path. Host editors (beforeinput / host-session) are unchanged and keep their deferred repositioning. The contenteditable e2e test no longer needs a settle delay. Co-Authored-By: Claude Opus 4.7 --- .../suggestions/ContentEditableAdapter.ts | 6 ++++++ .../suggestions/SuggestionTextEditService.ts | 14 +++++++++++++- tests/e2e/full.e2e.test.ts | 9 +++------ 3 files changed, 22 insertions(+), 7 deletions(-) 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 d29af400..714e1c01 100644 --- a/tests/e2e/full.e2e.test.ts +++ b/tests/e2e/full.e2e.test.ts @@ -6069,12 +6069,9 @@ describeE2E(`Extension E2E Test [${BROWSER_TYPE}]`, () => { await typeInInput(page, selector, "hello ("); await waitForInputContentMatch(page, selector, /^hello \(\)$/, browserTimeout(5000, 9000)); - // Wait for deferred cursor repositioning (rAF + setTimeout in content script). - // For contenteditable the caret is moved between the brackets asynchronously, - // so typing immediately can race and land "x" after ")" -> "hello ()x". - await sleep(200); - - // 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));