Skip to content

test(forms): reproduce signal form fields outliving the form's injector - #71625

Closed
Flarette wants to merge 1 commit into
angular:mainfrom
Flarette:triage/forms-host-teardown
Closed

Flarette wants to merge 1 commit into
angular:mainfrom
Flarette:triage/forms-host-teardown

Conversation

@Flarette

Copy link
Copy Markdown
Contributor

Issue #71622 | Triage note | Failing specs

Why the change

This adds failing specs showing that destroying a signal form's host leaves its fields alive, so that we can agree on the teardown contract before I write the fix.

Special things to note

  • This PR is triage only: it changes no library code. Five of its six specs fail on purpose. A fix should make them pass, and they then stay as regression tests.
  • The recommended fix is about 15 lines. It also makes "a form's fields end with the form's injector" a contract, and I'd like a decision on that first (see the end).
  • The recommended fix changes FieldNodeStructure.destroy(), which the injector-per-field triage (Signal forms: required(), min(), and other built-in rules create an injector for every field #71623) also changes. Either can land first.

Change outline

What this PR adds

packages/forms/signals/
├── test/node/form_host_teardown.spec.ts   # 5 failing specs, 1 passing control spec
└── triage/host-teardown.md           # spec output, root cause with links, fix options

Who destroys a field today

A field creates its own R3Injector on first use, as a child of the form's injector. An R3Injector doesn't register with its parent, so only the cleanup sweep ever destroys a field, and the sweep only handles fields that were removed from the model.

form(model, schema, {injector})                     # injector: a component's or an environment injector
  FormFieldManager(injector)
    effect(sweep, {injector})                       # dies with the host
      destroys structures no longer reachable from the root
  root FieldNodeStructure                           # never in `structures`, so never swept
    get injector() → Injector.create({parent: injector})
  child FieldNodeStructure → fieldManager.structures.add(this)
    get injector() → Injector.create({parent: injector})

When the host is destroyed, today:

host.destroy()
  sweep effect: destroyed
  root field injector: alive
  child field injectors: alive
    validateAsync() resources: in-flight load not aborted
    DestroyRef callbacks from managed metadata keys: not called

After the recommended fix:

host.destroy()
  sweep effect: destroyed
  root field injector: destroyed
  child field injectors: destroyed
    validateAsync() resources: in-flight load aborted
    DestroyRef callbacks from managed metadata keys: called once

Recommended fix: the field manager destroys every field with the host

FormFieldManager registers one DestroyRef hook per form. The root joins structures, so the hook reaches it.

 // packages/forms/signals/src/field/manager.ts
   createFieldManagementEffect(root: FieldNodeStructure): void {
     effect(
       () => {
         const liveStructures = new Set<FieldNodeStructure>();
         this.markStructuresLive(root, liveStructures);

         // Destroy all nodes that are no longer live.
         for (const structure of this.structures) {
           if (!liveStructures.has(structure)) {
             this.structures.delete(structure);
             untracked(() => structure.destroy());
           }
         }
       },
       {injector: this.injector},
     );
+
+    this.injector.get(DestroyRef).onDestroy(() => {
+      for (const structure of this.structures) {
+        structure.destroy();
+      }
+      this.structures.clear();
+    });
   }
 // packages/forms/signals/src/field/structure.ts
 export class RootFieldNodeStructure extends FieldNodeStructure {
   constructor(/* ... */) {
     super(logic, node, createChildNode);
     this.childrenMap = this.createChildrenMap();
+    // The sweep always marks the root live, so only the host's teardown destroys it.
+    this.fieldManager.structures.add(this);
   }
 }

 // class FieldNodeStructure
   destroy(): void {
-    this.injector.destroy();
+    this._injector?.destroy();
   }
  • No double destroy. The sweep removes a structure from structures before destroying it, so the hook destroys only what's left. R3Injector.destroy() throws if it runs twice.
  • Both host kinds work. A component's injector returns its view's DestroyRef, and an environment injector returns itself.
  • compatForm(). CompatStructure doesn't join structures either. It needs the same structures.add(this) after it assigns fieldManager.
  • Cost. One hook per form.

Alternatives

Option Change Tradeoff
1B: each field injector registers with the host In the injector getter, register injector.destroy() on the parent's DestroyRef and unregister when the field is swept No registry, and the root and compat fields are covered for free. But it adds one host hook per field, and unregistering during host teardown needs care
1C: replace per-field R3Injectors with a lightweight field scope A small Injector that serves its own DestroyRef and delegates everything else to the form's injector. Child scopes register on their parent scope Also removes the per-field injector cost from #71623. It's a larger change: INJECTOR, destroyed, and EnvironmentInjector lookups need handling

The triage note has the full code for each option.

Decisions for maintainers

  1. Contract. Should destroying a form's injector destroy all of its fields? Nothing documents this today. The injector option's docs only mention dependency injection. The guide says cleanup is tied to "the field's lifecycle" but never says when that ends.
  2. Use after teardown. After the fix, a field that's first read after its host is destroyed gets an injector whose parent is destroyed, and nothing ever sweeps it. Should that throw a clear error, return an inert field, or stay undefined?
  3. Mechanism. 1A keeps per-field R3Injectors. If you'd rather replace them (1C), I'd fold this fix into that change instead.

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

google-cla Bot commented Oct 26, 2026

Copy link
Copy Markdown

Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA).

View this failed invocation of the CLA check for more information.

For the most up to date status, view the checks section at the bottom of the pull request.

Destroying the component or environment injector that owns a signal
form doesn't destroy its fields. In-flight validateAsync() loads aren't
aborted, DestroyRef callbacks registered by managed metadata keys never
run, and on an environment injector new loads keep starting.

Add specs that assert the expected teardown, 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-host-teardown branch from 5fb74d4 to c2529b8 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