Skip to content

bug(autocomplete): signal forms with debounce #33385

@Roman991

Description

@Roman991

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

21

Description

Bug:

debounce() from signal forms breaks input when used on a matAutocomplete field (CVA write-back uses the debounced model value) in angular 22

When a signal-forms field uses debounce() and is bound via [formField] to an <input matInput [matAutocomplete]>, typing into the input is broken: characters get reverted/erased while the debounce window is
pending, and the model value is never updated correctly.

The same [formField] + debounce() combination works fine on a plain <input matInput> / <textarea matInput> (no autocomplete). The problem only appears when MatAutocompleteTrigger is on the element.

Reproduction

minimal repro: (stackblitz still has no support for node 22.22.3 required by angular 22)

  import { Component, signal } from '@angular/core';
  import { debounce, form, FormField } from '@angular/forms/signals';
  import { bootstrapApplication } from '@angular/platform-browser';
  import { MatFormFieldModule } from '@angular/material/form-field';
  import { MatInputModule } from '@angular/material/input';
  import { MatAutocompleteModule } from '@angular/material/autocomplete';

  @Component({
    selector: 'app-root',
    template: `
      <mat-form-field>
        <input
          type="text"
          matInput
          [formField]="autocompleteForm.input"
          [matAutocomplete]="auto"
        />
        <mat-autocomplete #auto="matAutocomplete">
          @for (option of options; track option) {
            <mat-option [value]="option">{{ option }}</mat-option>
          }
        </mat-autocomplete>
      </mat-form-field>
    `,
    imports: [FormField, MatFormFieldModule, MatInputModule, MatAutocompleteModule],
  })
  export class LoginApp {
    autocompleteModel = signal<{ input: string }>({ input: '' });
    autocompleteForm = form(this.autocompleteModel, (f) => {
      debounce(f.input, 1_000);
    });
    options: string[] = ['One', 'Two', 'Three'];
  }

  bootstrapApplication(LoginApp);

Steps: start typing in the field.

Expected behavior

Typed characters stay in the input. The debounce only delays the propagation of the UI value to the form model (field().value()); it must not interfere with what the user sees/types. (This is exactly how it
behaves for a plain matInput without autocomplete.)

Actual behavior

While the debounce window is pending, the typed text is reverted/cleared, because the stale (pre-debounce) model value is written back into the input on every change detection cycle.

Root cause analysis (AI generated)🤖

  The issue is the interaction between the ControlValueAccessor write-back path in @angular/forms/signals and MatAutocompleteTrigger.writeValue.

  Signal forms keep two signals per field:
  - controlValue → immediate UI value
  - value → model value, updated only after the debounce resolves (debounceSync() → sync()).

  The directive write-back path differs depending on how the control is bound:

  - Native / custom-control path (plain matInput, textarea) writes back the immediate value:
  const controlValue = state.controlValue();      // immediate
  if (bindingUpdated(...)) setNativeControlValue(input, controlValue);
  - → stays in sync with typing; debounce only delays value(). ✅
  - CVA path (cvaControlCreate, taken because MatAutocompleteTrigger provides NG_VALUE_ACCESSOR) writes back the debounced model value:
  const value = fieldState.value();               // DEBOUNCED
  if (bindingUpdated(...)) untracked(() => controlValueAccessor.writeValue(value));

  So on each keystroke:

  1. _handleInput → _onChange(typed) → controlValue.set(typed) → debounce starts; value() still holds the old value.
  2. The input event triggers change detection → the directive's update reads the stale value() and calls writeValue(staleValue).
  3. MatAutocompleteTrigger.writeValue → _assignOptionValue → _updateNativeInputValue sets this._formField._control.value = staleValue, overwriting what the user just typed.

  Effectively, the CVA write-back keeps stomping the input with the stale debounced model value while the debounce is pending.

  Suggested direction

  The signal-forms CVA write-back path should write back controlValue() (the immediate value), consistently with the native/custom-control paths, instead of the debounced value(). Otherwise debounce() is
  fundamentally incompatible with any ControlValueAccessor-based control (not just matAutocomplete).

  This may need to be triaged on the @angular/forms side rather than @angular/components, since the defective write-back lives in cvaControlCreate in @angular/forms/signals; MatAutocompleteTrigger just happens
  to be a CVA that surfaces it.

Metadata

Metadata

Assignees

No one assigned

    Labels

    P3An issue that is relevant to core functions, but does not impede progress. Important, but not urgentarea: material/autocompletegemini-triagedLabel noting that an issue has been triaged by gemini

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions