Skip to content

fix(forms): clean up fields when the form injector is destroyed - #71754

Open
splincode wants to merge 1 commit into
angular:mainfrom
splincode:fix/signal-form-host-teardown
Open

splincode wants to merge 1 commit into
angular:mainfrom
splincode:fix/signal-form-host-teardown

Conversation

@splincode

@splincode splincode commented Oct 31, 2026 •

Copy link
Copy Markdown
Contributor

PR Checklist

  • The commit message follows the guidelines.
  • Tests have been added.
  • The injector option documents resource cleanup.

PR Type

  • Bugfix

What is the current behavior?

Destroying a form's component or environment injector leaves its field injectors alive. Pending async validation is not aborted, managed metadata cleanup does not run, and environment-owned validation can start another request after teardown.

Fixes #71622.

What is the new behavior?

The field manager registers one destroy callback on the form's chosen injector and destroys its remaining fields. Root and compat structures join the existing registry. Removed fields are already deleted from that registry, and destroying an unused field does not allocate an injector.

This implements the manager-based approach described in the issue. An explicitly supplied injector remains the owner: destroying the calling component does not end the form's lifetime.

Does this PR introduce a breaking change?

  • No

Public API signatures and update scheduling are unchanged. The intended behavior change is cleanup of resources that previously survived their owner.

Validation

On main at 74961c662420ea02a5872bfa1a0ee3284526b887, all eight new lifecycle specs fail. With the fix:

  • //packages/forms/signals/test/node:test: 561 specs, 0 failures.
  • //packages/forms/signals/test/web:test_chromium: 362 specs, 0 failures.
  • ng-dev format, Prettier and git diff --check pass.

Coverage includes root and child cleanup before the first effect tick, component teardown, compat field injectors, exactly-once cleanup after removal, aborting pending validation, no validation loads after owner destruction, and the external-injector lifetime.

Local Bazel runs use telemetry opt-out, the system Java CA trust store, and a local test-runner workaround that supplies loopback only if this container rejects os.networkInterfaces(). No test assertions or Angular behavior are altered by that workaround. The full monorepo suite was not run.

The lazy-destroy guard also appears in #71737; it is needed here to avoid creating injectors during owner teardown.

Production-mode memory measurement

Same AOT/ES2022/minified signup fixture built from base 74961c662420ea02a5872bfa1a0ee3284526b887 and PR head 1726af8bba3b3c1f1a31221698e919071ff66c74, with ngDevMode=false and ngJitMode=false. One username input, validateAsync(resource(fetch(...))), and 100 open/close cycles through @if.

Seven alternating pairs per scenario, fresh contexts, 20 completed-request warmup cycles. Metric: increase in Runtime.getHeapUsage().usedSize over the post-warmup heap, after three forced GCs. Chromium 158.0.8117.0, Linux x64. Medians:

After 100 destroyed forms Base PR
Heap increase while validation is pending 3.144 MiB 0.131 MiB
Completed-before-navigation control 0.148 MiB 0.146 MiB
Pending case after letting old requests finish 0.131 MiB 0.132 MiB
Pending validator loads not aborted on teardown 100 0

Median paired reduction: 3.012 MiB (3,158,788 bytes; observed range 3,157,976–3,159,336 bytes). All 100 pending loads were aborted in every PR sample. Counts describe loader/fetch invocations; Chromium can queue/coalesce local HTTP/1.1 requests.

This is a controlled production-mode fixture, not a deployed application. The completed control and response-release check show retention while validation is pending; this is not a claim of permanent component leakage after responses arrive or of an RSS/peak-memory reduction.

Reproduction bundles, fixture source, raw samples and build hashes:

angular-71754-memory-evidence.zip

@ngbot ngbot Bot added this to the Backlog milestone Oct 31, 2026
@pullapprove
pullapprove Bot requested a review from kirjs October 31, 2026 06:25

@JeanMeche JeanMeche left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AGENT: Thanks for the PR and thorough memory benchmarking! Addressing field teardown and aborting in-flight async validators when the form host is destroyed (#71622) is an essential fix.

I've left a few inline comments with architectural suggestions:

  1. Destruction Order (Root before Children): this.structures is a Set, iterating in insertion order (root first, children later). Reversing this order during teardown ensures child cleanup hooks don't attempt to access an already-destroyed parent or root injector (NG0205).
  2. Registration location: Setting up the DestroyRef.onDestroy hook in FormFieldManager's constructor cleanly separates lifecycle management from createFieldManagementEffect.
  3. Post-teardown state (isDestroyed): Guarding against field creation after host teardown avoids zombie field allocation.
  4. Rebase: The this._injector?.destroy() guard has already landed on main via #71737 (67e5069327).

{injector: this.injector},
);

this.injector.get(DestroyRef).onDestroy(() => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AGENT: A few architectural considerations regarding this teardown callback:

  1. Placement in createFieldManagementEffect: Registering the host injector's onDestroy callback inside createFieldManagementEffect conflates effect creation with one-time host lifecycle management. FormFieldManager already receives injector in its constructor; setting up DestroyRef.onDestroy in the constructor is cleaner and ensures teardown is configured regardless of how or when effects are created.

  2. Destruction order (Root before Children): this.structures is a Set, so iteration order is insertion order. Because RootFieldNodeStructure is instantiated before its children, this loop destroys the root field first, followed by child fields. In Angular/DI lifecycles, teardown is typically bottom-up (children before parent/root). If a child field's onDestroy callback or managed metadata cleanup attempts to read from root or access services via root, the root injector has already been destroyed (causing NG0205: Injector has already been destroyed). Reversing the order or traversing bottom-up would prevent this.

  3. Re-entrancy / Mutation hazard: If any cleanup logic accesses another field on the form during teardown, ChildFieldNodeStructure will call this.structures.add(this) while iterating. Taking a snapshot (e.g. Array.from(this.structures).reverse()) and maintaining an isDestroyed guard avoids re-entrancy bugs and prevents zombie field allocation after host teardown.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, Jean! I moved the destroy hook into the constructor and now tear down a snapshot in reverse order. I added a test that checks the root injector is still available during child cleanup and that cleanup can't create new fields or field injectors.

this.pathKeys = computed(() =>
this.parent ? [...this.parent.structure.pathKeys(), this.keyInParent()] : [],
);
this.fieldManager.structures.add(this);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AGENT: Note an existing limitation with child compat nodes: in packages/forms/signals/compat/src/compat_field_node.ts, extractControlPropToSignal resolves its injector via getInjectorFromOptions, which returns root.structure.injector for child compat fields.

Because child compat structures don't own the injector used for toSignal(control.statusChanges), removing an individual child compat field (e.g. from an array of FormControls) calls destroy() on an undefined _injector, leaving the subscription active on root until the entire form is destroyed.

While adding CompatStructure to structures ensures that whole-form teardown cleans up anything registered on the compat structure's injector, child compat removal subscriptions remain retained on root.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, the root injector still owns those subscriptions. Removing a compat child leaves them active until the form is destroyed. To clean them up when the child is removed, we'd need to tie the subscriptions to the child field.

/** Destroys the field when it is no longer needed. */
destroy(): void {
this.injector.destroy();
this._injector?.destroy();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AGENT: Heads up: this optional-chaining fix (this._injector?.destroy()) has already been merged into main via #71737 (commit 67e5069327). Rebasing on the latest main will clear up this change from the diff.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rebased on main. The guard from #71737 is out of the diff.

describe('form lifecycle', () => {
useAutoTick();

const resources: ResourceRef<string | undefined>[] = [];

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AGENT: Minor cleanup: const resources: ResourceRef<string | undefined>[] = []; is set up and cleared in afterEach, but none of the specs in this file push into resources.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pendingValidation() pushes each resource into that array. afterEach uses it to clean up pending resources if the teardown under test fails. I added a comment to make that clear.

Destroy remaining field injectors with the form's chosen injector so pending
validation is aborted and managed metadata cleanup runs. Register teardown
in the field manager constructor and destroy descendants before their parents.
Prevent cleanup callbacks from materializing new fields or field injectors.

Preserve cleanup of removed fields and the lifetime of an explicitly
provided external injector.

Fixes angular#71622
@splincode
splincode force-pushed the fix/signal-form-host-teardown branch from 1726af8 to 4fba3d8 Compare November 11, 2026 06:18
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.

Signal forms: destroying a form's component doesn't destroy its fields, so async validation keeps running

2 participants