Skip to content

fix(composer): skip the input write-back during an IME composition - #5791

Open
ntdatt812 wants to merge 4 commits into
tinyhumansai:mainfrom
ntdatt812:fix/5763-composer-ime-composition
Open

fix(composer): skip the input write-back during an IME composition#5791
ntdatt812 wants to merge 4 commits into
tinyhumansai:mainfrom
ntdatt812:fix/5763-composer-ime-composition

Conversation

@ntdatt812

@ntdatt812 ntdatt812 commented Aug 26, 2026

Copy link
Copy Markdown

Closes #5763.

The defect

thread.tsx attached an onInputCapture handler to LexicalComposerInput that read the raw DOM textContent on every input event and pushed it into the store:

onInputCapture={event => {
  const target = event.target;
  if (target instanceof HTMLElement) {
    const text = target.textContent ?? '';
    globalThis.queueMicrotask(() => aui.composer.setText(text));
  }
}}

During an IME composition those events carry Lexical's pre-edit text. I traced the consequence through the shipped package rather than taking the report on trust — @assistant-ui/react-lexical/dist/plugins/SyncPlugin.js:

// :373  runtime -> Lexical
if (runtimeText === lastSyncedTextRef.current) return;
applyRuntimeText(runtimeText, void 0);

The handler's setText moves the store without moving lastSyncedTextRef, so that guard does not hold and applyRuntimeText rebuilds the editor mid-composition. The composition node dies, the IME cancels and commits the current pre-edit literally, and the next keystroke repeats it — nihao arrives as n ni nihao 你好.

English never triggers it because there is no composition to interrupt: the round-trip is idempotent for committed text.

Why removing it loses nothing

Both owners of that sync already exist, and both are composition-safe in a way the handler was not:

  • SyncPlugin owns editor-state → composer-text. It guards editor.isComposing() in both directions (:335, :356) and moves lastSyncedTextRef in lockstep with its own composer.setText (:326-329), so its writes never re-enter the apply path above.
  • useComposerTextBridge (app/src/components/chat/ChatComposer.tsx:215) owns the programmatic writes — dictation, clear-after-send, draft restore.

Both shipped in the same commit as the handler, which is what made it redundant from the start.

A textContent read was also wrong for a second reason: directive chips render as their labels while the runtime serializes something else, so any host supplying slash-command chips had a latent text-corruption path through the same line.

The now-unused const aui = useAui() binding in Composer goes with it. The ComposerAction component keeps its own.

Verification

npx tsc --noEmit → exit 0, no diagnostic in thread.tsx. pnpm lint → 0 errors (84 pre-existing react-hooks/set-state-in-effect warnings across the app, none in this file).

I could not reproduce the IME behaviour end-to-end here — it needs a CJK IME against a running desktop build, and this is a headless Windows box. What I did verify is the mechanism, in the shipped SyncPlugin source quoted above: the guard that would have prevented the rebuild is keyed on a ref the removed handler never updated. If you would like a regression test rather than a deletion, the natural shape is a composerRuntime.subscribe spy asserting no applyRuntimeText while editor.isComposing() — say the word and I will add it.

Upstream precedent the report cites (assistant-ui#4506, #4510, #4513, #5416) all fix in this same direction: remove the mid-composition write-back.

Summary by CodeRabbit

  • Bug Fixes

    • Improved message composer synchronization during typing.
    • Preserved in-progress IME composition without prematurely updating the composer.
    • Ensured committed IME text is captured correctly and can be sent as a message.
    • Improved consistency between entered text and displayed message content.
  • Tests

    • Added coverage for IME pre-edit and commit behavior to help prevent regressions.

The composer read the raw DOM textContent on every input event and pushed it
into the store. During an IME composition those events carry Lexical's pre-edit
text, so the store ended up disagreeing with SyncPlugin's lastSyncedTextRef; the
plugin's runtime->Lexical path then rebuilt the editor mid-composition and
destroyed the composition node. The IME cancelled and committed each interrupted
stage literally, so typing `nihao` left `n ni nihao 你好` in the box.

Nothing is lost by removing it. SyncPlugin already owns editor-state ->
composer-text and is composition-safe: it guards editor.isComposing() in both
directions and moves lastSyncedTextRef in lockstep with its own setText, so its
writes never re-enter the apply path. useComposerTextBridge already owns the
programmatic writes -- dictation, clear-after-send, draft restore.

A textContent read was also wrong for directive chips, which render as labels
while the runtime serializes something else.

Closes tinyhumansai#5763
@ntdatt812
ntdatt812 requested a review from a team August 26, 2026 16:02
@tinysweeper

tinysweeper Bot commented Aug 26, 2026

Copy link
Copy Markdown

How this change flows

0 changed behaviours across 6 relationships. 6 surrounding behaviours are shown (60 graph nodes walked). 50 further behaviours left out to keep the diagram readable.

flowchart LR
  n0["store"]:::impacted
  n1["ThreadComponentsContext"]:::impacted
  n2["Composer"]:::impacted
  n3["ThreadRoot"]:::impacted
  n4["renderStreamingConversation"]:::impacted
  n5["Thread"]:::impacted
  n2 -->|uses| n1
  n3 -->|uses| n1
  n3 -->|uses| n2
  n4 -->|uses| n0
  n5 -->|uses| n1
  n5 -->|uses| n3
  classDef changed fill:#0d4429,stroke:#238636,color:#e6edf3
  classDef impacted fill:#161b22,stroke:#6e7681,color:#c9d1d9
  classDef flagged fill:#5a1e02,stroke:#d93f0b,color:#ffffff
  classDef blocking fill:#67060c,stroke:#f85149,color:#ffffff
Loading

Green: changed behaviour. Grey: surrounding behaviour. Arrows name the call, use, implementation, or test relationship. Orange: has findings. Red: has a finding that blocks the merge.

tinysweeper 0.1.0

@tinysweeper tinysweeper Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tinysweeper found nothing blocking. Approving.

$0.0000 · 0 in / 0 out

@tinysweeper tinysweeper Bot added the priority: p3 Whenever. Cosmetic, a nicety, or a cleanup with no user visible effect. label Aug 26, 2026
@coderabbitai

coderabbitai Bot commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 29b1b4c4-8c7a-49aa-a842-684cf0318bcf

📥 Commits

Reviewing files that changed from the base of the PR and between 0fba18a and 487538c.

📒 Files selected for processing (1)
  • app/src/pages/__tests__/Conversations.render.test.tsx
🚧 Files skipped from review as they are similar to previous changes (1)
  • app/src/pages/tests/Conversations.render.test.tsx

Included review availability: Your plan provides up to 10 included reviews per hour; 7 remain after this review.


📝 Walkthrough

Walkthrough

The composer now ignores active IME composition events and synchronizes committed Lexical text with the composer store. Regression tests cover pre-edit and commit events, including sending 你好.

Changes

Composer synchronization

Layer / File(s) Summary
Filter composition input and update composer text
app/src/components/assistant-ui/thread.tsx, app/src/pages/__tests__/Conversations.render.test.tsx
onInputCapture now checks only nativeEvent.isComposing. Active composition input is ignored, while commit input updates the composer store. Tests verify that pre-edit text is ignored and committed 你好 is sent.

Estimated code review effort: 2 (Simple) | ~10 minutes

Merge Risk: ⚪ Minimal · up to 48753

This localized change removes the redundant input write-back that could interrupt IME composition without changing the existing synchronization owners; no actionable merge-blocking risk remains beyond normal checks and review.

Sequence Diagram(s)

sequenceDiagram
  participant IME
  participant LexicalComposerInput
  participant ComposerStore
  IME->>LexicalComposerInput: Send pre-edit input
  LexicalComposerInput-->>ComposerStore: Ignore while isComposing is true
  IME->>LexicalComposerInput: Send committed input
  LexicalComposerInput->>ComposerStore: Synchronize committed text
Loading

Poem

A rabbit guards the typing stream,
While letters gather in a dream.
Pre-edit text must wait its turn,
Then final words arrive and learn.
“你好” hops safely into view.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 66.67% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 3 functions across 2 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the primary change: skipping composer store write-back during active IME composition.
Linked Issues check ✅ Passed The implementation gates DOM-to-store synchronization on nativeEvent.isComposing, which prevents pre-edit text from rebuilding the editor while preserving composition-commit events. Regression tests c…
Out of Scope Changes check ✅ Passed The changes are limited to the composer input handler and focused IME regression tests. No unrelated code changes are present.
Full details: Linked Issues check

Explanation

The implementation gates DOM-to-store synchronization on nativeEvent.isComposing, which prevents pre-edit text from rebuilding the editor while preserving composition-commit events. Regression tests cover ignored intermediate events and accepted committed text for issue #5763.

  • Fix all pre-merge checks with AI

Warning

Your free Security trial is over. An organization admin can activate billing to continue.


Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2b6ef4fe19

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

* opens, so a host that wants a plain box still gets one.
*/}
{/*
* No `onInputCapture` write-back here. Reading the raw DOM

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve the synthetic input bridge used by WDIO

When the desktop WDIO suites call typeIntoComposer, the contenteditable branch directly assigns textContent and dispatches a synthetic input event instead of producing native editor operations (app/test/e2e/helpers/chat-harness.ts:114-133). This capture callback was the bridge that copied that DOM-only value into aui.composer; after removing it, the helper's DOM equality check can pass while the composer store remains empty, leaving the Send action without a prompt and breaking the chat E2E specs that use this helper. Keep a write-back that skips active composition, or update the helper to write through Lexical/the composer API in the same change.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct, and already addressed — but your finding is stronger than the fix it landed on, so I want to record it properly.

That comment is anchored to 2b6ef4fe1, the commit where I deleted the handler outright. CI rejected that (19 red tests) and I replaced it with exactly what you prescribe: a write-back that skips active composition.

const native = event.nativeEvent as InputEvent;
if (native.isComposing) {
  return;
}

I verified your specific case rather than assuming it: chat-harness.ts never sets isComposing, and new InputEvent('input', { bubbles: true, data: nextValue }) defaults it to false — so the WDIO bridge passes the guard and still writes through to aui.composer.

You found a second harness I had not seen. I traced the vitest helper (Conversations.render.test.tsx's setComposerText) after CI failed; I did not know the WDIO suites had the same DOM-only pattern. Two independent harnesses depending on this callback is a much better argument for keeping it than the one I had, and it also answers the open question I left on this PR — I had asked whether the handler was redundant and the helper should be fixed instead. With WDIO in the picture, changing the harnesses is clearly the wrong trade.

One nuance worth flagging since you are looking at this path: keying the skip off inputType === 'insertCompositionText' instead of isComposing would break the WDIO bridge in the other direction. I had that clause in the pushed version and removed it — browsers can emit the composition commit with that same inputType and isComposing === false, so it swallowed the finished text. isComposing alone is the boundary.

Both branches now have a test, each verified red against the corresponding wrong implementation.

coderabbitai[bot]
coderabbitai Bot previously approved these changes Aug 26, 2026
Removing the handler outright broke 19 page-level tests. Their helper sets
`textarea.textContent` directly and fires an `input` event, which never reaches
Lexical's editor state -- so SyncPlugin's update listener does not fire and the
removed write-back was the only thing turning typed text into store text under
test. The deletion was right about production and wrong about the harness, and
19 red tests are better evidence than my reading of SyncPlugin.

Guarding is also the smaller claim. It fixes the reported bug without betting on
the handler being redundant: skip the write-back while `isComposing` is true or
the event is an `insertCompositionText`, so nothing lands in the store
mid-composition and SyncPlugin's runtime->Lexical path never rebuilds the editor
under an active composition node. The commit event that ends a composition has
`isComposing === false` and carries the finished text, so committed input still
reaches the store exactly as before.

Conversations.render.test.tsx: 54 passed.

Closes tinyhumansai#5763
@ntdatt812

Copy link
Copy Markdown
Author

Correcting myself: CI was right and my original claim was wrong.

I said "nothing is lost by dropping it". Removing the handler broke 19 page-level tests in Conversations.render.test.tsx. The cause is in their input helper:

function setComposerText(textarea: HTMLElement, text: string) {
  textarea.textContent = text;
  fireEvent.input(textarea, { data: text, inputType: 'insertText' });
}

Setting textContent directly never reaches Lexical's editor state, so SyncPlugin's update listener does not fire — and the write-back I removed was the only thing turning typed text into store text under test. My reading of SyncPlugin was correct about production and said nothing about the harness, and I did not run the frontend suite before opening this. That was the miss.

Changed approach in e2ad37b: guard the handler rather than delete it.

const native = event.nativeEvent as InputEvent;
if (native.isComposing || native.inputType === 'insertCompositionText') {
  return;
}

This is also the smaller claim. It fixes the reported bug — nothing lands in the store mid-composition, so SyncPlugin's runtime→Lexical path never rebuilds the editor under a live composition node — without betting on the handler being redundant everywhere. The commit event that ends a composition has isComposing === false and carries the finished text, so committed input still reaches the store exactly as before.

vitest run --config test/vitest.config.ts src/pages/__tests__/Conversations.render.test.tsx54 passed.

Two things I would still value your view on, since they are the parts I cannot settle from outside:

  1. The textContent read still flattens directive chips to their labels while the runtime serializes something else. The guard does not touch that; it is a separate latent path.
  2. If the write-back really is redundant in production, the deletion is the better end state and the test helper should drive Lexical rather than the DOM. That is your call on your harness, not mine to force through a PR.

@ntdatt812 ntdatt812 changed the title fix(composer): stop the input write-back that cancels IME composition fix(composer): skip the input write-back during an IME composition Aug 26, 2026
coderabbitai[bot]
coderabbitai Bot previously approved these changes Aug 26, 2026
…oth paths

The diff-coverage gate rejected the previous commit for leaving the guard's
`return` untested. Writing that test found a real defect in the guard itself.

The guard also blocked on `inputType === 'insertCompositionText'`. That
inputType is not exclusive to pre-edit events: the event that *ends* a
composition can carry it too, with `isComposing === false`. So the guard
swallowed the one event carrying the committed text.

`isComposing` alone is the correct discriminator — it is the spec's own "a
composition is in progress" flag, true for exactly the pre-edit events and
false on the commit.

Two tests, one per branch, each verified red against the wrong version:

  - a half-composed stage must not reach the store
    (red with no guard at all: 'nihao' reaches the composer)
  - the committed text must be written back
    (red with the inputType clause: '你好' never arrives)

56 passed in this file; 135 passed across the five files that render Thread.
@ntdatt812

Copy link
Copy Markdown
Author

The coverage gate was right to reject this, and chasing it found a defect in my own guard.

What the gate caught. Diff-cover flagged line 387 — the guard's return — as uncovered. Writing that test meant writing the other half too: the event that ends a composition and carries the committed text.

What writing it exposed. The guard read:

if (native.isComposing || native.inputType === 'insertCompositionText') return;

insertCompositionText is not exclusive to pre-edit events. The event that ends a composition can carry that same inputType with isComposing === false. So the clause I added as a "backstop" swallowed the one event carrying the finished text.

That is not theoretical — I ran it:

guard half-composed stage stays out committed text arrives
none nihao reaches the composer
isComposing || inputType === 'insertCompositionText' (what I pushed) 你好 never arrives
isComposing only (now)

isComposing alone is the right discriminator: it is the spec's own "a composition is in progress" flag, so it is true for exactly the pre-edit events and false on the commit. The extra clause bought nothing and cost the commit.

Tests. Two, one per branch, each verified red against the corresponding wrong version (the table above is that run). The commit-path test deliberately keeps inputType: 'insertCompositionText' on the commit event, since that is precisely the shape the old guard mishandled.

Verification. 56 passed in Conversations.render.test.tsx; 135 passed across the five test files that render Thread. prettier --check . clean.

Worth noting for reviewers: in production SyncPlugin is what carries committed text from Lexical to the store, so the swallowed-commit bug would have been masked there. It is the test harness — which sets textContent directly and so never drives Lexical — where this write-back is the only path. That asymmetry is also why my original "nothing is lost by dropping the handler" claim was wrong, and it is the open question from my earlier comment that I still think is yours to decide.

coderabbitai[bot]
coderabbitai Bot previously approved these changes Aug 27, 2026
`tsc --noEmit` rejected the new assertion: `chatSend` is imported with its
real signature, so `.mock` is not on its type — only the `vi.fn()` behind the
module mock has it.

Switched to `toHaveBeenCalledWith(expect.objectContaining(...))`, which is
type-safe and matches how every other test in this file asserts a send.

My mistake was not running the repo's own type check before pushing; the test
run alone was green. `tsc --noEmit` and `prettier --check` are both clean now,
and the file is 56 passed.
@ntdatt812

Copy link
Copy Markdown
Author

Fixed the type error. That one was avoidable and mine.

tsc --noEmit rejected my new assertion:

Conversations.render.test.tsx(1711,21): error TS2339: Property 'mock' does not
exist on type '(params: ChatSendParams) => Promise<string | undefined>'.

chatSend is imported with its real signature; only the vi.fn() behind the module mock carries .mock. Switched to toHaveBeenCalledWith(expect.objectContaining({ message: '你好' })), which is type-safe and matches how every other send assertion in this file is written.

The PR CI Gate failure was downstream of this — it reports Frontend Checks did not pass, nothing separate.

I had run the test file but not tsc --noEmit, which is the actual gap: a green vitest run does not imply the file type-checks. Both are clean now, plus prettier --check, and the file is 56 passed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

priority: p3 Whenever. Cosmetic, a nicety, or a cleanup with no user visible effect.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Chat composer cancels IME composition mid-keystroke and commits pre-edit text as literal characters

1 participant