angular: Bug: setting `[disabled]` attribute no longer works with `formControlName`

Command

other

Is this a regression?

  • Yes, this behavior used to work in the previous version

The previous version in which this bug was not present was

Angular 14

Description

When upgrading from Angular 14 to Angular 15 the [Disabled] directive of form controls stopped working.

Is this a bug or did something change?

I’m using version 15.0.2

Minimal Reproduction

<button type=“button” id=“btnAddPeopleEditor” class=“btn btn-success btn-sm” (click)=“addPeople(1)” [disabled]=“disabledEditEditor || (habilitarControle(isReadOnly) == ‘true’)”> Add </button>

Exception or Error

No error occurs. It simply stopped working.

Your Environment

Angular CLI: 15.0.2
Node: 16.17.0
Package Manager: npm 8.18.0
OS: win32 x64

Angular: 15.0.2
... animations, cli, common, compiler, compiler-cli, core, forms
... platform-browser, platform-browser-dynamic, platform-server
... router

Package                         Version
---------------------------------------------------------
@angular-devkit/architect       0.1500.2
@angular-devkit/build-angular   15.0.2
@angular-devkit/core            15.0.2
@angular-devkit/schematics      15.0.2
@angular/cdk                    15.0.1
@angular/material               15.0.1
@nguniversal/builders           15.0.0
@nguniversal/common             15.0.0
@nguniversal/express-engine     15.0.0
@schematics/angular             15.0.2
rxjs                            7.5.7
typescript                      4.8.4

Anything else relevant?

No response

About this issue

  • Original URL
  • State: open
  • Created 2 years ago
  • Reactions: 14
  • Comments: 39 (12 by maintainers)

Commits related to this issue

Most upvoted comments

Using Reactive Forms? You should be setting disabled on your model, not your template.

I want to echo @e-oz here. This is not a good solution in my opinion. We can set reactive form validators in our templates using native HTML attributes like required, min, max, etc. Why should disabled be different?

[attr.disabled] and [disabled] doesn’t add “disabled” attribute anymore. Quite important change, actually, please mention such changes as “breaking”.

About the attr.disabled “bug”, this a breaking change/feature, the “fix” is to opt-out from the new behaviour by importing the ReactiveFormsModule with a config.

ReactiveFormsModule.withConfig({
      callSetDisabledState: 'whenDisabledForLegacyCode',
    }),

Hi everyone. Indeed, I’ve tracked this down, and @JeanMeche is correct. While trying to fix a bug in https://github.com/angular/angular/pull/47576, I accidentally caused this related breakage. For now, you can work around by opting out when you import forms:

ReactiveFormsModule.withConfig({
    callSetDisabledState: 'whenDisabledForLegacyCode',
}),

or, if you’re using template-driven forms:

FormsModule.withConfig({
    callSetDisabledState: 'whenDisabledForLegacyCode',
}),

I expect to have this fixed for real in a patch release soon.

Using Reactive Forms? You should be setting disabled on your model, not your template.

We can declare reactive form attributes in the template, but only some of them - that’s not cool. It is not always possible (or brings a lot of code) to modify some attributes based on input values - because they might change. When it’s in the template, Angular change detection will monitor the changes and update bindings, when it’s in the code - you have to do this, and it’s not always trivial.

Here is a directive as a workaround:

import { NgControl } from '@angular/forms';
import { ChangeDetectorRef, Directive, Input, OnChanges, OnInit } from '@angular/core';

@Directive({
  selector: '[disableControl]',
  standalone: true,
})
export class DisableControlDirective implements OnInit, OnChanges {
  constructor(private ngControl: NgControl, private cdr: ChangeDetectorRef) {
  }

  @Input() disableControl?: boolean | null;

  ngOnInit() {
    this.update(!!this.disableControl);
  }

  ngOnChanges() {
    this.update(!!this.disableControl);
  }

  private update(disable: boolean) {
    try {
      const ctrl = this.ngControl.control;
      if (ctrl) {
        if (disable) {
          if (!ctrl.disabled) {
            ctrl.disable({onlySelf: true, emitEvent: false});
            this.cdr.markForCheck();
          }
        } else {
          if (ctrl.disabled) {
            ctrl.enable({onlySelf: true, emitEvent: false});
            this.cdr.markForCheck();
          }
        }
      }
    } catch (e) {
      console?.error(e);
    }
  }
}

After considerable debugging, I got to the bottom of this.

tl;dr

This behavior change was caused by a fix to make setDisabledState always called. Previously, using [attr.disabled] caused your view to be out of sync with your model.

  • Using Reactive Forms? You should be setting disabled on your model, not your template. Try new FormControl({value: 'foo', disabled: true}). Or you could call myControl.disable() in ngOnInit.
  • Want to opt-out of the fix? Make sure you’re on 15.1.0 or later and import FormsModule.withConfig({callSetDisabledState: 'whenDisabledForLegacyCode'}) (or ReactiveFormsModule, if that’s what you’re using).
  • Using [attr.disabled] on radio buttons? This is currently broken, and will be fixed soon in #48864.

Full Explanation

setDisabledState is supposed to be called whenever the disabled state of a control changes, including upon control creation. However, a longstanding bug caused the method to not fire when an enabled control was attached. This bug was fixed in v15, in #47576.

This had a side effect: previously, it was possible to instantiate a reactive form control with [attr.disabled]=true, even though the the corresponding control was enabled in the model. (Note that the similar-looking property binding version [disabled]=true was always rejected.) This resulted in a mismatch between the model and the DOM – the model would think the control was enabled, when it was actually disabled. Now, because setDisabledState is always called, the value in the DOM will be immediately overwritten with the “correct” enabled value.

Users of Reactive Forms should instead disable the control directly in their model. (There are many ways to do this, such as using the {value: 'foo', disabled: true} constructor format, or immediately calling FooControl.disable() in ngOnInit.)

If this incompatibility is too breaking, you may also opt out using FormsModule.withConfig or ReactiveFormsModule.withConfig at the time you import the module, via the callSetDisabledState option.

However, there is an exceptional case: radio buttons. Because Reactive Forms models the entire group of radio buttons as a single FormControl, there is no way to control the disabled state for individual radios via the model. (This is probably a design oversight in the forms package!) In #48864, we have special cased radio buttons to ignore their first call to setDisabledState when in callSetDisabledState: 'always' mode. This preserves the old behavior, allowing [attr.disabled] to keep working.

Hi @jnizet

the bug occurs when using the formControlName within the formGroup.

You can look in the ‘profile-editor.component.html’ component and compare two controls:

  1. <input [disabled]="true" id="street" type="text" formControlName="street" />

Does not work.

<input [disabled]="true" id="text_disable1" type="text"/>

It works

Here’s an example:

https://stackblitz.com/edit/angular-cu6mce?file=src/app/profile-editor/profile-editor.component.html

We encountered this issue too when setting disabled attribute on template driven select/input[checkbox].

We have dynamic access rights and in ngAfterViewInit() we set the attribute when in read-only mode. It worked in ang14, but with ang15 it’s stopped in our template driven forms. Our reactive ones works fine. This is with latest 15.2.8

The disabled attribute never get set. It’s like the angular code removes it. Our directive sets it like this;

renderer.setAttribute(el, 'disabled', '');

I also tried this. Works with reactive, but not working in template:

renderer.setProperty(el, 'disabled', true);

I even tried this and the same. Works with reactive, but not template.

(<HTMLFieldSetElement>el).disabled = true;

The only way to get it working again was to disable the new disable functionality with:

FormsModule.withConfig({ callSetDisabledState: 'whenDisabledForLegacyCode' }),

I much prefer to have the new functionality but cannot figure out how to disable our template driven controls.

An input control using [attr.disabled] in reactive form that works in Angular 14.

https://stackblitz.com/edit/angular-ivy-ecordk?file=src/app/app.component.html

An input control using [attr.disabled] in reactive form not working in Angular 15.

https://stackblitz.com/edit/angular-ivy-pccxpj?file=src/app/app.component.ts

Thanks for the reproduction @leandrotassi. This is a serious regression that is likely to break a lot of forms in many apps.

The only PR touching the formControlName directive in v15 was #43499 by @crisbeto. This could be the cause – I’ll have to write a test to check.

@polonmedia

bootstrapApplication(AppComponent, { providers: [importProvidersFrom(FormsModule.withConfig(...), ...)

Hi @jnizet

the bug occurs when using the formControlName within the formGroup.

You can look in the ‘profile-editor.component.html’ component and compare two controls:

  1. <input [disabled]="true" id="street" type="text" formControlName="street" />

Does not work.

<input [disabled]="true" id="text_disable1" type="text"/>

It works

Here’s an example:

https://stackblitz.com/edit/angular-cu6mce?file=src/app/profile-editor/profile-editor.component.html

I have the same issue so for the workaround i have used [readonly]

@dylhunn In the original question a button was used, but all the answers look like they are using an input with a FormControl, which a button can’t use.

I have a component in which a button is used with the disabled attribute and this no longer works. If I use the withConfig fix in the module where my module of the component is imported it doesn’t fix it.

Any idea on a solution for disabled attribute in buttons?

@benartmon Far too many projects rely on the old behavior, including thousand of tests inside of Google. We may or may not deprecate it at some distant future point, but the option will never be removed.

I think that this PR actually introduced a regression @dylhunn, as now I cannot disable a single radio button anymore - the only way is to use the old behavior via callSetDisabledState: 'whenDisabledForLegacyCode'.
Take the example in the RadioControlValueAccessor docs, how could we disable individual radio buttons? In Angular 14 I used to do it via [attr.disabled], but in v15 that doesn’t work anymore. Disabling the whole control is not an option, as it disables all the radios, so how would we (with this new way) disable radio inputs individually? It’s a valid use case, right?