I think the problem comes from codemirrorValueChanged
codemirrorValueChanged(
cm: CodeMirror.Editor,
change: CodeMirror.EditorChangeLinkedList
) {
if (change.origin !== "setValue") {
console.log(`_onChange(${this.value})`);
this._onChange(cm.getValue());
}
}
But first, let's see what happens on FormControl.setValue():
setValue(value: any, options: {
onlySelf?: boolean,
emitEvent?: boolean,
emitModelToViewChange?: boolean,
emitViewToModelChange?: boolean
} = {}): void {
(this as {value: any}).value = this._pendingValue = value;
if (this._onChange.length && options.emitModelToViewChange !== false) {
this._onChange.forEach(
(changeFn) => changeFn(this.value, options.emitViewToModelChange !== false));
}
this.updateValueAndValidity(options);
}
Whether you're using Reactive Forms or Template Forms, each control will have to be set up, and for this we have the _setupControl function(NgModel, FormControlName), which has different implementation, depending on the directive, but in each case it will eventually call setUpControl:
export function setUpControl(control: FormControl, dir: NgControl): void {
if (!control) _throwError(dir, 'Cannot find control with');
if (!dir.valueAccessor) _throwError(dir, 'No value accessor for form control with');
control.validator = Validators.compose([control.validator!, dir.validator]);
control.asyncValidator = Validators.composeAsync([control.asyncValidator!, dir.asyncValidator]);
// `writeValue`: MODEL -> VIEW
dir.valueAccessor!.writeValue(control.value);
setUpViewChangePipeline(control, dir);
setUpModelChangePipeline(control, dir);
setUpBlurPipeline(control, dir);
if (dir.valueAccessor!.setDisabledState) {
/* ... */
}
/* ... */
}
The setUpViewChangePipeline is where the ControlValueAccessor's registerOnChange will be called:
function setUpViewChangePipeline(control: FormControl, dir: NgControl): void {
dir.valueAccessor!.registerOnChange((newValue: any) => {
control._pendingValue = newValue;
control._pendingChange = true;
control._pendingDirty = true;
// `updateControl` - update value from VIEW to MODEL
// e.g `VIEW` - an input
// e.g `MODEL` - [(ngModel)]="componentValue"
if (control.updateOn === 'change') updateControl(control, dir);
});
}
And setUpModelChangePipeline is where the _onChange array(from setValue snippet) is populated:
function setUpModelChangePipeline(control: FormControl, dir: NgControl): void {
control.registerOnChange((newValue: any, emitModelEvent: boolean) => {
// control -> view
dir.valueAccessor!.writeValue(newValue);
// control -> ngModel
if (emitModelEvent) dir.viewToModelUpdate(newValue);
});
}
So, this is where the emitModelToViewChange flag (from options.emitModelToViewChange !== false) is important.
Next, we have updateValueAndValidity, which is where the valueChanges and statusChanges subjects emit:
updateValueAndValidity(opts: {onlySelf?: boolean, emitEvent?: boolean} = {}): void {
this._setInitialStatus();
this._updateValue();
if (this.enabled) {
// In case of async validators
this._cancelExistingSubscription();
// Run sync validators
(this as {errors: ValidationErrors | null}).errors = this._runValidator();
(this as {status: string}).status = this._calculateStatus();
if (this.status === VALID || this.status === PENDING) {
this._runAsyncValidator(opts.emitEvent);
}
}
// !
if (opts.emitEvent !== false) {
(this.valueChanges as EventEmitter<any>).emit(this.value);
(this.statusChanges as EventEmitter<string>).emit(this.status);
}
if (this._parent && !opts.onlySelf) {
this._parent.updateValueAndValidity(opts);
}
}
So, we can conclude that the problem does not stem from FormControl.setValue(val, { emitEvent: false }).
Before updateValueAndValidity is called, we see that _onChange functions will be called first. Once again, such function looks like this:
// From `setUpModelChangePipeline`
control.registerOnChange((newValue: any, emitModelEvent: boolean) => {
// control -> view
dir.valueAccessor!.writeValue(newValue);
// control -> ngModel
if (emitModelEvent) dir.viewToModelUpdate(newValue);
});
In our case, valueAccessor.writeValue looks like this:
writeValue(value: string): void {
console.log(`[ControlValueAccessor] writeValue(${value})`);
// Updates the Material UI value with `set value()`.
this.value = value;
}
which will invoke the setter:
set value(value: string | null) {
console.log(`[MatFormFieldControl] set value(${value})`);
if (this._editor) {
this._editor.setValue(value + "");
this._editor.markClean();
// Postpone the refresh() to after CodeMirror/Browser has updated
// the layout according to the new content.
setTimeout(() => {
this._editor.refresh();
}, 1);
}
this.stateChanges.next();
}
And due to _editor.setValue, the onChanges event will occur and codemirrorValueChanged will be invoked:
codemirrorValueChanged(
cm: CodeMirror.Editor,
change: CodeMirror.EditorChangeLinkedList
) {
if (change.origin !== "setValue") {
console.log(`_onChange(${this.value})`);
this._onChange(cm.getValue());
}
}
What _onChange does it to invoke this callback:
// from `setUpViewChangePipeline`
dir.valueAccessor!.registerOnChange((newValue: any) => {
control._pendingValue = newValue;
control._pendingChange = true;
control._pendingDirty = true;
if (control.updateOn === 'change') updateControl(control, dir);
});
and updateControl will call control.setValue, but without emitEvent: false:
function updateControl(control: FormControl, dir: NgControl): void {
if (control._pendingDirty) control.markAsDirty();
control.setValue(control._pendingValue, {emitModelToViewChange: false});
dir.viewToModelUpdate(control._pendingValue);
control._pendingChange = false;
}
So, this should explain the current behavior.
One thing that I find out while debugging is that change is an array, not an object.
So, a possible solution would be:
codemirrorValueChanged(
cm: CodeMirror.Editor,
change: CodeMirror.EditorChangeLinkedList
) {
if (change[0].origin !== "setValue") {
console.log(`_onChange(${this.value})`);
this._onChange(cm.getValue());
}
}
I tried to explain these concepts and how the Angular Forms internals work in A thorough exploration of Angular Forms.