Repository navigation
Conversation
|
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
force-pushed
the
triage/forms-host-teardown
branch
from
October 26, 2026 07:06
5fb74d4 to
c2529b8
Compare
Member
|
Going to close the PRs as they're won't me merged as is. Let's move the discussions to the issues. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
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
Who destroys a field today
A field creates its own
R3Injectoron first use, as a child of the form's injector. AnR3Injectordoesn'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.When the host is destroyed, today:
After the recommended fix:
Recommended fix: the field manager destroys every field with the host
FormFieldManagerregisters oneDestroyRefhook per form. The root joinsstructures, 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(); }structuresbefore destroying it, so the hook destroys only what's left.R3Injector.destroy()throws if it runs twice.DestroyRef, and an environment injector returns itself.compatForm().CompatStructuredoesn't joinstructureseither. It needs the samestructures.add(this)after it assignsfieldManager.Alternatives
injectorgetter, registerinjector.destroy()on the parent'sDestroyRefand unregister when the field is sweptR3Injectors with a lightweight field scopeInjectorthat serves its ownDestroyRefand delegates everything else to the form's injector. Child scopes register on their parent scopeINJECTOR,destroyed, andEnvironmentInjectorlookups need handlingThe triage note has the full code for each option.
Decisions for maintainers
injectoroption's docs only mention dependency injection. The guide says cleanup is tied to "the field's lifecycle" but never says when that ends.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?
Does this PR introduce a breaking change?