Repository navigation
Signal forms: required(), min(), and other built-in rules create an injector for every field #71623
Description
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=allPlease 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(), anddebounce(). - Fields with custom
metadata()rules that use plain keys fromcreateMetadataKey(). - 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 withvalidateAsync()orvalidateHttp()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:
runMetadataCreateLifecycle()enters the field's injection context before it checks whether any key needs it.FieldNodeStructure.destroy()reads the lazyinjectorgetter, which creates an injector if there wasn't one.- The triage note has the counts for each rule, the root cause, and the full code for each option.
Metadata
Metadata
Assignees
Type
Projects
- StatusShow more project fieldsNo status
Activity
perf(forms): avoid creating unused field injectors