Repository navigation
Conversation
JeanMeche
left a comment
There was a problem hiding this comment.
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:
- Destruction Order (Root before Children):
this.structuresis aSet, 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). - Registration location: Setting up the
DestroyRef.onDestroyhook inFormFieldManager's constructor cleanly separates lifecycle management fromcreateFieldManagementEffect. - Post-teardown state (
isDestroyed): Guarding against field creation after host teardown avoids zombie field allocation. - Rebase: The
this._injector?.destroy()guard has already landed onmainvia #71737 (67e5069327).
| {injector: this.injector}, | ||
| ); | ||
|
|
||
| this.injector.get(DestroyRef).onDestroy(() => { |
There was a problem hiding this comment.
AGENT: A few architectural considerations regarding this teardown callback:
-
Placement in
createFieldManagementEffect: Registering the host injector'sonDestroycallback insidecreateFieldManagementEffectconflates effect creation with one-time host lifecycle management.FormFieldManageralready receivesinjectorin its constructor; setting upDestroyRef.onDestroyin the constructor is cleaner and ensures teardown is configured regardless of how or when effects are created. -
Destruction order (Root before Children):
this.structuresis aSet, so iteration order is insertion order. BecauseRootFieldNodeStructureis 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'sonDestroycallback or managed metadata cleanup attempts to read fromrootor access services via root, the root injector has already been destroyed (causingNG0205: Injector has already been destroyed). Reversing the order or traversing bottom-up would prevent this. -
Re-entrancy / Mutation hazard: If any cleanup logic accesses another field on the form during teardown,
ChildFieldNodeStructurewill callthis.structures.add(this)while iterating. Taking a snapshot (e.g.Array.from(this.structures).reverse()) and maintaining anisDestroyedguard avoids re-entrancy bugs and prevents zombie field allocation after host teardown.
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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(); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Rebased on main. The guard from #71737 is out of the diff.
| describe('form lifecycle', () => { | ||
| useAutoTick(); | ||
|
|
||
| const resources: ResourceRef<string | undefined>[] = []; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
1726af8 to
4fba3d8
Compare
PR Checklist
PR Type
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?
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.git diff --checkpass.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
74961c662420ea02a5872bfa1a0ee3284526b887and PR head1726af8bba3b3c1f1a31221698e919071ff66c74, withngDevMode=falseandngJitMode=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().usedSizeover the post-warmup heap, after three forced GCs. Chromium 158.0.8117.0, Linux x64. Medians: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