Skip to content

Signal forms: required(), min(), and other built-in rules create an injector for every field #71623

Open

Description

@Flarette

Which @angular/* package(s) are the source of the bug?

forms

Is this a regression?

No

Description

In a signal form, each field can get its own injector, so that a managed metadata key, like the one validateAsync() uses, can create resources in the field's injection context. But today a field creates an injector as soon as it has any metadata rule. Most built-in rules add metadata, so required(), min(), max(), minLength(), maxLength(), minDate(), maxDate(), pattern(), validateStandardSchema(), and debounce() all give each field an injector that nothing uses.

Removing a field also creates an injector if it didn't have one, just so the cleanup can destroy it.

Why it matters. The cost grows with the number of fields. Large tables, line-item lists, and dynamic arrays usually put required() or min() on every row. Each injector took about 1.1 to 1.5 µs to create in my measurement, and it also holds memory, which I didn't measure. This doesn't break anything, but it's work that the feature doesn't need.

Example. An order form with 1,000 line items:

import {Component, signal} from '@angular/core';
import {applyEach, form, FormField, min, required} from '@angular/forms/signals';

@Component({
  selector: 'order-lines',
  imports: [FormField],
  template: `
    @for (line of f; track $index) {
      <input [formField]="line.sku" />
      <input type="number" [formField]="line.qty" />
    }
  `,
})
export class OrderLines {
  readonly lines = signal(Array.from({length: 1000}, () => ({sku: '', qty: 1})));
  readonly f = form(this.lines, (p) => {
    applyEach(p, (line) => {
      required(line.sku);
      min(line.qty, 1);
    });
  });
}

Current behavior:

read every line's fields, as the template does   → 2,000 injectors (one per sku field, one per qty field)
lines.set([])                                    → 1,000 more injectors (one per line, created only to be destroyed)

Expected behavior (my assumption):

read every line's fields, as the template does   → 0 injectors (neither rule needs an injection context)
lines.set([])                                    → 0 injectors

This assumes that only managed metadata keys need a field injector. That's what the field metadata guide documents: a managed key's create runs in the field's injection context. Plain metadata keys only compute a value. Fields with validateAsync() or validateHttp() would still get an injector.

Please provide a link to a minimal reproduction of the bug

Failing specs: field_injector_allocation.spec.ts in triage PR #71626. The PR changes no library code.

pnpm bazel test //packages/forms/signals/test/node:test --test_arg=--filter="field injector allocation" --test_output=all

Please provide the exception or error you saw

There's no error. The specs count Injector.create calls for 50 array items with one rule each. They fail with:

creates injectors only for fields with managed metadata keys (50 items per rule)
    Expected $.required = 50 to equal 0.
    Expected $.min = 50 to equal 0.
    Expected $.debounce = 50 to equal 0.
creates no injectors to destroy 50 removed fields that never had one
    Injector.create calls while the cleanup sweep destroys the removed items: Expected 50 to be 0.

Please provide the environment you discovered this bug in (run ng version)

angular/angular main at 3e173fc844c440775d3f110da8505442e227dee5 (2026-10-24), built from source
Test target: //packages/forms/signals/test/node:test
OS: macOS (darwin arm64)

Anything else?

Affected areas:

  • Fields with any of these rules: required(), min(), max(), minLength(), maxLength(), minDate(), maxDate(), pattern(), validateStandardSchema(), and debounce().
  • Fields with custom metadata() rules that use plain keys from createMetadataKey().
  • Removing any field that never had an injector, including fields with no rules.
  • Combined with Signal form children initialization is not really lazy #70627, where the cleanup sweep materializes every reachable field, every field with one of these rules gets an injector after the first sweep, even if nothing reads it. I found this by reading the code, and no spec checks it.
  • Not affected: fields with no rules, or with validate() only. Fields with validateAsync() or validateHttp() need their injector and keep it.

Fix options and tradeoffs. The PR has code for each option.

Option What changes Tradeoffs
A (recommended): create the injector only for managed keys Move runInInjectionContext() inside the check for key.create, and don't create an injector during cleanup Small change, same documented contract. A field with several managed keys enters the injection context once per key
B: cache a "has managed keys" flag on the schema logic Return early before touching the injector Also skips a loop per field, but the cached flag could go stale, because metadata keys are added lazily
C: replace field injectors with a lightweight scope Shared with the host teardown issue (#71622) Cheapest per field, but a much larger change

Where to look in the code:

Activity

  1. added this to the needsTriage milestone on Oct 26, 2026
  2. added this to the needsTriage milestone on Oct 26, 2026
  3. added a commit that references this issue on Oct 30, 2026
    8259122
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions