Skip to content

test(forms): reproduce unneeded field injectors in signal forms - #71626

Closed
Flarette wants to merge 1 commit into
angular:mainfrom
Flarette:triage/forms-injector-allocation
Closed

Flarette wants to merge 1 commit into
angular:mainfrom
Flarette:triage/forms-injector-allocation

Conversation

@Flarette

Copy link
Copy Markdown
Contributor

Issue #71623 | Triage note | Failing specs

Why the change

This adds failing specs showing that signal form fields create injectors that nothing uses, so that we can agree on the scope of the fix before I write it.

Special things to note

Change outline

What this PR adds

packages/forms/signals/
├── test/node/field_injector_allocation.spec.ts   # 2 failing specs
└── triage/injector-allocation.md                 # spec output, root cause with links, fix options

Where the injector comes from

new FieldNode(...)
  metadataState.runMetadataCreateLifecycle()
    if the field has no metadata keys: return        # no rule, or validate() only
    runInInjectionContext(structure.injector, ...)   # the getter creates an R3Injector here
      for each metadata key
        if key.create: key.create(...)               # only managed keys, such as validateAsync()

cleanup sweep: a field was removed from the model
  structure.destroy()
    this.injector.destroy()                          # the getter creates an injector if there was none

Injector.create calls for a form over 50 array items, with one rule on each item, today:

no rule            0
validate()         0
required()        50
min()             50
debounce()        50
validateAsync()   50
remove 50 items without a rule: 50

After the recommended fix:

no rule            0
validate()         0
required()         0
min()              0
debounce()         0
validateAsync()   50
remove 50 items without a rule: 0

Recommended fix: enter an injection context only for managed keys

 // packages/forms/signals/src/field/metadata.ts, class FieldMetadataState
   runMetadataCreateLifecycle(): void {
     // ...early return and params-flag handling unchanged...
-      untracked(() =>
-        runInInjectionContext(this.node.structure.injector, () => {
-          for (const key of this.node.logicNode.logic.getMetadataKeys()) {
-            if (key.create) {
-              /* create the managed value */
-            }
-          }
-        }),
-      );
+      untracked(() => {
+        for (const key of logic.getMetadataKeys()) {
+          if (!key.create) {
+            continue;
+          }
+          // Only managed keys need the field's injector, so it's created here and nowhere else.
+          runInInjectionContext(this.node.structure.injector, () => {
+            /* create the managed value, unchanged */
+          });
+        }
+      });
   }
 // packages/forms/signals/src/field/structure.ts, class FieldNodeStructure
   destroy(): void {
-    this.injector.destroy();
+    this._injector?.destroy();
   }
  • Same contract. A managed key's create still runs in the field's injection context, which is all the guide promises. Plain keys only build a computed().
  • No other readers. Nothing else reads a field's injector. compatForm() children read the root's injector, and that read stays lazy.
  • Cost. A field with several managed keys enters the injection context once per key instead of once.

Alternatives

Option Change Tradeoff
B: cache "has managed keys" on the logic node Return early from runMetadataCreateLifecycle() when no key has create Also skips the key loop. But getMetadata() adds keys lazily, so a cached flag could go stale
C: a lightweight field scope instead of an R3Injector Option C in the host teardown triage Makes managed keys cheaper too. Much larger change

Decisions for maintainers

  1. Injection context scope. Do you want every field with metadata to keep an injection context, for example for planned features? Option A narrows it to managed keys, which matches what the guide documents.
  2. How much this matters. One injector costs about 1.1 to 1.5 µs to create in Node, and I didn't measure memory. Is that worth a fix now, or should it wait for the lightweight scope?
  3. Test style. Is counting Injector.create calls acceptable in a regression test, or would you prefer another check?

PR Checklist

Please check if your PR fulfills the following requirements:

PR Type

What kind of change does this PR introduce?

  • Bugfix
  • Feature
  • Code style update (formatting, local variables)
  • Refactoring (no functional changes, no api changes)
  • Build related changes
  • CI related changes
  • Documentation content changes
  • angular.dev application / infrastructure changes
  • Other... Please describe: triage. Failing repro specs and a triage note, with no library changes.

Does this PR introduce a breaking change?

  • Yes
  • No

@google-cla

This comment was marked as outdated.

A signal form field with any metadata rule, such as required(), min(),
or debounce(), creates its own R3Injector, although only managed
metadata keys use it. Removing a field that never had an injector also
creates one just to destroy it.

Add specs that count injector creation, plus a triage note with the
root cause and fix options. The specs fail until a fix lands. The note
is for review and isn't meant to be merged.
@Flarette
Flarette force-pushed the triage/forms-injector-allocation branch from 20db882 to ded228f Compare October 26, 2026 07:06
@JeanMeche

Copy link
Copy Markdown
Member

Going to close the PRs as they're won't me merged as is. Let's move the discussions to the issues.

@JeanMeche JeanMeche closed this Oct 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants