ngguide
← Blog

Angular stopped knowing what an input is

Igor Katsuba

For years, this was how Angular decided whether a DOM element was a form control:

typescript
export function isNativeFormElement(element: HTMLElement): element is NativeFormControl {
  return (
    element.tagName === 'INPUT' || element.tagName === 'SELECT' || element.tagName === 'TEXTAREA'
  );
}

A string. A comparison. That’s it.

In open PR #69452, it becomes this:

typescript
export function isNativeFormElement(element: HTMLElement): element is NativeFormControl {
  return (
    element.tagName === 'INPUT' ||
    element.tagName === 'SELECT' ||
    element.tagName === 'TEXTAREA' ||
    isFormAssociatedCustomElement(element)
  );
}

The difference is not cosmetic. Before, the framework knew the list of native elements — it was hardcoded in the source. Now it goes and asks the browser what this particular thing in the DOM can do.

Why anyone bothered

Signal Forms don’t see web components at all.

You build a <my-range>. You set static formAssociated = true. You wire up ElementInternals properly, exactly by spec. Angular treats it as a div. min and max never render. The value gets read from the wrong property.

Everything inside the signal forms directives funnels through that one hardcoded tag check. The PR gives those elements a type of their own — deliberately all-optional, because nothing in the platform guarantees a custom element implements any of it:

typescript
export interface FormAssociatedCustomElement extends HTMLElement {
  type?: string;
  checked?: boolean;
  value?: string;
  valueAsNumber?: number;
  valueAsDate?: Date;
  disabled?: boolean;
  readonly validity?: ValidityState;
  readonly validationMessage?: string;
}

The part that’s actually interesting

Fine — you can detect a form-associated custom element. But how do you know whether it supports min and max?

There is no interface. Nothing to ask.

The solution is to read the class statics and guess from the observed attributes:

typescript
export function elementAcceptsMinMax(element: HTMLElement): boolean {
  if (element.tagName === 'INPUT') {
    const type = (element as HTMLInputElement).type;
    return type === 'number' || type === 'range' || type === 'date' || type === 'month';
  }

  const {formAssociated, observedAttributes} = getCustomElementClass(element) ?? {};
  return !!(
    formAssociated &&
    observedAttributes?.includes('min') &&
    observedAttributes?.includes('max')
  );
}

If you subscribed to changes on min, you probably do something with it.

Probably.

One more thing worth naming

The registry lookup itself is a runtime call, on every element the directive lands on:

typescript
function getCustomElementClass(
  element: HTMLElement,
):
  | (CustomElementConstructor & {formAssociated?: boolean; observedAttributes?: string[]})
  | undefined {
  return typeof customElements === 'object'
    ? customElements.get(element.tagName.toLowerCase())
    : undefined;
}

customElements.get() runs at runtime. Which means the element has to be registered before Angular looks at it.

  • Lazy-loaded design system.
  • Code splitting.
  • Upgrade after first render.

Any of those and you quietly fall back to the old behaviour. A bug that doesn’t reproduce, because it depends on chunk load order.

What actually happened here

The boundary of “native” in Angular no longer matches the HTML spec.

It used to be a closed list the framework carried in its head. Now it’s a claimable property — anything that convincingly says it’s a form control gets treated like one.

That’s the right direction. The implementation is heuristics stacked on heuristics — which is exactly why it’d be good to have more eyes on it.

It’s a small, focused, non-breaking change that unblocks design systems built on web components. And the decisions inside it — guessing capability from observedAttributes, resolving the registry at runtime — are the kind that get much harder to revisit once they ship.

Open since June. Would be great to see it move: angular/angular#69452.


Read also