Skip to content

Commit af6afdd

Browse files
authored
fix(input): DP-180970 ime composition bug (#1146)
1 parent 8191e11 commit af6afdd

File tree

3 files changed

+119
-5
lines changed

3 files changed

+119
-5
lines changed

packages/dialtone-vue/components/input/input.test.js

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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);

packages/dialtone-vue/components/input/input.vue

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -373,6 +373,8 @@ export default {
373373
isInvalid: false,
374374
defaultLength: 0,
375375
hasSlotContent,
376+
isComposing: false,
377+
justEndedComposition: false,
376378
};
377379
},
378380
@@ -408,7 +410,25 @@ export default {
408410
409411
inputListeners () {
410412
return {
413+
compositionstart: () => {
414+
this.isComposing = true;
415+
},
416+
417+
compositionend: () => {
418+
this.isComposing = false;
419+
this.justEndedComposition = true;
420+
const val = this.$refs.input.value;
421+
this.$emit('input', val);
422+
this.$emit('update:modelValue', val);
423+
// Clear the flag after the current synchronous event processing so
424+
// Firefox's post-compositionend input event is skipped, but the
425+
// next real user input (a separate browser task) is not.
426+
Promise.resolve().then(() => { this.justEndedComposition = false; });
427+
},
428+
411429
input: async event => {
430+
if (this.isComposing) return;
431+
if (this.justEndedComposition) return;
412432
let val = event.target.value;
413433
if (this.type === INPUT_TYPES.FILE) {
414434
const files = Array.from(event.target.files);
@@ -534,7 +554,8 @@ export default {
534554
}
535555
536556
// Set textarea value programmatically to avoid attribute binding
537-
if (this.isTextarea && this.$refs.input && this.$refs.input.value !== newValue) {
557+
// Skip during IME composition to avoid interrupting in-progress input
558+
if (this.isTextarea && !this.isComposing && this.$refs.input && this.$refs.input.value !== newValue) {
538559
this.$refs.input.value = newValue;
539560
}
540561
},

pnpm-lock.yaml

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)