Skip to content

fix: place auto-close caret synchronously on plain contenteditable#363

Merged
bartekplus merged 2 commits into
masterfrom
fix/flaky-contenteditable-bracket-autoclose-e2e
May 28, 2026
Merged

fix: place auto-close caret synchronously on plain contenteditable#363
bartekplus merged 2 commits into
masterfrom
fix/flaky-contenteditable-bracket-autoclose-e2e

Conversation

@bartekplus

@bartekplus bartekplus commented May 28, 2026

Copy link
Copy Markdown
Owner

Summary

Fixes the intermittent Tests → E2E Full failure where Grammar Rule Engine auto-closes brackets in #test-contenteditable timed out (to match /^hello \(x\)$/ after 5000ms). The flaky test was a symptom of a real product race, so this fixes the product rather than just padding the test with a sleep.

Root cause

After auto-closing a bracket, the caret must move between the brackets. For contenteditable that was done only via a deferred bridge event (requestAnimationFrame + setTimeout(30ms)Selection.modify() in the main world). That deferral exists because React hosts (Lexical/Slate) reconcile asynchronously and override a synchronously-set caret — but it was applied to all contenteditable, including plain ones.

So a fast follow-up keystroke on a plain contenteditable could land before the reposition ran → hello ()x instead of hello (x). This is a genuine fast-typing / paste glitch for real users (worse under main-thread load), not merely a test artifact; in CI it surfaced as the 5000ms timeout.

Plain <input>/<textarea> were unaffected (caret set synchronously via setSelectionRange), which is why only the contenteditable test flaked.

Fix

Scoped strictly to the plain-DOM apply path (appliedBy === "fallback-dom"):

  • ContentEditableAdapter: the execCommand branch returned before setCaret, leaving the caret after ). Now it places the caret at the final offset synchronously (matching the pure-DOM path).
  • SuggestionTextEditService: skip the deferred relative move-back for fallback-dom (the caret is already correct; a relative move-back would double-correct it).

Host editors (React via beforeinput = host-beforeinput; CKEditor via host session = no appliedBy) are untouched and keep their deferred repositioning. autoBracketClose is the only rule that sets cursorOffset, so the blast radius is exactly the auto-close-on-plain-CE case.

The contenteditable e2e test no longer needs a settle delay (the earlier sleep(200) is removed; the Lexical test keeps its own, since React stays deferred).

Verification

  • Full e2e suite chrome: 64 pass, 0 fail
  • Full e2e suite firefox: 64 pass, 0 fail
  • Previously-flaky #test-contenteditable test: 8/8 runs pass (was racy)
  • bun run lint, bun run typecheck, prettier --check: all clean

Test plan

  • CI Tests workflow green on both browsers
  • E2E Full stays green across repeated runs

🤖 Generated with Claude Code

bartekplus and others added 2 commits May 28, 2026 07:41
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
@bartekplus bartekplus changed the title fix(test): deflake contenteditable auto-bracket-close e2e fix: place auto-close caret synchronously on plain contenteditable May 28, 2026
@bartekplus bartekplus enabled auto-merge (squash) May 28, 2026 06:06
@bartekplus bartekplus disabled auto-merge May 28, 2026 06:12
@bartekplus bartekplus merged commit 353194b into master May 28, 2026
8 checks passed
@bartekplus bartekplus deleted the fix/flaky-contenteditable-bracket-autoclose-e2e branch May 28, 2026 06:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant