Custom Angular FormField emitting event when configured not to

Viewed 1842

This question has a Stackblitz: stackblitz.com/edit/angular-material-starter-template-8ojscv.

I have implemented a custom Angular Material FormField that wraps CodeMirror.

In my app component I suscribe to valueChanges on the Form Control to listen to the user typing:

export class AppComponent implements OnInit {
   // Custom value accessor for CodeMirror.
   control: FormControl = new FormControl('', {updateOn: 'change'});

   ngOnInit() {
    // Listen for the user typing in CodeMirror.
    this.control.valueChanges.pipe(
        debounceTime(500),
        distinctUntilChanged(),
        tap((value: string) => {
          console.log(`The user typed "${value}"`);
        })
    ).subscribe();
  }
}

I noticed that when using setValue, the valueChanges Observable emits a value even if the options object prohibits it:

// This appears to have no effect.
this.control.setValue(value, {
   // Prevent the statusChanges and valueChanges observables from
   // emitting events when the control value is updated.
   emitEvent: false,
}

The high-level flow in my Stackblitz demo is:

  1. Click the setValue(0) button
  2. The app component calls setValue on the FormControl with emitEvent: false
  3. The ControlValueAccessor writeValue(value: string) method is called on the custom FormField component (my-input.component)
  4. Writing the value is delegated to the MatFormFieldControl value setter
  5. The MatFormFieldControl value setter writes the value to the CodeMirror editor with this._editor.setValue(value + "")
  6. The changes CodeMirror event is triggered (it was added in ngAfterViewInit).
  7. The registered callback function is called with the updated value (this._onChange(cm.getValue()))
  8. The valueChanges Observable emits the updated value

Yes, my-input.component explicitly calls the registered calledback function, but I had expected the framework (Angular or Angular Material) to honor emitEvent: false and not emit the event.

Is it the responsibility of the custom FormField implementation to implement the options object and not call the registered callback if emitEvent: false is set?

1 Answers

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.

Related