Skip to content

Fix iOS TextInput IME composition issues for CJK languages#56082

Open
kdwkr wants to merge 14 commits intofacebook:mainfrom
kdwkr:fix/ios-textinput-ime-composition
Open

Fix iOS TextInput IME composition issues for CJK languages#56082
kdwkr wants to merge 14 commits intofacebook:mainfrom
kdwkr:fix/ios-textinput-ime-composition

Conversation

@kdwkr
Copy link
Contributor

@kdwkr kdwkr commented Mar 13, 2026

Summary:

CJK (Chinese/Japanese/Korean) IME composition on Fabric is broken — the composition state is destroyed during input, and the composition underline never renders. This affects Japanese, Chinese, and Korean users on the New Architecture.

Root causes and fixes:

  1. defaultTextAttributes reapplied during composition destroys marked textupdateEventEmitter:, updateProps:, and traitCollectionDidChange: all set defaultTextAttributes directly, which calls [super setDefaultTextAttributes:] and reapplies attributes to the entire text, interfering with the composition state. Fix: defer during active markedTextRange by storing in _pendingDefaultTextAttributes, apply after composition ends in textInputDidChange. When updateProps defers, it preserves the event emitter key from any prior pending updateEventEmitter update. When traitCollectionDidChange defers, it carries forward the existing event emitter key.

  2. _setAttributedString: overwrites text during state round-tripsupdateState: round-trips call _setAttributedString: which replaces attributedText, resetting markedTextRange. Fix: skip when markedTextRange is active; text syncs via textInputDidChange_updateState after composition.

  3. maxLength enforcement blocks IME mid-compositiontextInputShouldChangeText:inRange: enforces maxLength without checking markedTextRange, truncating intermediate CJK input (e.g., Korean ㅎ→하→한). Fix: defer maxLength during composition, enforce via post-composition truncation in textInputDidChange with grapheme-cluster-safe truncation (using rangeOfComposedCharacterSequenceAtIndex: to avoid splitting emoji and composed characters). Truncation uses _comingFromJS wrapper to prevent recursive textInputDidChange from textInputDidChangeSelection.

  4. textInputDidChangeSelection triggers spurious updates in multilineisEqual: on NSAttributedString fails during composition due to system underline attributes, causing unnecessary textInputDidChange calls. Fix: use .string isEqualToString: for bare text comparison in both textInputDidChangeSelection and _ignoreNextTextInputCall guard. Also skip the early textInputDidChange call from textInputDidChangeSelection during composition (!markedTextRange guard).

  5. setTextAndSelection interferes with composition — JS-driven text and selection updates via setTextAndSelection can destroy the composition state. Fix: skip text update, _updateState, AND selection update (setSelectedTextRange:) entirely when markedTextRange is active. JS re-asserts its controlled value after composition ends via the onChangesetTextAndSelection cycle.

  6. _updateState Fabric round-trips during composition_updateState in textInputDidChange pushes state to Fabric during composition, causing round-trips that interfere with UIKit's marked text rendering. Fix: defer _updateState until composition commits (markedTextRange becomes nil).

  7. No-op NSShadow and NSBackgroundColor in defaultTextAttributes/typingAttributes prevent IME composition underline rendering — UIKit uses typingAttributes as the base for marked text rendering. When these contain no-op NSShadow (empty shadow with zero offset/blur) or transparent NSBackgroundColor (added by UIKit as defaults and propagated through React Native's attribute flow), UIKit fails to render the composition underline. This is undocumented UIKit behavior. Fix: strip these no-op attributes (along with the React-internal EventEmitter attribute) from typingAttributes/defaultTextAttributes before passing to UIKit, while preserving user-specified shadow/background values. Applied in three locations: RCTUITextView.setDefaultTextAttributes: (multiline), RCTUITextField.setDefaultTextAttributes: (single-line, stripped before [super setDefaultTextAttributes:]), and _updateTypingAttributes (when reading from Fabric-synced attributed text).

Paper (old architecture) is unaffected; these issues are Fabric-only due to synchronous updateEventEmitter:/updateState: calls.

Fixes #48497
Fixes #55257

Changelog:

[IOS] [FIXED] - Fix CJK IME composition state being destroyed and composition underline not rendering in Fabric TextInput

Test Plan:

Native XCTest (22 tests, all passing):

  • Full composition lifecycle simulations for Korean (ㅎ→한, 감사), Japanese (romaji→漢, candidate re-selection 橋→箸), Chinese (Pinyin→中, Zhuyin/Bopomofo→中), and mixed Latin+CJK
  • Guard verification: _setAttributedString blocked during composition, deferred defaultTextAttributes preserved/applied, maxLength bypassed during composition, multiline bare text comparison, JS-driven update blocked
  • Composition underline attribute preservation tests
  • typingAttributes stripping: EventEmitter, no-op NSShadow, transparent NSBackgroundColor removed; user-specified shadow/background preserved; font and foreground color preserved

JS tests (18 tests, all passing):

  • Controlled/uncontrolled components with CJK composition event patterns
  • Korean multi-syllable, Japanese romaji→hiragana/katakana/candidate re-selection, Chinese Pinyin/Wubi/Zhuyin
  • Mixed Latin↔CJK switching, continuous sentence composition
  • maxLength interaction, multiline composition, value prop updates

Manual verification (iOS Simulator):

  • Japanese Romaji: composition underline visible on every composition (single-line and multiline)
  • Chinese Pinyin: composition underline visible on candidates
  • Korean: composition works correctly (no underline expected — Korean uses inline replacement)
  • English: no regression in normal typing
  • Controlled TextInput: value prop works correctly during CJK input
  • Native UITextField/UITextView comparison: verified that no-op NSShadow + transparent NSBackgroundColor in defaultTextAttributes blocks composition underline rendering (UIKit undocumented behavior)

Test Video

Chinese, Japanese, Korean

ime.testing.video.mp4

English

english.test.mp4

CJK (Chinese/Japanese/Korean) IME composition on Fabric was broken in
multiple ways - the composition underline disappeared and composition
state was destroyed. This was caused by four independent issues:

1. `updateEventEmitter:` reapplied `defaultTextAttributes` every render,
   which destroyed the composition underline. Now deferred during active
   `markedTextRange` and applied after composition ends.

2. `_setAttributedString:` overwrote `attributedText` during state
   round-trips, resetting `markedTextRange`. Now skipped when
   `markedTextRange` is active.

3. `textInputShouldChangeText:inRange:` enforced `maxLength` during
   composition, blocking or truncating intermediate IME input. Now
   deferred until composition commits, with post-composition truncation.

4. `textInputDidChangeSelection` used `isEqual:` on attributed strings,
   which always failed during composition due to system underline
   attributes. Changed to bare text comparison via `isEqualToString:`.

These issues only affected the New Architecture (Fabric); Paper was
unaffected due to its asynchronous bridge timing.

Fixes: facebook#48497
Fixes: facebook#55257
Fixes: facebook#55059
@meta-cla meta-cla bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Mar 13, 2026
@kdwkr kdwkr marked this pull request as ready for review March 13, 2026 08:16
@kdwkr kdwkr marked this pull request as draft March 13, 2026 08:17
kdwkr added 11 commits March 17, 2026 15:21
- Guard defaultTextAttributes in updateProps and traitCollectionDidChange
  to prevent breaking IME composition during JS-driven re-renders
- Use grapheme-cluster-safe truncation in post-composition maxLength
  enforcement to avoid splitting emoji and composed characters
- Skip JS-driven text updates entirely during IME composition in
  setTextAndSelection to prevent unnecessary state ping-pong
- Prevent double textInputDidChange/onChange when post-composition
  maxLength truncation triggers _setAttributedString in multiline mode.
  The recursive call path was: _setAttributedString → setSelectedTextRange
  → textInputDidChangeSelection → textInputDidChange (recursive). Fixed
  by suppressing delegate callbacks during internal truncation.
- Fix stale event emitter when updateEventEmitter and updateProps both
  defer during composition. updateProps now reads the event emitter from
  the pending dict if updateEventEmitter already deferred one, instead
  of reading the stale value from the view.
- Improve test quality: testDefaultTextAttributesSkippedDuringMarkedText
  now actually verifies deferral behavior instead of only testing mock
  setup. Add documentation for maxLength test coverage limitations.
- Guard multiline textInputDidChangeSelection against triggering
  textInputDidChange during composition (markedTextRange check)
- Change _ignoreNextTextInputCall guard from isEqual: to bare string
  comparison to prevent double onChange during composition in multiline
- Preserve event emitter key in traitCollectionDidChange deferred path
  to prevent loss when traitCollectionDidChange fires between
  updateEventEmitter and updateProps during composition
- Handle maxLength=0 edge case in post-composition truncation
- Add test file to RNTesterUnitTests Xcode target (pbxproj)
Test controlled/uncontrolled components with CJK composition event
patterns (Korean ㅎ→하→한, Japanese か→漢字), multiline composition,
maxLength interaction, and value prop updates during composition.
All 7 tests pass. Existing 26 TextInput tests unaffected.
- Japanese romaji→hiragana (kanji), romaji→katakana (東京),
  candidate re-selection (橋→箸→端→箸)
- Chinese Pinyin (zhongguo→中国), Wubi stroke (ggtt→王),
  Zhuyin/Bopomofo (ㄓㄨㄥ→中)
- Korean multi-syllable (감사합니다)
- Mixed Latin↔CJK switching mid-sentence
- Continuous sentence composition (私は学生です, 我爱中国)
Replace individual guard-condition tests with full composition lifecycle
tests that call UIKit delegate methods in the exact order they fire
during real IME input. Remove Maestro E2E test (inputText bypasses IME).

Lifecycle tests added:
- Korean: ㅎ→하→한 (single syllable), 감→감ㅅ→감사 (multi-syllable)
- Japanese: k→か→かん→漢 (romaji), はし→橋→箸→端→箸 (candidate re-selection)
- Chinese: z→zh→zhong→中 (Pinyin), ㄓㄨㄥ→中 (Zhuyin/Bopomofo)
- Mixed: "Hello" + Japanese IME → "Hello世界"

Guard tests retained:
- _setAttributedString blocked/allowed
- Deferred defaultTextAttributes preserved/applied
- maxLength bypassed during composition
- Multiline bare text comparison
- JS-driven update blocked during composition

16 tests, all passing.
- Add 3 tests verifying composition underline (NSUnderlineStyleAttributeName)
  is preserved during state round-trips, deferred attribute updates,
  and correctly removed after commit
- Fix Prettier formatting in TextInput-ime-test.js (CI lint failure)
- Remove Maestro textinput-ime.yml (inputText bypasses IME)

19 native tests, 18 JS tests — all passing.
- Skip _updateState in textInputDidChange during composition to prevent
  Fabric round-trips that interfere with UIKit's marked text rendering
- Guard setTextAndSelection selection updates during composition to
  prevent setSelectedTextRange: from clearing markedTextRange
- Remove debug logging, fix Flow type errors in JS test file
…ix IME composition underline

UIKit uses typingAttributes/defaultTextAttributes as the base for IME
composition marked text rendering. When these contain no-op NSShadow
(empty shadow with zero offset/blur) or transparent NSBackgroundColor,
UIKit fails to render the composition underline on marked text.

These no-op values are added by UIKit as defaults and propagated through
React Native's defaultTextAttributes flow. This commit strips them
(along with the React-internal EventEmitter attribute) before passing
to UIKit, while preserving user-specified shadow/background values.

Changes:
- RCTUITextView.setDefaultTextAttributes: strip from typingAttributes
- RCTUITextField.setDefaultTextAttributes: strip before [super set...]
- _updateTypingAttributes: strip when reading from attributed text
- Add native tests for stripping behavior and value preservation
@kdwkr kdwkr marked this pull request as ready for review March 18, 2026 10:43
@facebook-github-tools facebook-github-tools bot added the Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team. label Mar 18, 2026
@cipolleschi
Copy link
Contributor

This is a very delicate set of changes. Can you please attach videos of RNTester working fine with english, japanese, korean and chinese characters, please? This will increase our confidence that everything is working correctly.

@kdwkr kdwkr force-pushed the fix/ios-textinput-ime-composition branch from d115278 to 5940f15 Compare March 19, 2026 03:03
@kdwkr
Copy link
Contributor Author

kdwkr commented Mar 19, 2026

@cipolleschi I have attached a video demonstrating the tests in English, Chinese, Japanese, and Korean. Please review this PR carefully so that the issues with the CJK IME can be resolved as soon as possible.

…ventEmitter key in traitCollectionDidChange

- updateEventEmitter: use _pendingDefaultTextAttributes as base when it
  exists, so deferred text-style changes from updateProps are not lost
- traitCollectionDidChange: preserve EventEmitter key in the non-composing
  branch (was already preserved in the composing branch)
- RCTUITextView/RCTUITextField: replace @"EventEmitter" string literal
  with local constant kRCTEventEmitterAttributeKey for consistency
- Remove duplicate test file from React/Tests/TextInput/ (canonical copy
  is in rn-tester/RNTesterUnitTests/)
@kdwkr kdwkr force-pushed the fix/ios-textinput-ime-composition branch 2 times, most recently from cb95b3f to 1b69da3 Compare March 19, 2026 03:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team.

Projects

None yet

2 participants