Skip to content

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

Open

Description

@Flarette

Which @angular/* package(s) are the source of the bug?

forms

Is this a regression?

No

Description

When the component that owns a signal form is destroyed, the form's fields aren't. Anything a field sets up for its own lifetime keeps going:

  • An async validation request that's in flight isn't cancelled.
  • Cleanup that a managed metadata key registers with DestroyRef never runs.
  • If the form was created with an environment injector, its validators keep reacting to model changes and start new requests after the injector is destroyed.

Fields that are removed from the model, such as deleted array items, are cleaned up correctly. Only the teardown of the whole form is missing.

Why it matters. Users who leave a page with a pending username check, address lookup, or similar validator keep that request running. Libraries that build on createManagedMetadataKey() can't rely on DestroyRef for cleanup, even though the field metadata guide says that cleanup is tied to the field's lifecycle.

Example. A form checks whether a username is available:

import {Component, resource, signal} from '@angular/core';
import {form, FormField, validateAsync} from '@angular/forms/signals';

@Component({
  selector: 'signup-form',
  imports: [FormField],
  template: `<input [formField]="f.username" />`,
})
export class SignupForm {
  readonly f = form(signal({username: ''}), (p) => {
    validateAsync(p.username, {
      params: ({value}) => value(),
      factory: (username) =>
        resource({
          params: username,
          loader: ({params, abortSignal}) =>
            fetch(`/api/username-available?u=${params}`, {signal: abortSignal}).then((r) => r.json()),
        }),
      onSuccess: (available) => (available ? undefined : {kind: 'taken'}),
      onError: () => undefined,
    });
  });
}

The user types alice, and the form starts GET /api/username-available?u=alice. Before it returns, the user navigates away, and Angular destroys SignupForm.

Current behavior:

SignupForm destroyed
  username field: still alive
  GET /api/username-available?u=alice: still running (abortSignal.aborted === false)

Expected behavior (my assumption):

SignupForm destroyed
  username field: destroyed
  GET /api/username-available?u=alice: cancelled (abortSignal.aborted === true)

This assumes that a form's fields live as long as the injector the form was created with: the component's injector by default, or the injector option. Neither the API docs nor the guide says when a whole form's fields are destroyed, so this needs a maintainer decision. I've listed the open questions in the triage PR.

Please provide a link to a minimal reproduction of the bug

Failing specs: form_host_teardown.spec.ts in triage PR #71625. The PR changes no library code.

pnpm bazel test //packages/forms/signals/test/node:test --test_arg=--filter="form host teardown" --test_output=all

Please provide the exception or error you saw

There's no error. The specs fail with:

runs root-field DestroyRef callbacks when the form injector is destroyed
    root-field DestroyRef callbacks after env.destroy(): Expected spy destroyed to have been called once. It was called 0 times.
runs live child-field DestroyRef callbacks when the form injector is destroyed
    live child-field DestroyRef callbacks after env.destroy(): Expected spy destroyed to have been called once. It was called 0 times.
aborts an in-flight validateAsync load when the form injector is destroyed
    loader AbortSignal.aborted after env.destroy(): Expected false to be true.
aborts an in-flight validateAsync load when the host component is destroyed
    loader AbortSignal.aborted after fixture.destroy(): Expected false to be true.
starts no validation loads after the form injector is destroyed
    loads started, including any after env.destroy(): Expected 2 to be 1.

Please provide the environment you discovered this bug in (run ng version)

angular/angular main at 3e173fc844c440775d3f110da8505442e227dee5 (2026-10-24), built from source
Test target: //packages/forms/signals/test/node:test
OS: macOS (darwin arm64)

Anything else?

Affected areas:

  • validateAsync(), validateHttp(), and validateStandardSchema() with async schemas: in-flight loads aren't aborted when the host is destroyed.
  • Custom managed metadata keys (createManagedMetadataKey()): DestroyRef callbacks, effect()s, and resource()s that create sets up aren't cleaned up.
  • Forms created with an environment injector, for example through the injector option: validation effects keep running, so new loads start after the injector is destroyed.
  • compatForm() fields: they use a separate structure class that would need the same fix.
  • Not affected: fields removed from the model. The form's cleanup sweep destroys them, and the triage PR has a passing spec that shows it.

Fix options and tradeoffs. The PR has code for each option.

Option What changes Tradeoffs
A (recommended): the form destroys all of its fields when its injector is destroyed One DestroyRef hook per form, plus registering the root field About 15 lines. Defines the lifetime contract above. Doesn't define what happens if code reads a new field after teardown
B: each field's injector registers with the form's injector A hook per field instead of per form No bookkeeping, but more hooks on the host, and removing hooks during teardown needs care
C: replace the per-field injectors with a lightweight scope A new small Injector type for fields Also removes the per-field injector cost in #71623. Much larger, and it touches dependency injection behavior

Where to look in the code:

Activity

  1. added this to the needsTriage milestone on Oct 26, 2026
  2. added this to the needsTriage milestone on Oct 26, 2026
  3. added a commit that references this issue on Nov 11, 2026
    4fba3d8
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions