Skip to content

fix(super-editor): defer layout rerender and track-changes rewriting during IME composition (SD-2368)#3711

Open
luccas-harbour wants to merge 9 commits into
mainfrom
luccas/sd-2368-bug-defer-layout-rerender-during-ime-composition
Open

fix(super-editor): defer layout rerender and track-changes rewriting during IME composition (SD-2368)#3711
luccas-harbour wants to merge 9 commits into
mainfrom
luccas/sd-2368-bug-defer-layout-rerender-during-ime-composition

Conversation

@luccas-harbour

Copy link
Copy Markdown
Contributor

Summary

CJK (and other IME-based) input was broken in the presentation editor, especially with track changes enabled: Chrome aborted the native composition on nearly every keystroke, dropping or mangling preedit text. This PR makes IME composition survive end-to-end by deferring everything that restructures the composing DOM or repaints layout until compositionend, then reconciling afterwards.

Root causes fixed

  1. Track-changes rewriting killed the composition. trackedTransaction rewrites each preedit update into tracked-insert mark spans plus decorations, which restructures the DOM text node Chrome is composing into — aborting the composition on every keystroke.
  2. Layout repaints detached the composition anchor. PresentationEditor rerendered the visible layout on every doc change, including mid-composition preedit updates.
  3. Blink's empty-inline cleanup destroyed the hidden editor's contentDOM. In an empty paragraph, Chrome's preedit rewrite (delete + reinsert) empties the inline span.sd-paragraph-content; Blink removes the "redundant" empty span, ProseMirror loses its contentDOM and redraws, and the first composed keystrokes vanish.
  4. Synthetic key events corrupted IME commits. When a commit replaces a longer preedit ("ni h") with shorter text ("你好"), prosemirror-view's DOM-diff heuristic synthesizes a Backspace. Our run-aware Backspace chain succeeded (vanilla PM's would fail and fall through), so ProseMirror discarded the committed text and deleted a preedit character instead.

How it works

Deferred composition tracking (Editor.ts)

  • While a composition is in flight, composition transactions are applied raw (no trackedTransaction rewrite). The inserted range is tracked in #deferredCompositionRange and mapped through every subsequently applied transaction.
  • On compositionend (microtask-deferred, with a forced domObserver flush so the trailing DOM read lands first), #flushDeferredCompositionTracking converts the composed range into a tracked insertion in one transaction. Text composed inside an existing suggestion is left alone (mark inheritance already covers it).
  • Deletions outside the composed range (e.g. composing over a selection) are captured as slices, restored at flush time, and marked as tracked deletions — paired with the insertion as a replacement group (replacementGroupId) unless trackedChanges.replacements: 'independent' is set. Deferral falls back to immediate tracking when the deleted content can't be faithfully restored (tables, non-text leaves).
  • blur force-flushes so compositions abandoned without compositionend don't leak raw untracked text; chained compositions (common with CJK IMEs) keep deferring until the last one ends.
  • The flush transaction sets compositionTrackingFlush meta, which trackedTransaction now skips to avoid re-rewriting the marks it carries.

Deferred layout repaint (PresentationEditor.ts)

  • compositionstart/compositionend listeners on the visible host and the active hidden editor target set an #isComposing flag; #flushRerenderQueue keeps #pendingDocChange/#pendingMapping intact while composing and a single rerender is scheduled when the composition ends. Listeners are re-pointed when the active hidden target changes (body ↔ header/footer ↔ story), with blur/focusout/non-composing input as escape hatches.

Hidden-host DOM survival (HiddenHost.ts)

  • Injects a per-document stylesheet forcing .sd-paragraph-content to display: block inside hidden hosts only, so Blink gives the emptied container a placeholder <br> instead of removing it. Visible editors are unaffected.

Keymap guards (keymap.js)

  • handleEnter/handleBackspace/handleDelete decline while view.composing, restoring vanilla ProseMirror's fall-through for synthesized mid-composition key events.

Tests

  • New Playwright spec tests/comments/chinese-ime-composition.spec.ts simulating real Chrome composition event sequences (preedit updates, commit, empty-paragraph composition, tracked-changes mode).
  • Unit coverage for the deferral guardrails in track-changes-extension.test.js (deferred range mapping, replacement pairing, blur flush, skip-meta) and PresentationEditor.test.ts (composition deferral lifecycle, target swapping, rerender resume).
  • HiddenHost.test.ts covers the injected stylesheet; keymap-backspace-chain.test.js covers the composing decline.

Chrome aborts a native IME composition whenever the composing DOM node is
restructured mid-preedit, which made CJK input lose committed characters
(e.g. 你好) in both editing and suggesting modes. Address every source of
mid-composition DOM churn:

- Editor: defer tracked-transaction rewriting while a composition is in
  flight; track the composed range through applied transactions and convert
  it into a single tracked insertion after compositionend (blur as fallback),
  surfacing the new change id to the sidebar bubble pipeline via meta.
- PresentationEditor: defer visible layout repaints while composing and
  flush the pending rerender once on compositionend/blur/focusout, with
  non-composing input as a lost-compositionend recovery path.
- keymap: decline Backspace/Delete/Enter while composing so prosemirror-view's
  synthesized key events from composition commits fall through and the DOM
  change applies as-is.
- HiddenHost: force `.sd-paragraph-content` to display: block in hidden hosts
  so Blink's empty-inline cleanup can't remove the contentDOM when the
  preedit is rewritten in an empty paragraph.

Adds behavior specs covering committed text, no visible repaints
mid-composition, deferred tracking, and the tracked-change sidebar bubble.
@luccas-harbour luccas-harbour requested a review from a team as a code owner June 11, 2026 12:51
@linear-code

linear-code Bot commented Jun 11, 2026

Copy link
Copy Markdown

SD-2368

@codecov-commenter

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

…tion undo event

The post-composition flush applies trackInsert marks in a separate
transaction whose mark-only steps have empty step maps, so
prosemirror-history's adjacency check failed and the flush became its own
undo event. A first undo then only stripped the suggestion marks, leaving
the composed text behind as an untracked edit, and the unified history
coordinator recorded a spurious extra entry.

Stamp the flush with the deferred transactions' composition id so
prosemirror-history groups it with the composed text: one undo removes
text and marks together, and the coordinator never sees a second event.

Caught by header-footer-undo-cross-container.spec.ts on Firefox, where
Playwright's keyboard.insertText is synthesized as an IME commit.
@luccas-harbour luccas-harbour self-assigned this Jun 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants