diff --git a/packages/react-native/Libraries/Components/TextInput/__tests__/TextInput-ime-test.js b/packages/react-native/Libraries/Components/TextInput/__tests__/TextInput-ime-test.js new file mode 100644 index 000000000000..fa8e3adc883d --- /dev/null +++ b/packages/react-native/Libraries/Components/TextInput/__tests__/TextInput-ime-test.js @@ -0,0 +1,894 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +const {enter} = require('../../../Utilities/ReactNativeTestTools'); +const TextInput = require('../TextInput').default; +const React = require('react'); +const {createRef, useState} = require('react'); +const ReactTestRenderer = require('react-test-renderer'); + +jest.unmock('../TextInput'); + +/** + * Tests that verify JS-level TextInput behavior during IME composition + * scenarios. The actual IME guards (markedTextRange checks, deferred + * defaultTextAttributes, etc.) are in the native layer and tested by + * RCTTextInputComponentViewIMETests.mm. These tests verify that the JS + * component correctly handles the event patterns produced by native + * during CJK composition. + */ +describe('TextInput IME composition behavior', () => { + describe('controlled component with CJK composition', () => { + it('handles intermediate composition text via onChange', () => { + // Simulates Korean IME: ㅎ → 하 → 한 → 한글 + // Each step fires onChange from native. The controlled component should + // update its value at each step without losing state. + const onChangeText = jest.fn(); + let currentText = ''; + + function ControlledInput() { + const [text, setText] = useState(''); + currentText = text; + return ( + { + onChangeText(t); + setText(t); + }} + /> + ); + } + + let renderer; + ReactTestRenderer.act(() => { + renderer = ReactTestRenderer.create(); + }); + + // $FlowFixMe[incompatible-use] + const input = renderer.root.findByType(TextInput); + + // Simulate Korean composition steps + const compositionSteps = ['ㅎ', '하', '한', '한글']; + compositionSteps.forEach(step => { + ReactTestRenderer.act(() => { + enter(input, step); + }); + }); + + expect(onChangeText).toHaveBeenCalledTimes(4); + expect(onChangeText.mock.calls.map(c => c[0])).toEqual(compositionSteps); + expect(currentText).toBe('한글'); + }); + + it('handles Japanese romaji-to-hiragana conversion', () => { + // Japanese IME romaji input: typing "k","a","n","j","i" produces: + // k → か (ka) → かn → かん (kan) → かんj → かんじ (kanji) → 漢字 (kanji conversion) + const onChangeText = jest.fn(); + let currentText = ''; + + function ControlledInput() { + const [text, setText] = useState(''); + currentText = text; + return ( + { + onChangeText(t); + setText(t); + }} + /> + ); + } + + let renderer; + ReactTestRenderer.act(() => { + renderer = ReactTestRenderer.create(); + }); + + // $FlowFixMe[incompatible-use] + const input = renderer.root.findByType(TextInput); + + // Romaji "kanji" → hiragana → kanji conversion + const compositionSteps = [ + 'k', // raw romaji, still composing + 'か', // "ka" converted to hiragana + 'かn', // next consonant buffered + 'かん', // "kan" complete + 'かんj', // next consonant buffered + 'かんじ', // "kanji" in hiragana + '漢字', // user selected kanji conversion from candidate list + ]; + compositionSteps.forEach(step => { + ReactTestRenderer.act(() => { + enter(input, step); + }); + }); + + expect(onChangeText).toHaveBeenCalledTimes(7); + expect(currentText).toBe('漢字'); + }); + + it('handles Japanese romaji-to-katakana conversion', () => { + // Some IME modes convert romaji directly to katakana: + // "to" → "と" → "トウキョウ" → "東京" (or user keeps katakana) + const onChangeText = jest.fn(); + let currentText = ''; + + function ControlledInput() { + const [text, setText] = useState(''); + currentText = text; + return ( + { + onChangeText(t); + setText(t); + }} + /> + ); + } + + let renderer; + ReactTestRenderer.act(() => { + renderer = ReactTestRenderer.create(); + }); + + // $FlowFixMe[incompatible-use] + const input = renderer.root.findByType(TextInput); + + const compositionSteps = [ + 't', + 'と', // "to" → hiragana + 'とう', + 'とうk', + 'とうき', + 'とうきょ', + 'とうきょう', + '東京', // kanji conversion selected + ]; + compositionSteps.forEach(step => { + ReactTestRenderer.act(() => { + enter(input, step); + }); + }); + + expect(onChangeText).toHaveBeenCalledTimes(8); + expect(currentText).toBe('東京'); + }); + + it('handles Japanese composition with candidate re-selection', () => { + // User types "hashi", gets 橋, re-selects to 箸, then confirms + const onChangeText = jest.fn(); + let currentText = ''; + + function ControlledInput() { + const [text, setText] = useState(''); + currentText = text; + return ( + { + onChangeText(t); + setText(t); + }} + /> + ); + } + + let renderer; + ReactTestRenderer.act(() => { + renderer = ReactTestRenderer.create(); + }); + + // $FlowFixMe[incompatible-use] + const input = renderer.root.findByType(TextInput); + + const compositionSteps = [ + 'h', + 'は', + 'はs', + 'はし', // "hashi" in hiragana + '橋', // first candidate + '箸', // user scrolls to different candidate + '端', // another candidate + '箸', // user goes back and confirms + ]; + compositionSteps.forEach(step => { + ReactTestRenderer.act(() => { + enter(input, step); + }); + }); + + expect(onChangeText).toHaveBeenCalledTimes(8); + expect(currentText).toBe('箸'); + }); + + it('handles Chinese Pinyin composition', () => { + // Chinese Pinyin IME: typing "zhongguo" produces: + // z → zh → zho → zhon → zhong → 中 (selected from candidates) + // Then "guo" → 国 → 中国 + const onChangeText = jest.fn(); + let currentText = ''; + + function ControlledInput() { + const [text, setText] = useState(''); + currentText = text; + return ( + { + onChangeText(t); + setText(t); + }} + /> + ); + } + + let renderer; + ReactTestRenderer.act(() => { + renderer = ReactTestRenderer.create(); + }); + + // $FlowFixMe[incompatible-use] + const input = renderer.root.findByType(TextInput); + + // First character: "zhong" → 中 + const firstChar = ['z', 'zh', 'zho', 'zhon', 'zhong', '中']; + firstChar.forEach(step => { + ReactTestRenderer.act(() => { + enter(input, step); + }); + }); + + expect(currentText).toBe('中'); + + // Second character: "guo" → 国, appended after 中 + const secondChar = ['中g', '中gu', '中guo', '中国']; + secondChar.forEach(step => { + ReactTestRenderer.act(() => { + enter(input, step); + }); + }); + + expect(onChangeText).toHaveBeenCalledTimes(10); + expect(currentText).toBe('中国'); + }); + + it('handles Chinese Wubi stroke-based input', () => { + // Wubi IME uses letter keys as stroke codes: e.g., "ggtt" → 王 + const onChangeText = jest.fn(); + let currentText = ''; + + function ControlledInput() { + const [text, setText] = useState(''); + currentText = text; + return ( + { + onChangeText(t); + setText(t); + }} + /> + ); + } + + let renderer; + ReactTestRenderer.act(() => { + renderer = ReactTestRenderer.create(); + }); + + // $FlowFixMe[incompatible-use] + const input = renderer.root.findByType(TextInput); + + // Wubi input for 王 (wang/king) + const compositionSteps = ['g', 'gg', 'ggt', 'ggtt', '王']; + compositionSteps.forEach(step => { + ReactTestRenderer.act(() => { + enter(input, step); + }); + }); + + expect(onChangeText).toHaveBeenCalledTimes(5); + expect(currentText).toBe('王'); + }); + + it('handles Chinese Zhuyin (Bopomofo) input', () => { + // Zhuyin IME (used in Taiwan): ㄓㄨㄥ → 中 + const onChangeText = jest.fn(); + let currentText = ''; + + function ControlledInput() { + const [text, setText] = useState(''); + currentText = text; + return ( + { + onChangeText(t); + setText(t); + }} + /> + ); + } + + let renderer; + ReactTestRenderer.act(() => { + renderer = ReactTestRenderer.create(); + }); + + // $FlowFixMe[incompatible-use] + const input = renderer.root.findByType(TextInput); + + const compositionSteps = ['ㄓ', 'ㄓㄨ', 'ㄓㄨㄥ', '中']; + compositionSteps.forEach(step => { + ReactTestRenderer.act(() => { + enter(input, step); + }); + }); + + expect(onChangeText).toHaveBeenCalledTimes(4); + expect(currentText).toBe('中'); + }); + + it('handles Korean multi-syllable composition', () => { + // Korean IME builds syllables incrementally: + // ㄱ → 가 → 감 → 감ㅅ → 감사 → 감사ㅎ → 감사하 → 감사합 → 감사합ㄴ → 감사합니 → 감사합니ㄷ → 감사합니다 + const onChangeText = jest.fn(); + let currentText = ''; + + function ControlledInput() { + const [text, setText] = useState(''); + currentText = text; + return ( + { + onChangeText(t); + setText(t); + }} + /> + ); + } + + let renderer; + ReactTestRenderer.act(() => { + renderer = ReactTestRenderer.create(); + }); + + // $FlowFixMe[incompatible-use] + const input = renderer.root.findByType(TextInput); + + const compositionSteps = [ + 'ㄱ', + '가', + '감', + '감ㅅ', + '감사', + '감사ㅎ', + '감사하', + '감사합', + '감사합ㄴ', + '감사합니', + '감사합니ㄷ', + '감사합니다', + ]; + compositionSteps.forEach(step => { + ReactTestRenderer.act(() => { + enter(input, step); + }); + }); + + expect(onChangeText).toHaveBeenCalledTimes(12); + expect(currentText).toBe('감사합니다'); + }); + + it('preserves existing text when composition appends', () => { + // User types "hello" then starts CJK composition: "hello" → "helloㅎ" → "hello하" → "hello한" + const onChangeText = jest.fn(); + let currentText = ''; + + function ControlledInput() { + const [text, setText] = useState('hello'); + currentText = text; + return ( + { + onChangeText(t); + setText(t); + }} + /> + ); + } + + let renderer; + ReactTestRenderer.act(() => { + renderer = ReactTestRenderer.create(); + }); + + // $FlowFixMe[incompatible-use] + const input = renderer.root.findByType(TextInput); + + ReactTestRenderer.act(() => { + enter(input, 'helloㅎ'); + }); + ReactTestRenderer.act(() => { + enter(input, 'hello하'); + }); + ReactTestRenderer.act(() => { + enter(input, 'hello한'); + }); + + expect(onChangeText).toHaveBeenCalledTimes(3); + expect(currentText).toBe('hello한'); + }); + }); + + describe('controlled component with maxLength and CJK', () => { + it('allows composition text through onChange even at maxLength boundary', () => { + // With maxLength=5 and existing text "1234", native allows composition + // past the limit during IME (enforced after commit). The JS side should + // receive the full text from native's onChange event. + const onChangeText = jest.fn(); + + function ControlledInput() { + const [text, setText] = useState('1234'); + return ( + { + onChangeText(t); + setText(t); + }} + /> + ); + } + + let renderer; + ReactTestRenderer.act(() => { + renderer = ReactTestRenderer.create(); + }); + + // $FlowFixMe[incompatible-use] + const input = renderer.root.findByType(TextInput); + + // During composition, native sends text that may temporarily exceed maxLength. + // JS receives it as-is from native onChange. + ReactTestRenderer.act(() => { + enter(input, '1234ㅎ'); + }); + expect(onChangeText).toHaveBeenLastCalledWith('1234ㅎ'); + + // After composition commits, native truncates and sends final text. + ReactTestRenderer.act(() => { + enter(input, '1234한'); + }); + expect(onChangeText).toHaveBeenLastCalledWith('1234한'); + + // Native may send truncated text after enforcing maxLength post-commit. + ReactTestRenderer.act(() => { + enter(input, '12345'); + }); + expect(onChangeText).toHaveBeenLastCalledWith('12345'); + }); + }); + + describe('uncontrolled component with CJK composition', () => { + it('fires onChange and onChangeText during composition', () => { + const onChange = jest.fn(); + const onChangeText = jest.fn(); + + let renderer; + ReactTestRenderer.act(() => { + renderer = ReactTestRenderer.create( + , + ); + }); + + // $FlowFixMe[incompatible-use] + const input = renderer.root.findByType(TextInput); + + ReactTestRenderer.act(() => { + enter(input, 'ㅎ'); + }); + ReactTestRenderer.act(() => { + enter(input, '한'); + }); + + expect(onChange).toHaveBeenCalledTimes(2); + expect(onChangeText).toHaveBeenCalledTimes(2); + expect(onChangeText.mock.calls).toEqual([['ㅎ'], ['한']]); + }); + }); + + describe('multiline with CJK composition', () => { + it('handles composition in multiline mode', () => { + const onChangeText = jest.fn(); + let currentText = ''; + + function ControlledMultiline() { + const [text, setText] = useState('line1\n'); + currentText = text; + return ( + { + onChangeText(t); + setText(t); + }} + /> + ); + } + + let renderer; + ReactTestRenderer.act(() => { + renderer = ReactTestRenderer.create(); + }); + + // $FlowFixMe[incompatible-use] + const input = renderer.root.findByType(TextInput); + + // Composition on second line + ReactTestRenderer.act(() => { + enter(input, 'line1\nㅎ'); + }); + ReactTestRenderer.act(() => { + enter(input, 'line1\n한'); + }); + ReactTestRenderer.act(() => { + enter(input, 'line1\n한글'); + }); + + expect(onChangeText).toHaveBeenCalledTimes(3); + expect(currentText).toBe('line1\n한글'); + }); + }); + + describe('mixed Latin and CJK input', () => { + it('handles switching from Latin to Japanese mid-sentence', () => { + // User types "Hello " in Latin, then switches to Japanese IME + const onChangeText = jest.fn(); + let currentText = ''; + + function ControlledInput() { + const [text, setText] = useState(''); + currentText = text; + return ( + { + onChangeText(t); + setText(t); + }} + /> + ); + } + + let renderer; + ReactTestRenderer.act(() => { + renderer = ReactTestRenderer.create(); + }); + + // $FlowFixMe[incompatible-use] + const input = renderer.root.findByType(TextInput); + + // Latin portion typed directly + ReactTestRenderer.act(() => { + enter(input, 'Hello '); + }); + + // Japanese IME activated, typing "sekai" → 世界 + const compositionSteps = [ + 'Hello s', + 'Hello せ', + 'Hello せk', + 'Hello せか', + 'Hello せかi', + 'Hello せかい', + 'Hello 世界', + ]; + compositionSteps.forEach(step => { + ReactTestRenderer.act(() => { + enter(input, step); + }); + }); + + expect(currentText).toBe('Hello 世界'); + }); + + it('handles switching from Chinese to Latin mid-sentence', () => { + // User types Chinese first, then switches to Latin + const onChangeText = jest.fn(); + let currentText = ''; + + function ControlledInput() { + const [text, setText] = useState(''); + currentText = text; + return ( + { + onChangeText(t); + setText(t); + }} + /> + ); + } + + let renderer; + ReactTestRenderer.act(() => { + renderer = ReactTestRenderer.create(); + }); + + // $FlowFixMe[incompatible-use] + const input = renderer.root.findByType(TextInput); + + // Chinese Pinyin: "ni" → 你 + ['n', 'ni', '你'].forEach(step => { + ReactTestRenderer.act(() => { + enter(input, step); + }); + }); + + // Chinese Pinyin: "hao" → 好 + ['你h', '你ha', '你hao', '你好'].forEach(step => { + ReactTestRenderer.act(() => { + enter(input, step); + }); + }); + + // Switch to Latin and type directly + ReactTestRenderer.act(() => { + enter(input, '你好 World'); + }); + + expect(currentText).toBe('你好 World'); + }); + + it('handles Korean input between Latin words', () => { + // "React 네이티브 is great" + const onChangeText = jest.fn(); + let currentText = ''; + + function ControlledInput() { + const [text, setText] = useState(''); + currentText = text; + return ( + { + onChangeText(t); + setText(t); + }} + /> + ); + } + + let renderer; + ReactTestRenderer.act(() => { + renderer = ReactTestRenderer.create(); + }); + + // $FlowFixMe[incompatible-use] + const input = renderer.root.findByType(TextInput); + + // "React " typed in Latin + ReactTestRenderer.act(() => { + enter(input, 'React '); + }); + + // Korean composition: 네이티브 + const koreanSteps = [ + 'React ㄴ', + 'React 네', + 'React 네ㅇ', + 'React 네이', + 'React 네이ㅌ', + 'React 네이티', + 'React 네이티ㅂ', + 'React 네이티브', + ]; + koreanSteps.forEach(step => { + ReactTestRenderer.act(() => { + enter(input, step); + }); + }); + + // Back to Latin + ReactTestRenderer.act(() => { + enter(input, 'React 네이티브 is great'); + }); + + expect(currentText).toBe('React 네이티브 is great'); + }); + }); + + describe('continuous CJK sentence composition', () => { + it('handles Japanese sentence with multiple conversions', () => { + // Typing "watashiha" → 私は, then "gakusei" → 学生, then "desu" → です + const onChangeText = jest.fn(); + let currentText = ''; + + function ControlledInput() { + const [text, setText] = useState(''); + currentText = text; + return ( + { + onChangeText(t); + setText(t); + }} + /> + ); + } + + let renderer; + ReactTestRenderer.act(() => { + renderer = ReactTestRenderer.create(); + }); + + // $FlowFixMe[incompatible-use] + const input = renderer.root.findByType(TextInput); + + // First word: watashi → 私 + ['w', 'わ', 'わt', 'わた', 'わたs', 'わたし', '私'].forEach(step => { + ReactTestRenderer.act(() => { + enter(input, step); + }); + }); + + // Particle: ha → は + ['私h', '私は'].forEach(step => { + ReactTestRenderer.act(() => { + enter(input, step); + }); + }); + + // Second word: gakusei → 学生 + [ + '私はg', + '私はが', + '私はがk', + '私はがく', + '私はがくs', + '私はがくせ', + '私はがくせi', + '私はがくせい', + '私は学生', + ].forEach(step => { + ReactTestRenderer.act(() => { + enter(input, step); + }); + }); + + // Copula: desu → です + ['私は学生d', '私は学生で', '私は学生でs', '私は学生です'].forEach( + step => { + ReactTestRenderer.act(() => { + enter(input, step); + }); + }, + ); + + expect(currentText).toBe('私は学生です'); + }); + + it('handles Chinese Pinyin sentence input', () => { + // Typing "wo ai zhongguo" → 我爱中国 + const onChangeText = jest.fn(); + let currentText = ''; + + function ControlledInput() { + const [text, setText] = useState(''); + currentText = text; + return ( + { + onChangeText(t); + setText(t); + }} + /> + ); + } + + let renderer; + ReactTestRenderer.act(() => { + renderer = ReactTestRenderer.create(); + }); + + // $FlowFixMe[incompatible-use] + const input = renderer.root.findByType(TextInput); + + // "wo" → 我 + ['w', 'wo', '我'].forEach(step => { + ReactTestRenderer.act(() => { + enter(input, step); + }); + }); + + // "ai" → 爱 + ['我a', '我ai', '我爱'].forEach(step => { + ReactTestRenderer.act(() => { + enter(input, step); + }); + }); + + // "zhongguo" → 中国 + [ + '我爱z', + '我爱zh', + '我爱zho', + '我爱zhon', + '我爱zhong', + '我爱中', + '我爱中g', + '我爱中gu', + '我爱中guo', + '我爱中国', + ].forEach(step => { + ReactTestRenderer.act(() => { + enter(input, step); + }); + }); + + expect(currentText).toBe('我爱中国'); + }); + }); + + describe('value prop change during simulated composition', () => { + it('controlled component can set value after composition text arrives', () => { + // Simulates: native fires onChange with composition text, then JS + // transforms the text (e.g., uppercase) and sets it back. + const ref = createRef>(); + + function TransformingInput() { + const [text, setText] = useState(''); + return ( + { + // Transform: just accept the text as-is + setText(t); + }} + /> + ); + } + + let renderer; + ReactTestRenderer.act(() => { + renderer = ReactTestRenderer.create(); + }); + + // $FlowFixMe[incompatible-use] + const input = renderer.root.findByType(TextInput); + + // Composition intermediate + ReactTestRenderer.act(() => { + enter(input, '한'); + }); + + expect(input.props.value).toBe('한'); + + // Final committed text + ReactTestRenderer.act(() => { + enter(input, '한글'); + }); + + expect(input.props.value).toBe('한글'); + }); + }); +}); diff --git a/packages/react-native/Libraries/Text/TextInput/Multiline/RCTUITextView.mm b/packages/react-native/Libraries/Text/TextInput/Multiline/RCTUITextView.mm index cbda3771e97c..e9e870d142df 100644 --- a/packages/react-native/Libraries/Text/TextInput/Multiline/RCTUITextView.mm +++ b/packages/react-native/Libraries/Text/TextInput/Multiline/RCTUITextView.mm @@ -13,6 +13,10 @@ #import #import +// Must match RCTAttributedStringEventEmitterKey in RCTAttributedTextUtils.h +// (cannot import directly — Libraries/Text must not depend on ReactCommon). +static NSString *const kRCTEventEmitterAttributeKey = @"EventEmitter"; + @implementation RCTUITextView { UILabel *_placeholderView; UITextView *_detachedTextView; @@ -135,7 +139,19 @@ - (void)setDefaultTextAttributes:(NSDictionary *)defa } _defaultTextAttributes = defaultTextAttributes; - self.typingAttributes = defaultTextAttributes; + // Strip attributes that interfere with UIKit's IME composition underline rendering. + // Only remove no-op defaults; preserve user-specified values. + NSMutableDictionary *typingAttrs = [defaultTextAttributes mutableCopy]; + [typingAttrs removeObjectForKey:kRCTEventEmitterAttributeKey]; + NSShadow *shadow = typingAttrs[NSShadowAttributeName]; + if (shadow && CGSizeEqualToSize(shadow.shadowOffset, CGSizeZero) && shadow.shadowBlurRadius == 0) { + [typingAttrs removeObjectForKey:NSShadowAttributeName]; + } + UIColor *bgColor = typingAttrs[NSBackgroundColorAttributeName]; + if (bgColor && CGColorGetAlpha(bgColor.CGColor) == 0) { + [typingAttrs removeObjectForKey:NSBackgroundColorAttributeName]; + } + self.typingAttributes = typingAttrs; [self _updatePlaceholder]; } diff --git a/packages/react-native/Libraries/Text/TextInput/Singleline/RCTUITextField.mm b/packages/react-native/Libraries/Text/TextInput/Singleline/RCTUITextField.mm index 052c003476d1..9d97e6959074 100644 --- a/packages/react-native/Libraries/Text/TextInput/Singleline/RCTUITextField.mm +++ b/packages/react-native/Libraries/Text/TextInput/Singleline/RCTUITextField.mm @@ -12,6 +12,10 @@ #import #import +// Must match RCTAttributedStringEventEmitterKey in RCTAttributedTextUtils.h +// (cannot import directly — Libraries/Text must not depend on ReactCommon). +static NSString *const kRCTEventEmitterAttributeKey = @"EventEmitter"; + @implementation RCTUITextField { RCTBackedTextFieldDelegateAdapter *_textInputDelegateAdapter; NSDictionary *_defaultTextAttributes; @@ -93,7 +97,22 @@ - (void)setDefaultTextAttributes:(NSDictionary *)defa } _defaultTextAttributes = defaultTextAttributes; - [super setDefaultTextAttributes:defaultTextAttributes]; + // Strip attributes that interfere with UIKit's IME composition underline rendering. + // NSShadow and NSBackgroundColor prevent UIKit from drawing the composition underline + // on marked text, but only remove them when they are no-op defaults (empty shadow, + // clear background) — preserve user-specified values. + // EventEmitter is a React-internal attribute (NSData wrapping C++ weak_ptr). + NSMutableDictionary *uikitAttrs = [defaultTextAttributes mutableCopy]; + [uikitAttrs removeObjectForKey:kRCTEventEmitterAttributeKey]; + NSShadow *shadow = uikitAttrs[NSShadowAttributeName]; + if (shadow && CGSizeEqualToSize(shadow.shadowOffset, CGSizeZero) && shadow.shadowBlurRadius == 0) { + [uikitAttrs removeObjectForKey:NSShadowAttributeName]; + } + UIColor *bgColor = uikitAttrs[NSBackgroundColorAttributeName]; + if (bgColor && CGColorGetAlpha(bgColor.CGColor) == 0) { + [uikitAttrs removeObjectForKey:NSBackgroundColorAttributeName]; + } + [super setDefaultTextAttributes:uikitAttrs]; [self _updatePlaceholder]; } diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm b/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm index cbbc402de42f..b34fd816012a 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm @@ -80,6 +80,14 @@ @implementation RCTTextInputComponentView { BOOL _hasInputAccessoryView; CGSize _previousContentSize; + + /* + * When IME composition is active (markedTextRange != nil), we defer updating + * defaultTextAttributes to avoid destroying the composition underline. + * See: https://github.com/facebook/react-native/issues/48497 + */ + BOOL _needsUpdateDefaultTextAttributes; + NSDictionary *_pendingDefaultTextAttributes; } #pragma mark - UIView overrides @@ -109,11 +117,24 @@ - (void)updateEventEmitter:(const EventEmitter::Shared &)eventEmitter { [super updateEventEmitter:eventEmitter]; + // Use pending attributes as the base if they exist (e.g., from a prior updateProps + // that deferred during composition), to avoid clobbering deferred text-style changes. NSMutableDictionary *defaultAttributes = - [_backedTextInputView.defaultTextAttributes mutableCopy]; + _pendingDefaultTextAttributes + ? [_pendingDefaultTextAttributes mutableCopy] + : [_backedTextInputView.defaultTextAttributes mutableCopy]; defaultAttributes[RCTAttributedStringEventEmitterKey] = RCTWrapEventEmitter(_eventEmitter); + // During IME composition, skip setting defaultTextAttributes. + // UITextField.setDefaultTextAttributes reapplies attributes to the entire text, + // which removes the composition underline and breaks the IME state. + if (_backedTextInputView.markedTextRange) { + _needsUpdateDefaultTextAttributes = YES; + _pendingDefaultTextAttributes = [defaultAttributes copy]; + return; + } + _backedTextInputView.defaultTextAttributes = defaultAttributes; } @@ -143,8 +164,26 @@ - (void)traitCollectionDidChange:(UITraitCollection *)previousTraitCollection UITraitCollection.currentTraitCollection.preferredContentSizeCategory != previousTraitCollection.preferredContentSizeCategory) { const auto &newTextInputProps = static_cast(*_props); - _backedTextInputView.defaultTextAttributes = + NSMutableDictionary *attributes = RCTNSTextAttributesFromTextAttributes(newTextInputProps.getEffectiveTextAttributes(RCTFontSizeMultiplier())); + if (_backedTextInputView.markedTextRange) { + // Preserve the event emitter key from any prior pending update (e.g., from updateEventEmitter) + // or from the current view attributes, since RCTNSTextAttributesFromTextAttributes does not include it. + id emitter = _pendingDefaultTextAttributes[RCTAttributedStringEventEmitterKey] + ?: _backedTextInputView.defaultTextAttributes[RCTAttributedStringEventEmitterKey]; + if (emitter) { + attributes[RCTAttributedStringEventEmitterKey] = emitter; + } + _needsUpdateDefaultTextAttributes = YES; + _pendingDefaultTextAttributes = [attributes copy]; + } else { + // Preserve the event emitter key since RCTNSTextAttributesFromTextAttributes does not include it. + id emitter = _backedTextInputView.defaultTextAttributes[RCTAttributedStringEventEmitterKey]; + if (emitter) { + attributes[RCTAttributedStringEventEmitterKey] = emitter; + } + _backedTextInputView.defaultTextAttributes = attributes; + } } } @@ -295,9 +334,17 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared & if (newTextInputProps.textAttributes != oldTextInputProps.textAttributes) { NSMutableDictionary *defaultAttributes = RCTNSTextAttributesFromTextAttributes(newTextInputProps.getEffectiveTextAttributes(RCTFontSizeMultiplier())); + // If updateEventEmitter already deferred a pending update with the new event emitter, + // use it instead of the stale one on the view that hasn't been updated yet. + id pendingEmitter = _pendingDefaultTextAttributes[RCTAttributedStringEventEmitterKey]; defaultAttributes[RCTAttributedStringEventEmitterKey] = - _backedTextInputView.defaultTextAttributes[RCTAttributedStringEventEmitterKey]; - _backedTextInputView.defaultTextAttributes = defaultAttributes; + pendingEmitter ?: _backedTextInputView.defaultTextAttributes[RCTAttributedStringEventEmitterKey]; + if (_backedTextInputView.markedTextRange) { + _needsUpdateDefaultTextAttributes = YES; + _pendingDefaultTextAttributes = [defaultAttributes copy]; + } else { + _backedTextInputView.defaultTextAttributes = defaultAttributes; + } } if (newTextInputProps.selectionColor != oldTextInputProps.selectionColor) { @@ -384,6 +431,8 @@ - (void)prepareForRecycle _lastStringStateWasUpdatedWith = nil; _ignoreNextTextInputCall = NO; _didMoveToWindow = NO; + _needsUpdateDefaultTextAttributes = NO; + _pendingDefaultTextAttributes = nil; _backedTextInputView.inputAccessoryViewID = nil; _backedTextInputView.inputAccessoryView = nil; _hasInputAccessoryView = false; @@ -457,7 +506,9 @@ - (NSString *)textInputShouldChangeText:(NSString *)text inRange:(NSRange)range } } - if (props.maxLength < std::numeric_limits::max()) { + // Defer maxLength enforcement during IME composition — it will be applied + // after the composition is committed (in textInputDidChange). + if (props.maxLength < std::numeric_limits::max() && !_backedTextInputView.markedTextRange) { NSInteger allowedLength = props.maxLength - _backedTextInputView.attributedText.string.length + range.length; if (allowedLength > 0 && text.length > allowedLength) { @@ -490,12 +541,58 @@ - (void)textInputDidChange return; } - if (_ignoreNextTextInputCall && [_lastStringStateWasUpdatedWith isEqual:_backedTextInputView.attributedText]) { + if (_ignoreNextTextInputCall && + [_lastStringStateWasUpdatedWith.string isEqualToString:_backedTextInputView.attributedText.string]) { _ignoreNextTextInputCall = NO; return; } - [self _updateState]; + // After composition ends, apply any pending defaultTextAttributes that were + // deferred during IME composition (Fix 1). + if (_needsUpdateDefaultTextAttributes && !_backedTextInputView.markedTextRange) { + _needsUpdateDefaultTextAttributes = NO; + if (_pendingDefaultTextAttributes) { + _backedTextInputView.defaultTextAttributes = _pendingDefaultTextAttributes; + _pendingDefaultTextAttributes = nil; + } + } + + // After composition ends, enforce maxLength by truncating if needed (Fix 3). + if (!_backedTextInputView.markedTextRange) { + const auto &props = static_cast(*_props); + if (props.maxLength < std::numeric_limits::max()) { + NSString *currentText = _backedTextInputView.attributedText.string; + if ((NSInteger)currentText.length > props.maxLength) { + NSInteger truncateAt = props.maxLength; + // Ensure we don't split multi-codepoint characters (emoji, composed CJK, etc.) + if (truncateAt > 0) { + NSRange charRange = [currentText rangeOfComposedCharacterSequenceAtIndex:truncateAt - 1]; + if (charRange.location + charRange.length > (NSUInteger)truncateAt) { + truncateAt = charRange.location; + } + } + { + NSString *truncated = truncateAt > 0 ? [currentText substringToIndex:truncateAt] : @""; + NSAttributedString *truncatedAttr = + [[NSAttributedString alloc] initWithString:truncated + attributes:_backedTextInputView.defaultTextAttributes]; + // Suppress delegate callbacks from _setAttributedString to prevent + // textInputDidChangeSelection from triggering a recursive textInputDidChange. + // This is an internal truncation, not a user or JS action. + _comingFromJS = YES; + [self _setAttributedString:truncatedAttr]; + _comingFromJS = NO; + } + } + } + } + + // During IME composition, defer _updateState to avoid Fabric round-trips + // that can interfere with UIKit's marked text rendering. + // State will be pushed when composition commits (markedTextRange becomes nil). + if (!_backedTextInputView.markedTextRange) { + [self _updateState]; + } if (_eventEmitter) { const auto &textInputEventEmitter = static_cast(*_eventEmitter); @@ -515,7 +612,8 @@ - (void)textInputDidChangeSelection [self _updateTypingAttributes]; const auto &props = static_cast(*_props); - if (props.multiline && ![_lastStringStateWasUpdatedWith isEqual:_backedTextInputView.attributedText]) { + if (props.multiline && !_backedTextInputView.markedTextRange && + ![_lastStringStateWasUpdatedWith.string isEqualToString:_backedTextInputView.attributedText.string]) { [self textInputDidChange]; _ignoreNextTextInputCall = YES; } @@ -575,24 +673,34 @@ - (void)setTextAndSelection:(NSInteger)eventCount } _comingFromJS = YES; if (value && ![value isEqualToString:_backedTextInputView.attributedText.string]) { - NSAttributedString *attributedString = - [[NSAttributedString alloc] initWithString:value attributes:_backedTextInputView.defaultTextAttributes]; - [self _setAttributedString:attributedString]; - [self _updateState]; - } - - UITextPosition *startPosition = [_backedTextInputView positionFromPosition:_backedTextInputView.beginningOfDocument - offset:start]; - UITextPosition *endPosition = [_backedTextInputView positionFromPosition:_backedTextInputView.beginningOfDocument - offset:end]; - - if (startPosition && endPosition) { - UITextRange *range = [_backedTextInputView textRangeFromPosition:startPosition toPosition:endPosition]; - [_backedTextInputView setSelectedTextRange:range notifyDelegate:NO]; - // ensure we scroll to the selected position - NSInteger offsetEnd = [_backedTextInputView offsetFromPosition:_backedTextInputView.beginningOfDocument - toPosition:range.end]; - [_backedTextInputView scrollRangeToVisible:NSMakeRange(offsetEnd, 0)]; + if (!_backedTextInputView.markedTextRange) { + NSAttributedString *attributedString = + [[NSAttributedString alloc] initWithString:value attributes:_backedTextInputView.defaultTextAttributes]; + [self _setAttributedString:attributedString]; + [self _updateState]; + } + // During IME composition, skip JS-driven text updates entirely. + // The composition will commit via textInputDidChange, after which + // JS can re-assert its controlled value through a new setTextAndSelection call. + } + + // During IME composition, skip selection updates from JS. + // Calling setSelectedTextRange: while markedTextRange is active causes UIKit + // to clear the marked text range, destroying the composition underline. + if (!_backedTextInputView.markedTextRange) { + UITextPosition *startPosition = [_backedTextInputView positionFromPosition:_backedTextInputView.beginningOfDocument + offset:start]; + UITextPosition *endPosition = [_backedTextInputView positionFromPosition:_backedTextInputView.beginningOfDocument + offset:end]; + + if (startPosition && endPosition) { + UITextRange *range = [_backedTextInputView textRangeFromPosition:startPosition toPosition:endPosition]; + [_backedTextInputView setSelectedTextRange:range notifyDelegate:NO]; + // ensure we scroll to the selected position + NSInteger offsetEnd = [_backedTextInputView offsetFromPosition:_backedTextInputView.beginningOfDocument + toPosition:range.end]; + [_backedTextInputView scrollRangeToVisible:NSMakeRange(offsetEnd, 0)]; + } } _comingFromJS = NO; } @@ -768,6 +876,12 @@ - (void)_restoreTextSelectionAndIgnoreCaretChange:(BOOL)ignore - (void)_setAttributedString:(NSAttributedString *)attributedString { + // During IME composition, skip replacing attributed text to preserve markedTextRange. + // The final text will be synced via textInputDidChange -> _updateState after composition ends. + if (_backedTextInputView.markedTextRange) { + return; + } + if ([self _textOf:attributedString equals:_backedTextInputView.attributedText]) { return; } @@ -806,8 +920,20 @@ - (void)_updateTypingAttributes toPosition:_backedTextInputView.selectedTextRange.start]; NSUInteger samplePoint = offsetStart == 0 ? 0 : offsetStart - 1; - _backedTextInputView.typingAttributes = [_backedTextInputView.attributedText attributesAtIndex:samplePoint - effectiveRange:NULL]; + NSMutableDictionary *attrs = [[_backedTextInputView.attributedText attributesAtIndex:samplePoint + effectiveRange:NULL] mutableCopy]; + // Strip attributes that interfere with UIKit's IME composition underline rendering. + // Only remove no-op defaults; preserve user-specified values. + [attrs removeObjectForKey:RCTAttributedStringEventEmitterKey]; + NSShadow *shadow = attrs[NSShadowAttributeName]; + if (shadow && CGSizeEqualToSize(shadow.shadowOffset, CGSizeZero) && shadow.shadowBlurRadius == 0) { + [attrs removeObjectForKey:NSShadowAttributeName]; + } + UIColor *bgColor = attrs[NSBackgroundColorAttributeName]; + if (bgColor && CGColorGetAlpha(bgColor.CGColor) == 0) { + [attrs removeObjectForKey:NSBackgroundColorAttributeName]; + } + _backedTextInputView.typingAttributes = attrs; } } diff --git a/packages/rn-tester/RNTesterPods.xcodeproj/project.pbxproj b/packages/rn-tester/RNTesterPods.xcodeproj/project.pbxproj index 898cd14e1ca5..a0f19e957004 100644 --- a/packages/rn-tester/RNTesterPods.xcodeproj/project.pbxproj +++ b/packages/rn-tester/RNTesterPods.xcodeproj/project.pbxproj @@ -19,6 +19,7 @@ 832F45BB2A8A6E1F0097B4E6 /* SwiftTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 832F45BA2A8A6E1F0097B4E6 /* SwiftTest.swift */; }; A975CA6C2C05EADF0043F72A /* RCTNetworkTaskTests.m in Sources */ = {isa = PBXBuildFile; fileRef = A975CA6B2C05EADE0043F72A /* RCTNetworkTaskTests.m */; }; C175B6D9ED9336FB66637943 /* libPods-RNTester.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 4C706D402EE4AF9BE838CBA9 /* libPods-RNTester.a */; }; + 5C175C1C2966CE7C263BDE88 /* RCTTextInputComponentViewIMETests.mm in Sources */ = {isa = PBXBuildFile; fileRef = 8D48EA169B37CE411049B4C1 /* RCTTextInputComponentViewIMETests.mm */; }; CD10C7A5290BD4EB0033E1ED /* RCTEventEmitterTests.m in Sources */ = {isa = PBXBuildFile; fileRef = CD10C7A4290BD4EB0033E1ED /* RCTEventEmitterTests.m */; }; E62F11832A5C6580000BF1C8 /* FlexibleSizeExampleView.mm in Sources */ = {isa = PBXBuildFile; fileRef = 27F441E81BEBE5030039B79C /* FlexibleSizeExampleView.mm */; }; E62F11842A5C6584000BF1C8 /* UpdatePropertiesExampleView.mm in Sources */ = {isa = PBXBuildFile; fileRef = 272E6B3C1BEA849E001FCF37 /* UpdatePropertiesExampleView.mm */; }; @@ -98,6 +99,7 @@ AC474BFB29BBD4A1002BDAED /* RNTester.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; name = RNTester.xctestplan; path = RNTester/RNTester.xctestplan; sourceTree = ""; }; B0E70A8A05E03E868F8703FE /* Pods-RNTesterIntegrationTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RNTesterIntegrationTests.release.xcconfig"; path = "Target Support Files/Pods-RNTesterIntegrationTests/Pods-RNTesterIntegrationTests.release.xcconfig"; sourceTree = ""; }; CA59C9994B1822826D8983F0 /* Pods-RNTester.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RNTester.debug.xcconfig"; path = "Target Support Files/Pods-RNTester/Pods-RNTester.debug.xcconfig"; sourceTree = ""; }; + 8D48EA169B37CE411049B4C1 /* RCTTextInputComponentViewIMETests.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; path = RCTTextInputComponentViewIMETests.mm; sourceTree = ""; }; CD10C7A4290BD4EB0033E1ED /* RCTEventEmitterTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RCTEventEmitterTests.m; sourceTree = ""; }; D134EB89DD98253FCF879A47 /* Pods-RNTesterUnitTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RNTesterUnitTests.debug.xcconfig"; path = "Target Support Files/Pods-RNTesterUnitTests/Pods-RNTesterUnitTests.debug.xcconfig"; sourceTree = ""; }; E771AEEA22B44E3100EA1189 /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = RNTester/Info.plist; sourceTree = ""; }; @@ -336,6 +338,7 @@ A975CA6B2C05EADE0043F72A /* RCTNetworkTaskTests.m */, E7DB20BE22B2BAA4005AC45F /* RCTNativeAnimatedNodesManagerTests.m */, E7DB20AD22B2BAA3005AC45F /* RCTPerformanceLoggerTests.m */, + 8D48EA169B37CE411049B4C1 /* RCTTextInputComponentViewIMETests.mm */, E7DB20C122B2BAA4005AC45F /* RCTUnicodeDecodeTests.m */, E7DB20D022B2BAA5005AC45F /* RCTURLUtilsTests.m */, E7DB20B322B2BAA4005AC45F /* RNTesterUnitTestsBundle.js */, @@ -764,6 +767,7 @@ E7DB20EB22B2BAA6005AC45F /* RCTConvert_YGValueTests.m in Sources */, E7DB20E922B2BAA6005AC45F /* RCTComponentPropsTests.m in Sources */, E7DB20D822B2BAA6005AC45F /* RCTJSONTests.m in Sources */, + 5C175C1C2966CE7C263BDE88 /* RCTTextInputComponentViewIMETests.mm in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/packages/rn-tester/RNTesterUnitTests/RCTTextInputComponentViewIMETests.mm b/packages/rn-tester/RNTesterUnitTests/RCTTextInputComponentViewIMETests.mm new file mode 100644 index 000000000000..96a4a4057fbc --- /dev/null +++ b/packages/rn-tester/RNTesterUnitTests/RCTTextInputComponentViewIMETests.mm @@ -0,0 +1,719 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import +#import +#import + +#import +#import + +#import + +/** + * Mock UITextField subclass that allows overriding markedTextRange for testing + * IME composition scenarios without requiring actual keyboard input. + */ +@interface RCTMockTextField : RCTUITextField +@property (nonatomic, strong, nullable) UITextRange *mockMarkedTextRange; +@end + +@implementation RCTMockTextField + +- (UITextRange *)markedTextRange +{ + return _mockMarkedTextRange; +} + +@end + +/** + * Mock UITextView subclass for multiline IME composition testing. + */ +@interface RCTMockTextView : RCTUITextView +@property (nonatomic, strong, nullable) UITextRange *mockMarkedTextRange; +@end + +@implementation RCTMockTextView + +- (UITextRange *)markedTextRange +{ + return _mockMarkedTextRange; +} + +@end + +/** + * Helper to create a non-nil UITextRange for simulating IME composition. + */ +static UITextRange *createMockTextRange(UITextField *textField) +{ + UITextPosition *start = textField.beginningOfDocument; + UITextPosition *end = [textField positionFromPosition:start offset:1]; + if (!end) { + end = start; + } + return [textField textRangeFromPosition:start toPosition:end ?: start]; +} + +static UITextRange *createMockTextRangeForTextView(UITextView *textView) +{ + UITextPosition *start = textView.beginningOfDocument; + UITextPosition *end = [textView positionFromPosition:start offset:1]; + if (!end) { + end = start; + } + return [textView textRangeFromPosition:start toPosition:end ?: start]; +} + +#pragma mark - Test class + +@interface RCTTextInputComponentViewIMETests : XCTestCase + +@end + +@implementation RCTTextInputComponentViewIMETests + +#pragma mark - Helpers + +- (RCTTextInputComponentView *)createSingleLineWithMock:(RCTMockTextField **)outMock +{ + RCTTextInputComponentView *cv = [[RCTTextInputComponentView alloc] initWithFrame:CGRectZero]; + RCTMockTextField *mock = [[RCTMockTextField alloc] initWithFrame:CGRectZero]; + [cv setValue:mock forKey:@"_backedTextInputView"]; + mock.textInputDelegate = (id)cv; + if (outMock) { + *outMock = mock; + } + return cv; +} + +- (RCTTextInputComponentView *)createMultiLineWithMock:(RCTMockTextView **)outMock +{ + RCTTextInputComponentView *cv = [[RCTTextInputComponentView alloc] initWithFrame:CGRectZero]; + RCTMockTextView *mock = [[RCTMockTextView alloc] initWithFrame:CGRectZero]; + [cv setValue:mock forKey:@"_backedTextInputView"]; + mock.textInputDelegate = (id)cv; + if (outMock) { + *outMock = mock; + } + return cv; +} + +#pragma mark - Korean IME lifecycle + +- (void)testKoreanCompositionLifecycle +{ + // Simulates the full UIKit delegate call sequence for typing "한": + // ㅎ (composing) → 하 (composing) → 한 (composing) → 한 (committed) + RCTMockTextField *mock; + RCTTextInputComponentView *cv = [self createSingleLineWithMock:&mock]; + id delegate = (id)cv; + + NSDictionary *ulAttrs = @{NSUnderlineStyleAttributeName : @(NSUnderlineStyleSingle)}; + + // Step 1: ㅎ — composition starts + mock.mockMarkedTextRange = nil; + [delegate textInputShouldChangeText:@"ㅎ" inRange:NSMakeRange(0, 0)]; + mock.attributedText = [[NSAttributedString alloc] initWithString:@"ㅎ" attributes:ulAttrs]; + mock.mockMarkedTextRange = createMockTextRange(mock); + [delegate textInputDidChange]; + XCTAssertEqualObjects(mock.attributedText.string, @"ㅎ"); + + // Step 2: 하 — vowel added + [delegate textInputShouldChangeText:@"하" inRange:NSMakeRange(0, 1)]; + mock.attributedText = [[NSAttributedString alloc] initWithString:@"하" attributes:ulAttrs]; + [delegate textInputDidChange]; + XCTAssertEqualObjects(mock.attributedText.string, @"하"); + + // Step 3: 한 — final consonant added + [delegate textInputShouldChangeText:@"한" inRange:NSMakeRange(0, 1)]; + mock.attributedText = [[NSAttributedString alloc] initWithString:@"한" attributes:ulAttrs]; + [delegate textInputDidChange]; + XCTAssertEqualObjects(mock.attributedText.string, @"한"); + + // Step 4: commit — markedTextRange cleared, underline removed + mock.mockMarkedTextRange = nil; + mock.attributedText = [[NSAttributedString alloc] initWithString:@"한"]; + [delegate textInputDidChange]; + XCTAssertEqualObjects(mock.attributedText.string, @"한"); +} + +#pragma mark - Japanese romaji lifecycle + +- (void)testJapaneseRomajiCompositionLifecycle +{ + // k → か → かn → かん → 漢 (candidate) → commit + RCTMockTextField *mock; + RCTTextInputComponentView *cv = [self createSingleLineWithMock:&mock]; + id delegate = (id)cv; + + NSDictionary *ul = @{NSUnderlineStyleAttributeName : @(NSUnderlineStyleSingle)}; + mock.mockMarkedTextRange = createMockTextRange(mock); + + for (NSString *step in @[ @"k", @"か", @"かn", @"かん" ]) { + mock.attributedText = [[NSAttributedString alloc] initWithString:step attributes:ul]; + [delegate textInputDidChange]; + XCTAssertEqualObjects(mock.attributedText.string, step); + } + + // Candidate selection + mock.attributedText = [[NSAttributedString alloc] initWithString:@"漢" attributes:ul]; + [delegate textInputDidChange]; + + // Commit + mock.mockMarkedTextRange = nil; + mock.attributedText = [[NSAttributedString alloc] initWithString:@"漢"]; + [delegate textInputDidChange]; + XCTAssertEqualObjects(mock.attributedText.string, @"漢"); +} + +#pragma mark - Chinese Pinyin lifecycle + +- (void)testChinesePinyinCompositionLifecycle +{ + // z → zh → zhong → 中 (candidate) → commit + RCTMockTextField *mock; + RCTTextInputComponentView *cv = [self createSingleLineWithMock:&mock]; + id delegate = (id)cv; + + NSDictionary *ul = @{NSUnderlineStyleAttributeName : @(NSUnderlineStyleSingle)}; + mock.mockMarkedTextRange = createMockTextRange(mock); + + for (NSString *step in @[ @"z", @"zh", @"zho", @"zhon", @"zhong" ]) { + mock.attributedText = [[NSAttributedString alloc] initWithString:step attributes:ul]; + [delegate textInputDidChange]; + XCTAssertEqualObjects(mock.attributedText.string, step); + } + + mock.attributedText = [[NSAttributedString alloc] initWithString:@"中" attributes:ul]; + [delegate textInputDidChange]; + + mock.mockMarkedTextRange = nil; + mock.attributedText = [[NSAttributedString alloc] initWithString:@"中"]; + [delegate textInputDidChange]; + XCTAssertEqualObjects(mock.attributedText.string, @"中"); +} + +#pragma mark - Korean multi-syllable lifecycle + +- (void)testKoreanMultiSyllableCompositionLifecycle +{ + // 감사 — two syllables: ㄱ→가→감 (commit) → ㅅ→사 (commit) + RCTMockTextField *mock; + RCTTextInputComponentView *cv = [self createSingleLineWithMock:&mock]; + id delegate = (id)cv; + + NSDictionary *ul = @{NSUnderlineStyleAttributeName : @(NSUnderlineStyleSingle)}; + + // First syllable: 감 + mock.mockMarkedTextRange = createMockTextRange(mock); + for (NSString *step in @[ @"ㄱ", @"가", @"감" ]) { + mock.attributedText = [[NSAttributedString alloc] initWithString:step attributes:ul]; + [delegate textInputDidChange]; + } + + // ㅅ is typed — 감 commits, new syllable starts: 감ㅅ + // System splits: "감" committed + "ㅅ" composing + mock.attributedText = [[NSAttributedString alloc] initWithString:@"감ㅅ" attributes:ul]; + [delegate textInputDidChange]; + XCTAssertEqualObjects(mock.attributedText.string, @"감ㅅ"); + + // ㅏ added: 감사 + mock.attributedText = [[NSAttributedString alloc] initWithString:@"감사" attributes:ul]; + [delegate textInputDidChange]; + + // Commit + mock.mockMarkedTextRange = nil; + mock.attributedText = [[NSAttributedString alloc] initWithString:@"감사"]; + [delegate textInputDidChange]; + + XCTAssertEqualObjects(mock.attributedText.string, @"감사"); +} + +#pragma mark - Japanese candidate re-selection lifecycle + +- (void)testJapaneseCandidateReselectionLifecycle +{ + // はし → 橋 → 箸 → 端 → 箸 (user scrolls candidates and confirms) + RCTMockTextField *mock; + RCTTextInputComponentView *cv = [self createSingleLineWithMock:&mock]; + id delegate = (id)cv; + + NSDictionary *ul = @{NSUnderlineStyleAttributeName : @(NSUnderlineStyleSingle)}; + mock.mockMarkedTextRange = createMockTextRange(mock); + + // Romaji input + for (NSString *step in @[ @"h", @"は", @"はs", @"はし" ]) { + mock.attributedText = [[NSAttributedString alloc] initWithString:step attributes:ul]; + [delegate textInputDidChange]; + } + + // Candidate selection — user scrolls through candidates + for (NSString *candidate in @[ @"橋", @"箸", @"端", @"箸" ]) { + mock.attributedText = [[NSAttributedString alloc] initWithString:candidate attributes:ul]; + [delegate textInputDidChange]; + // Text should reflect the current candidate, not be replaced by JS + XCTAssertEqualObjects(mock.attributedText.string, candidate); + } + + // Commit + mock.mockMarkedTextRange = nil; + mock.attributedText = [[NSAttributedString alloc] initWithString:@"箸"]; + [delegate textInputDidChange]; + + XCTAssertEqualObjects(mock.attributedText.string, @"箸"); +} + +#pragma mark - Chinese Zhuyin (Bopomofo) lifecycle + +- (void)testChineseZhuyinCompositionLifecycle +{ + // Zhuyin (Bopomofo) IME used in Taiwan: ㄓㄨㄥ → 中 + RCTMockTextField *mock; + RCTTextInputComponentView *cv = [self createSingleLineWithMock:&mock]; + id delegate = (id)cv; + + NSDictionary *ul = @{NSUnderlineStyleAttributeName : @(NSUnderlineStyleSingle)}; + mock.mockMarkedTextRange = createMockTextRange(mock); + + for (NSString *step in @[ @"ㄓ", @"ㄓㄨ", @"ㄓㄨㄥ" ]) { + mock.attributedText = [[NSAttributedString alloc] initWithString:step attributes:ul]; + [delegate textInputDidChange]; + XCTAssertEqualObjects(mock.attributedText.string, step); + } + + // Candidate selected + mock.attributedText = [[NSAttributedString alloc] initWithString:@"中" attributes:ul]; + [delegate textInputDidChange]; + + // Commit + mock.mockMarkedTextRange = nil; + mock.attributedText = [[NSAttributedString alloc] initWithString:@"中"]; + [delegate textInputDidChange]; + + XCTAssertEqualObjects(mock.attributedText.string, @"中"); +} + +#pragma mark - Mixed Latin + CJK lifecycle + +- (void)testMixedLatinAndCJKComposition +{ + // User types "Hello" in Latin, then switches to Japanese IME and types "世界" + RCTMockTextField *mock; + RCTTextInputComponentView *cv = [self createSingleLineWithMock:&mock]; + id delegate = (id)cv; + + NSDictionary *ul = @{NSUnderlineStyleAttributeName : @(NSUnderlineStyleSingle)}; + + // Latin input (no composition) + mock.mockMarkedTextRange = nil; + mock.attributedText = [[NSAttributedString alloc] initWithString:@"Hello"]; + [delegate textInputDidChange]; + + // Japanese composition starts after "Hello" + mock.mockMarkedTextRange = createMockTextRange(mock); + for (NSString *step in @[ @"Hellos", @"Helloせ", @"Helloせk", @"Helloせか", @"Helloせかi", @"Helloせかい" ]) { + mock.attributedText = [[NSAttributedString alloc] initWithString:step attributes:ul]; + [delegate textInputDidChange]; + } + + // Candidate selected + mock.attributedText = [[NSAttributedString alloc] initWithString:@"Hello世界" attributes:ul]; + [delegate textInputDidChange]; + + // Commit + mock.mockMarkedTextRange = nil; + mock.attributedText = [[NSAttributedString alloc] initWithString:@"Hello世界"]; + [delegate textInputDidChange]; + + XCTAssertEqualObjects(mock.attributedText.string, @"Hello世界"); +} + +#pragma mark - Composition underline preservation + +- (void)testCompositionUnderlinePreservedDuringStateRoundTrip +{ + // The core bug: during IME composition, the system adds NSUnderlineStyleAttributeName + // to show the composition underline. If _setAttributedString is called (e.g., from a + // Fabric state round-trip), it would replace the attributed text with one that has NO + // underline, destroying the visual composition indicator. Our guard must prevent this. + RCTMockTextField *mock; + RCTTextInputComponentView *cv = [self createSingleLineWithMock:&mock]; + + // Set up text WITH underline (simulating active IME composition) + NSDictionary *withUnderline = @{ + NSFontAttributeName : [UIFont systemFontOfSize:14], + NSUnderlineStyleAttributeName : @(NSUnderlineStyleSingle), + }; + mock.attributedText = [[NSAttributedString alloc] initWithString:@"한" attributes:withUnderline]; + mock.mockMarkedTextRange = createMockTextRange(mock); + + // Attempt to replace with text WITHOUT underline (simulating state round-trip) + NSAttributedString *withoutUnderline = + [[NSAttributedString alloc] initWithString:@"한" + attributes:@{NSFontAttributeName : [UIFont systemFontOfSize:14]}]; + +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Warc-performSelector-leaks" + SEL sel = NSSelectorFromString(@"_setAttributedString:"); + if ([cv respondsToSelector:sel]) { + [cv performSelector:sel withObject:withoutUnderline]; + } +#pragma clang diagnostic pop + + // The underline attribute must still be present (guard blocked the replacement) + NSDictionary *resultAttrs = [mock.attributedText attributesAtIndex:0 effectiveRange:nil]; + XCTAssertNotNil( + resultAttrs[NSUnderlineStyleAttributeName], + @"Composition underline must be preserved — _setAttributedString should be blocked during composition"); +} + +- (void)testCompositionUnderlinePreservedDuringDeferredAttributeUpdate +{ + // When updateEventEmitter defers defaultTextAttributes during composition, + // the underline on the current text must not be disturbed. + RCTMockTextField *mock; + RCTTextInputComponentView *cv = [self createSingleLineWithMock:&mock]; + id delegate = (id)cv; + + NSDictionary *withUnderline = @{ + NSFontAttributeName : [UIFont systemFontOfSize:14], + NSUnderlineStyleAttributeName : @(NSUnderlineStyleSingle), + }; + mock.attributedText = [[NSAttributedString alloc] initWithString:@"か" attributes:withUnderline]; + mock.mockMarkedTextRange = createMockTextRange(mock); + + // Simulate deferred attribute update (from updateEventEmitter during composition) + NSDictionary *newAttrs = @{NSFontAttributeName : [UIFont boldSystemFontOfSize:16]}; + [cv setValue:@YES forKey:@"_needsUpdateDefaultTextAttributes"]; + [cv setValue:newAttrs forKey:@"_pendingDefaultTextAttributes"]; + + // textInputDidChange fires mid-composition — deferred attrs must NOT be applied + [delegate textInputDidChange]; + + // Underline must still be on the text + NSDictionary *resultAttrs = [mock.attributedText attributesAtIndex:0 effectiveRange:nil]; + XCTAssertNotNil( + resultAttrs[NSUnderlineStyleAttributeName], + @"Composition underline must survive deferred attribute update during composition"); + + // Deferred flag must still be pending + XCTAssertTrue([[cv valueForKey:@"_needsUpdateDefaultTextAttributes"] boolValue]); +} + +- (void)testUnderlineRemovedAfterCompositionCommit +{ + // After composition commits, the system removes the underline. + // Our code should allow normal attribute updates at this point. + RCTMockTextField *mock; + RCTTextInputComponentView *cv = [self createSingleLineWithMock:&mock]; + id delegate = (id)cv; + + // Mid-composition: underline present + NSDictionary *withUnderline = @{ + NSFontAttributeName : [UIFont systemFontOfSize:14], + NSUnderlineStyleAttributeName : @(NSUnderlineStyleSingle), + }; + mock.attributedText = [[NSAttributedString alloc] initWithString:@"한" attributes:withUnderline]; + mock.mockMarkedTextRange = createMockTextRange(mock); + [delegate textInputDidChange]; + + // Commit: system removes underline and clears markedTextRange + mock.mockMarkedTextRange = nil; + mock.attributedText = [[NSAttributedString alloc] initWithString:@"한" + attributes:@{NSFontAttributeName : [UIFont systemFontOfSize:14]}]; + [delegate textInputDidChange]; + + // After commit, underline should be gone (system removed it) + NSDictionary *resultAttrs = [mock.attributedText attributesAtIndex:0 effectiveRange:nil]; + XCTAssertNil( + resultAttrs[NSUnderlineStyleAttributeName], + @"Underline should be removed after composition commits"); +} + +#pragma mark - _setAttributedString guard + +- (void)testSetAttributedStringBlockedDuringComposition +{ + RCTMockTextField *mock; + RCTTextInputComponentView *cv = [self createSingleLineWithMock:&mock]; + + mock.attributedText = [[NSAttributedString alloc] initWithString:@"composing"]; + mock.mockMarkedTextRange = createMockTextRange(mock); + +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Warc-performSelector-leaks" + SEL sel = NSSelectorFromString(@"_setAttributedString:"); + if ([cv respondsToSelector:sel]) { + [cv performSelector:sel + withObject:[[NSAttributedString alloc] initWithString:@"replaced"]]; + } +#pragma clang diagnostic pop + + XCTAssertEqualObjects(mock.attributedText.string, @"composing", + @"Text must not be replaced during composition"); +} + +- (void)testSetAttributedStringAllowedAfterComposition +{ + RCTMockTextField *mock; + RCTTextInputComponentView *cv = [self createSingleLineWithMock:&mock]; + + mock.attributedText = [[NSAttributedString alloc] initWithString:@"old"]; + mock.mockMarkedTextRange = nil; + +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Warc-performSelector-leaks" + SEL sel = NSSelectorFromString(@"_setAttributedString:"); + if ([cv respondsToSelector:sel]) { + [cv performSelector:sel + withObject:[[NSAttributedString alloc] initWithString:@"new"]]; + } +#pragma clang diagnostic pop + + XCTAssertEqualObjects(mock.attributedText.string, @"new", + @"Text should be replaced when not composing"); +} + +#pragma mark - Deferred defaultTextAttributes + +- (void)testDeferredAttributesPreservedDuringComposition +{ + RCTMockTextField *mock; + RCTTextInputComponentView *cv = [self createSingleLineWithMock:&mock]; + mock.attributedText = [[NSAttributedString alloc] initWithString:@"test"]; + + [cv setValue:@YES forKey:@"_needsUpdateDefaultTextAttributes"]; + [cv setValue:@{NSFontAttributeName : [UIFont systemFontOfSize:18]} + forKey:@"_pendingDefaultTextAttributes"]; + + mock.mockMarkedTextRange = createMockTextRange(mock); + [(id)cv textInputDidChange]; + + XCTAssertTrue([[cv valueForKey:@"_needsUpdateDefaultTextAttributes"] boolValue]); + XCTAssertNotNil([cv valueForKey:@"_pendingDefaultTextAttributes"]); +} + +- (void)testDeferredAttributesAppliedAfterComposition +{ + RCTMockTextField *mock; + RCTTextInputComponentView *cv = [self createSingleLineWithMock:&mock]; + mock.attributedText = [[NSAttributedString alloc] initWithString:@"test"]; + + [cv setValue:@YES forKey:@"_needsUpdateDefaultTextAttributes"]; + [cv setValue:@{NSFontAttributeName : [UIFont systemFontOfSize:18]} + forKey:@"_pendingDefaultTextAttributes"]; + + mock.mockMarkedTextRange = nil; + [(id)cv textInputDidChange]; + + XCTAssertFalse([[cv valueForKey:@"_needsUpdateDefaultTextAttributes"] boolValue]); + XCTAssertNil([cv valueForKey:@"_pendingDefaultTextAttributes"]); +} + +#pragma mark - Full lifecycle with deferred attributes + +- (void)testCompositionLifecycleWithDeferredAttributes +{ + // Composition starts → deferred attrs set mid-composition → composition ends → attrs applied + RCTMockTextField *mock; + RCTTextInputComponentView *cv = [self createSingleLineWithMock:&mock]; + id delegate = (id)cv; + + NSDictionary *ul = @{NSUnderlineStyleAttributeName : @(NSUnderlineStyleSingle)}; + + // Composition starts + mock.mockMarkedTextRange = createMockTextRange(mock); + mock.attributedText = [[NSAttributedString alloc] initWithString:@"ㅎ" attributes:ul]; + [delegate textInputDidChange]; + + // Mid-composition: deferred attribute update arrives + [cv setValue:@YES forKey:@"_needsUpdateDefaultTextAttributes"]; + [cv setValue:@{NSFontAttributeName : [UIFont boldSystemFontOfSize:20]} + forKey:@"_pendingDefaultTextAttributes"]; + + mock.attributedText = [[NSAttributedString alloc] initWithString:@"한" attributes:ul]; + [delegate textInputDidChange]; + + // Still composing — deferred must be preserved + XCTAssertTrue([[cv valueForKey:@"_needsUpdateDefaultTextAttributes"] boolValue]); + + // Commit + mock.mockMarkedTextRange = nil; + mock.attributedText = [[NSAttributedString alloc] initWithString:@"한"]; + [delegate textInputDidChange]; + + // Deferred attrs must now be applied + XCTAssertFalse([[cv valueForKey:@"_needsUpdateDefaultTextAttributes"] boolValue]); + XCTAssertNil([cv valueForKey:@"_pendingDefaultTextAttributes"]); + XCTAssertEqualObjects(mock.attributedText.string, @"한"); +} + +#pragma mark - maxLength + +- (void)testMaxLengthBypassedDuringComposition +{ + RCTMockTextField *mock; + RCTTextInputComponentView *cv = [self createSingleLineWithMock:&mock]; + mock.attributedText = [[NSAttributedString alloc] initWithString:@"12345"]; + mock.mockMarkedTextRange = createMockTextRange(mock); + + NSString *result = + [(id)cv textInputShouldChangeText:@"한" inRange:NSMakeRange(5, 0)]; + XCTAssertEqualObjects(result, @"한"); +} + +- (void)testMaxLengthPassthroughWhenNotComposing +{ + RCTMockTextField *mock; + RCTTextInputComponentView *cv = [self createSingleLineWithMock:&mock]; + mock.attributedText = [[NSAttributedString alloc] initWithString:@"12345"]; + mock.mockMarkedTextRange = nil; + + NSString *result = + [(id)cv textInputShouldChangeText:@"6" inRange:NSMakeRange(5, 0)]; + XCTAssertEqualObjects(result, @"6"); +} + +#pragma mark - Multiline bare text comparison + +- (void)testMultilineBareTextComparisonPreventsFalsePositive +{ + RCTMockTextView *mock; + RCTTextInputComponentView *cv = [self createMultiLineWithMock:&mock]; + + mock.attributedText = + [[NSMutableAttributedString alloc] initWithString:@"test" + attributes:@{ + NSFontAttributeName : [UIFont systemFontOfSize:14], + NSUnderlineStyleAttributeName : @(NSUnderlineStyleSingle), + }]; + + NSAttributedString *withoutUL = + [[NSAttributedString alloc] initWithString:@"test" + attributes:@{NSFontAttributeName : [UIFont systemFontOfSize:14]}]; + [cv setValue:withoutUL forKey:@"_lastStringStateWasUpdatedWith"]; + + // Bare strings equal — new comparison returns YES (no false positive) + XCTAssertTrue([withoutUL.string isEqualToString:mock.attributedText.string]); + // Full attributed strings differ — old comparison would return NO + XCTAssertFalse([withoutUL isEqual:mock.attributedText]); +} + +#pragma mark - JS-driven update blocked during composition + +- (void)testJSDrivenTextUpdateBlockedDuringComposition +{ + RCTMockTextField *mock; + RCTTextInputComponentView *cv = [self createSingleLineWithMock:&mock]; + + mock.attributedText = [[NSAttributedString alloc] initWithString:@"composing" + attributes:@{ + NSUnderlineStyleAttributeName : @(NSUnderlineStyleSingle), + }]; + mock.mockMarkedTextRange = createMockTextRange(mock); + +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Warc-performSelector-leaks" + SEL sel = NSSelectorFromString(@"_setAttributedString:"); + if ([cv respondsToSelector:sel]) { + [cv performSelector:sel + withObject:[[NSAttributedString alloc] initWithString:@"js-value"]]; + } +#pragma clang diagnostic pop + + XCTAssertEqualObjects(mock.attributedText.string, @"composing", + @"JS-driven update must be blocked during composition"); +} + +#pragma mark - typingAttributes stripping for IME composition underline + +- (void)testTypingAttributesStrippedForMultiline +{ + // UITextView (multiline): setDefaultTextAttributes: must strip EventEmitter, + // no-op NSShadow, and transparent NSBackgroundColor from typingAttributes. + // These attributes prevent UIKit from rendering the IME composition underline. + RCTMockTextView *mock; + [self createMultiLineWithMock:&mock]; + + NSShadow *noopShadow = [NSShadow new]; // offset (0,0), blur 0 + NSDictionary *attrs = @{ + NSFontAttributeName : [UIFont systemFontOfSize:14], + NSForegroundColorAttributeName : [UIColor blackColor], + @"EventEmitter" : [@"fake-emitter" dataUsingEncoding:NSUTF8StringEncoding], + NSShadowAttributeName : noopShadow, + NSBackgroundColorAttributeName : [UIColor clearColor], + }; + mock.defaultTextAttributes = attrs; + + XCTAssertNil(mock.typingAttributes[@"EventEmitter"], + @"EventEmitter must be stripped from typingAttributes"); + XCTAssertNil(mock.typingAttributes[NSShadowAttributeName], + @"No-op NSShadow must be stripped from typingAttributes"); + XCTAssertNil(mock.typingAttributes[NSBackgroundColorAttributeName], + @"Transparent NSBackgroundColor must be stripped from typingAttributes"); + XCTAssertNotNil(mock.typingAttributes[NSFontAttributeName], + @"Standard attributes like font must be preserved"); + XCTAssertNotNil(mock.typingAttributes[NSForegroundColorAttributeName], + @"Standard attributes like foreground color must be preserved"); +} + +- (void)testRealShadowAndBackgroundPreservedInMultilineTypingAttributes +{ + // User-specified shadow and background color must NOT be stripped. + RCTMockTextView *mock; + [self createMultiLineWithMock:&mock]; + + NSShadow *realShadow = [NSShadow new]; + realShadow.shadowOffset = CGSizeMake(1, 1); + realShadow.shadowBlurRadius = 2; + NSDictionary *attrs = @{ + NSFontAttributeName : [UIFont systemFontOfSize:14], + NSShadowAttributeName : realShadow, + NSBackgroundColorAttributeName : [UIColor yellowColor], + }; + mock.defaultTextAttributes = attrs; + + XCTAssertNotNil(mock.typingAttributes[NSShadowAttributeName], + @"User-specified NSShadow must be preserved"); + XCTAssertNotNil(mock.typingAttributes[NSBackgroundColorAttributeName], + @"User-specified NSBackgroundColor must be preserved"); +} + +- (void)testUpdateTypingAttributesStripsIMEBlockingAttrs +{ + // _updateTypingAttributes reads from attributed text (which has EventEmitter + // and no-op attrs from Fabric). These must be stripped so UIKit can render + // the composition underline on subsequent compositions. + RCTMockTextField *mock; + RCTTextInputComponentView *cv = [self createSingleLineWithMock:&mock]; + + NSShadow *noopShadow = [NSShadow new]; + NSDictionary *attrsWithJunk = @{ + NSFontAttributeName : [UIFont systemFontOfSize:14], + @"EventEmitter" : [@"fake-emitter" dataUsingEncoding:NSUTF8StringEncoding], + NSShadowAttributeName : noopShadow, + NSBackgroundColorAttributeName : [UIColor clearColor], + }; + mock.attributedText = [[NSAttributedString alloc] initWithString:@"test" attributes:attrsWithJunk]; + mock.mockMarkedTextRange = nil; + mock.selectedTextRange = [mock textRangeFromPosition:mock.beginningOfDocument toPosition:mock.beginningOfDocument]; + + [(id)cv textInputDidChangeSelection]; + + XCTAssertNil(mock.typingAttributes[@"EventEmitter"], + @"EventEmitter must be stripped from typingAttributes"); + XCTAssertNil(mock.typingAttributes[NSShadowAttributeName], + @"No-op NSShadow must be stripped from typingAttributes"); + XCTAssertNil(mock.typingAttributes[NSBackgroundColorAttributeName], + @"Transparent NSBackgroundColor must be stripped from typingAttributes"); + XCTAssertNotNil(mock.typingAttributes[NSFontAttributeName], + @"Font must be preserved in typingAttributes"); +} + +@end