From 7fa86cd6d4e86d5a17a6ab1532ff2fac6cdfff1b Mon Sep 17 00:00:00 2001 From: Yi Zhan Date: Fri, 4 Sep 2026 17:46:35 +0000 Subject: [PATCH] fix: do not cancel CodeMirror compose beforeChange Signed-off-by: Yi Zhan --- .ts/index.js | 21 +++++++++++++++++++-- index.js | 20 ++++++++++++++++++-- src/index.tsx | 32 ++++++++++++++++++++++++++++++-- test/index.spec.tsx | 39 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 106 insertions(+), 6 deletions(-) diff --git a/.ts/index.js b/.ts/index.js index b075495..664ac7b 100644 --- a/.ts/index.js +++ b/.ts/index.js @@ -288,6 +288,7 @@ var Controlled = (function (_super) { _this.deferred = null; _this.emulating = false; _this.hydrated = false; + _this.hydratingCompose = false; _this.initCb = function () { if (_this.props.editorDidConfigure) { _this.props.editorDidConfigure(_this.editor); @@ -312,16 +313,24 @@ var Controlled = (function (_super) { }); } if (!this.hydrated) { - this.deferred ? this.resolveChange(props.value) : this.initChange(props.value || ''); + if (!this.hydratingCompose) { + this.deferred ? this.resolveChange(props.value) : this.initChange(props.value || ''); + } } this.hydrated = true; }; Controlled.prototype.initChange = function (value) { this.emulating = true; var doc = this.editor.getDoc(); + var next = value || ''; + if (next === doc.getValue()) { + this.mirror.setValue(next); + this.emulating = false; + return; + } var lastLine = doc.lastLine(); var lastChar = doc.getLine(doc.lastLine()).length; - doc.replaceRange(value || '', { line: 0, ch: 0 }, { line: lastLine, ch: lastChar }); + doc.replaceRange(next, { line: 0, ch: 0 }, { line: lastLine, ch: lastChar }); this.mirror.setValue(value); doc.clearHistory(); this.mirror.clearHistory(); @@ -385,6 +394,14 @@ var Controlled = (function (_super) { if (_this.emulating) { return; } + if (data.origin === '*compose') { + _this.hydratingCompose = true; + var phantomChange_1 = _this.mirrorChange(data); + if (_this.props.onBeforeChange) + _this.props.onBeforeChange(_this.editor, data, phantomChange_1); + _this.hydratingCompose = false; + return; + } data.cancel(); _this.deferred = data; var phantomChange = _this.mirrorChange(_this.deferred); diff --git a/index.js b/index.js index 8016a6e..1a9fdbf 100644 --- a/index.js +++ b/index.js @@ -306,6 +306,7 @@ var Controlled = function(_super) { _this.deferred = null; _this.emulating = false; _this.hydrated = false; + _this.hydratingCompose = false; _this.initCb = function() { if (_this.props.editorDidConfigure) { _this.props.editorDidConfigure(_this.editor); @@ -332,16 +333,24 @@ var Controlled = function(_super) { }); } if (!this.hydrated) { - this.deferred ? this.resolveChange(props.value) : this.initChange(props.value || ''); + if (!this.hydratingCompose) { + this.deferred ? this.resolveChange(props.value) : this.initChange(props.value || ''); + } } this.hydrated = true; }; Controlled.prototype.initChange = function(value) { this.emulating = true; var doc = this.editor.getDoc(); + var next = value || ''; + if (next === doc.getValue()) { + this.mirror.setValue(next); + this.emulating = false; + return; + } var lastLine = doc.lastLine(); var lastChar = doc.getLine(doc.lastLine()).length; - doc.replaceRange(value || '', { + doc.replaceRange(next, { line: 0, ch: 0 }, { @@ -405,6 +414,13 @@ var Controlled = function(_super) { if (_this.emulating) { return; } + if (data.origin === '*compose') { + _this.hydratingCompose = true; + var phantomChange_1 = _this.mirrorChange(data); + if (_this.props.onBeforeChange) _this.props.onBeforeChange(_this.editor, data, phantomChange_1); + _this.hydratingCompose = false; + return; + } data.cancel(); _this.deferred = data; var phantomChange = _this.mirrorChange(_this.deferred); diff --git a/src/index.tsx b/src/index.tsx index c1eb49f..95526db 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -386,6 +386,8 @@ export class Controlled extends React.Component { /** @internal */ private hydrated: boolean; /** @internal */ + private hydratingCompose: boolean; + /** @internal */ private initCb: () => void; /** @internal */ private mirror: any; @@ -408,6 +410,7 @@ export class Controlled extends React.Component { this.deferred = null; this.emulating = false; this.hydrated = false; + this.hydratingCompose = false; this.initCb = () => { if (this.props.editorDidConfigure) { this.props.editorDidConfigure(this.editor); @@ -437,7 +440,11 @@ export class Controlled extends React.Component { }); } if (!this.hydrated) { - this.deferred ? this.resolveChange(props.value) : this.initChange(props.value || '') + // A *compose beforeChange is still in flight: the native IME change + // will apply. Do not setValue/replaceRange over it. + if (!this.hydratingCompose) { + this.deferred ? this.resolveChange(props.value) : this.initChange(props.value || '') + } } this.hydrated = true; } @@ -448,10 +455,20 @@ export class Controlled extends React.Component { this.emulating = true; const doc = this.editor.getDoc(); + const next = value || ''; + + // Controlled value updates after a native *compose change must not + // replaceRange/setValue the whole document (that resets IME state). + if (next === doc.getValue()) { + this.mirror.setValue(next); + this.emulating = false; + return; + } + const lastLine = doc.lastLine(); const lastChar = doc.getLine(doc.lastLine()).length; - doc.replaceRange(value || '', + doc.replaceRange(next, {line: 0, ch: 0}, {line: lastLine, ch: lastChar}); @@ -537,6 +554,17 @@ export class Controlled extends React.Component { return; } + // IME/dead-key composition uses origin "*compose". Cancelling it + // prevents CodeMirror from completing the composition lifecycle. + if (data.origin === '*compose') { + this.hydratingCompose = true; + let phantomChange = this.mirrorChange(data); + if (this.props.onBeforeChange) + this.props.onBeforeChange(this.editor, data, phantomChange); + this.hydratingCompose = false; + return; + } + data.cancel(); this.deferred = data; diff --git a/test/index.spec.tsx b/test/index.spec.tsx index 76dd803..779fcf4 100644 --- a/test/index.spec.tsx +++ b/test/index.spec.tsx @@ -439,6 +439,45 @@ describe('Change', () => { const doc = wrapper.instance().editor.getDoc(); doc.replaceRange('foo', {line: 1, ch: 1}); }); + + // refs https://github.com/scniro/react-codemirror2/issues/327 + // Cancelling beforeChange with origin "*compose" aborts IME/dead-key composition. + it('[Controlled] change:*compose is not cancelled', () => { + let composeData; + const wrapper = Enzyme.mount( + { + composeData = data; + }} + />); + const doc = wrapper.instance().editor.getDoc(); + doc.replaceRange('é', {line: 0, ch: 3}, {line: 0, ch: 3}, '*compose'); + expect(composeData).toBeDefined(); + expect(composeData.origin).toBe('*compose'); + expect(composeData.canceled).toBe(false); + expect(doc.getValue()).toEqual('fooé'); + wrapper.unmount(); + }); + + it('[Controlled] change:*compose commits once when value is updated', () => { + let composeData; + const wrapper = Enzyme.mount( + { + composeData = data; + wrapper.setProps({value}); + }} + />); + const doc = wrapper.instance().editor.getDoc(); + doc.replaceRange('é', {line: 0, ch: 3}, {line: 0, ch: 3}, '*compose'); + expect(composeData).toBeDefined(); + expect(composeData.origin).toBe('*compose'); + expect(composeData.canceled).toBe(false); + expect(doc.getValue()).toEqual('fooé'); + wrapper.unmount(); + }); }); describe('Props', () => {