@@ -586,6 +586,99 @@ describe('DtInput tests', () => {
586586 } ) ;
587587 } ) ;
588588
589+ describe ( 'IME Composition Tests' , ( ) => {
590+ describe ( 'When type is not a textarea' , ( ) => {
591+ it ( 'should not emit input or update:modelValue while composing' , async ( ) => {
592+ await nativeInput . trigger ( 'compositionstart' ) ;
593+ await nativeInput . trigger ( 'input' ) ;
594+
595+ expect ( wrapper . emitted ( ) . input ) . toBeUndefined ( ) ;
596+ expect ( wrapper . emitted ( ) [ 'update:modelValue' ] ) . toBeUndefined ( ) ;
597+ } ) ;
598+
599+ it ( 'should emit input and update:modelValue after composition ends' , async ( ) => {
600+ await nativeInput . trigger ( 'compositionstart' ) ;
601+ nativeInput . element . value = 'か' ;
602+ await nativeInput . trigger ( 'input' ) ; // Chrome: input fires before compositionend (blocked)
603+ await nativeInput . trigger ( 'compositionend' ) ; // compositionend emits the committed value
604+
605+ expect ( wrapper . emitted ( ) . input [ 0 ] [ 0 ] ) . toBe ( 'か' ) ;
606+ expect ( wrapper . emitted ( ) [ 'update:modelValue' ] [ 0 ] [ 0 ] ) . toBe ( 'か' ) ;
607+ } ) ;
608+
609+ it ( 'should not double-emit when input fires after compositionend (Firefox order)' , async ( ) => {
610+ await nativeInput . trigger ( 'compositionstart' ) ;
611+ nativeInput . element . value = 'か' ;
612+ // Firefox fires compositionend then input — dispatch both synchronously
613+ // before the microtask that clears justEndedComposition can run
614+ nativeInput . element . dispatchEvent ( new Event ( 'compositionend' , { bubbles : true } ) ) ;
615+ nativeInput . element . dispatchEvent ( new Event ( 'input' , { bubbles : true } ) ) ;
616+ await wrapper . vm . $nextTick ( ) ;
617+
618+ expect ( wrapper . emitted ( ) . input ) . toHaveLength ( 1 ) ;
619+ expect ( wrapper . emitted ( ) [ 'update:modelValue' ] ) . toHaveLength ( 1 ) ;
620+ expect ( wrapper . emitted ( ) . input [ 0 ] [ 0 ] ) . toBe ( 'か' ) ;
621+ } ) ;
622+
623+ it ( 'should resume normal emission after composition ends' , async ( ) => {
624+ await nativeInput . trigger ( 'compositionstart' ) ;
625+ await nativeInput . trigger ( 'compositionend' ) ;
626+
627+ nativeInput . element . value = 'hello' ;
628+ await nativeInput . trigger ( 'input' ) ;
629+
630+ const inputEmissions = wrapper . emitted ( ) . input ;
631+ expect ( inputEmissions [ inputEmissions . length - 1 ] [ 0 ] ) . toBe ( 'hello' ) ;
632+ } ) ;
633+ } ) ;
634+
635+ describe ( 'When type is a textarea' , ( ) => {
636+ beforeEach ( ( ) => {
637+ mockProps = { type : 'textarea' } ;
638+ updateWrapper ( ) ;
639+ } ) ;
640+
641+ it ( 'should not emit input or update:modelValue while composing' , async ( ) => {
642+ await nativeTextarea . trigger ( 'compositionstart' ) ;
643+ await nativeTextarea . trigger ( 'input' ) ;
644+
645+ expect ( wrapper . emitted ( ) . input ) . toBeUndefined ( ) ;
646+ expect ( wrapper . emitted ( ) [ 'update:modelValue' ] ) . toBeUndefined ( ) ;
647+ } ) ;
648+
649+ it ( 'should emit input and update:modelValue after composition ends' , async ( ) => {
650+ await nativeTextarea . trigger ( 'compositionstart' ) ;
651+ nativeTextarea . element . value = 'か' ;
652+ await nativeTextarea . trigger ( 'input' ) ; // Chrome: input fires before compositionend (blocked)
653+ await nativeTextarea . trigger ( 'compositionend' ) ; // compositionend emits the committed value
654+
655+ expect ( wrapper . emitted ( ) . input [ 0 ] [ 0 ] ) . toBe ( 'か' ) ;
656+ expect ( wrapper . emitted ( ) [ 'update:modelValue' ] [ 0 ] [ 0 ] ) . toBe ( 'か' ) ;
657+ } ) ;
658+
659+ it ( 'should not double-emit when input fires after compositionend (Firefox order)' , async ( ) => {
660+ await nativeTextarea . trigger ( 'compositionstart' ) ;
661+ nativeTextarea . element . value = 'か' ;
662+ nativeTextarea . element . dispatchEvent ( new Event ( 'compositionend' , { bubbles : true } ) ) ;
663+ nativeTextarea . element . dispatchEvent ( new Event ( 'input' , { bubbles : true } ) ) ;
664+ await wrapper . vm . $nextTick ( ) ;
665+
666+ expect ( wrapper . emitted ( ) . input ) . toHaveLength ( 1 ) ;
667+ expect ( wrapper . emitted ( ) [ 'update:modelValue' ] ) . toHaveLength ( 1 ) ;
668+ expect ( wrapper . emitted ( ) . input [ 0 ] [ 0 ] ) . toBe ( 'か' ) ;
669+ } ) ;
670+
671+ it ( 'should not override textarea value via modelValue watcher while composing' , async ( ) => {
672+ nativeTextarea . element . value = 'composing...' ;
673+ await nativeTextarea . trigger ( 'compositionstart' ) ;
674+
675+ await wrapper . setProps ( { modelValue : 'external update' } ) ;
676+
677+ expect ( nativeTextarea . element . value ) . toBe ( 'composing...' ) ;
678+ } ) ;
679+ } ) ;
680+ } ) ;
681+
589682 describe ( 'Extendability Tests' , ( ) => {
590683 it ( 'should handle pass through props/attrs' , async ( ) => {
591684 expect ( nativeInput . attributes ( ) ) . toMatchObject ( baseAttrs ) ;
0 commit comments