Skip to content

perf(forms): avoid retaining AbstractControl in signal forms - #71753

Open
JeanMeche wants to merge 2 commits into
angular:mainfrom
JeanMeche:fix-signal-forms-abstract-control-tree-shaking
Open

JeanMeche wants to merge 2 commits into
angular:mainfrom
JeanMeche:fix-signal-forms-abstract-control-tree-shaking

Conversation

@JeanMeche

@JeanMeche JeanMeche commented Oct 31, 2026 •

Copy link
Copy Markdown
Member

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: Performance / bundle size optimization

What is the current behavior?

FieldNodeContext.valueOf checks result instanceof AbstractControl to throw NG1907 when reading an AbstractControl inside a regular form(). That value reference prevents AbstractControl (along with control event classes and validator helpers) from being tree-shaken in applications that only use signal forms.

Issue Number: Fixes #71621

What is the new behavior?

  • Introduces an internal symbol-based type guard ɵisAbstractControl that checks a private Symbol set in AbstractControl's constructor without referencing the AbstractControl class value, remaining compatible with Closure Compiler (jscompiler) property renaming and ES2022 class tree-shaking.
  • Adds a bundling symbol test (//packages/core/test/bundling/forms_signals:symbol_test) verifying that AbstractControl and its associated classes/helpers are tree-shaken in signal forms bundles.

Impact / Dropped Symbols

  • In [formField] apps (//packages/core/test/bundling/forms_signals): Drops 19 symbols (~1.9 KB gzipped) from @angular/forms:
    • Core model & statuses: AbstractControl, VALID, INVALID, PENDING, DISABLED
    • Control event classes: ValueChangeEvent, StatusChangeEvent, PristineChangeEvent, TouchedChangeEvent, FormSubmittedEvent
    • Validator & options helpers: addValidators, removeValidators, hasValidator, makeValidatorsArray, coerceToValidator, coerceToAsyncValidator, pickValidators, pickAsyncValidators, isOptionsObj
  • In model-only form() apps (without [formField]): Drops ~75 symbols (~4.7 KB gzipped), additionally tree-shaking Validators, all built-in validator functions, ControlEvent, FormResetEvent, RxJS async validator helpers (forkJoin, from, innerFrom, scheduled, map, etc.), and tslib async generator helpers.

Does this PR introduce a breaking change?

  • Yes
  • No

Other information

Follow-up opportunity for [formField]

FormField still retains NgControl, AbstractControlDirective, Validators, RequiredValidator, FormResetEvent, and ReactiveValidationError because:

  1. FormField provides {provide: NgControl, useFactory: () => inject(FormField).interopNgControl} for CVA / inject(NgControl) interop, and NgControl extends AbstractControlDirective and references FormResetEvent, RequiredValidator, and ReactiveValidationError in its methods.
  2. InteropNgControl.hasValidator and cvaControlCreate reference Validators.required and Validators.compose.

Decoupling the NgControl DI token from its directive implementation and avoiding direct references to the Validators class in InteropNgControl / cvaControlCreate could drop an additional ~55 symbols (including the remaining RxJS forkJoin / from async validator helpers) from [formField] bundles in a follow-up.

@angular-robot angular-robot Bot added area: performance Issues related to performance area: forms labels Oct 31, 2026
@ngbot ngbot Bot added this to the Backlog milestone Oct 31, 2026
@JeanMeche
JeanMeche marked this pull request as ready for review October 31, 2026 00:59
@JeanMeche
JeanMeche requested a review from leonsenft October 31, 2026 00:59

@leonsenft leonsenft left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nice!

Would you mind splitting the single commit into two: first land the golden, then land the optimization so it's evident from the diff between the first and second commit?

@@ -572,6 +586,7 @@ export abstract class AbstractControl<
validators: ValidatorFn | ValidatorFn[] | null,
asyncValidators: AsyncValidatorFn | AsyncValidatorFn[] | null,
) {
(this as Record<symbol, unknown>)[ABSTRACT_CONTROL] = true;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Can this just be a field rather than assigned in the constructor?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Unfortunately not — in ES2022/Rollup, declaring a computed class field (private readonly [ABSTRACT_CONTROL] = true;) evaluates the computed property key during class definition at module evaluation time. Bundlers treat class declarations with computed fields as side-effectful, which prevents AbstractControl (and its related symbols) from being tree-shaken (fails the symbol_test). Keeping the assignment inside the constructor ensures the class declaration remains side-effect-free at the module level.

Add a bundling symbol test and baseline golden file for signal forms applications.
Previously, `FieldNodeContext.valueOf` used `result instanceof AbstractControl` to throw `NG1907` when reading an `AbstractControl` inside a regular `form()`. That value reference prevented `AbstractControl` (along with control event classes and validator helpers) from being tree-shaken in applications that only use signal forms.

Introduce an internal symbol-based type guard `ɵisAbstractControl` that checks a private symbol set in `AbstractControl`'s constructor without referencing the `AbstractControl` class value.

Fixes angular#71621
@JeanMeche
JeanMeche force-pushed the fix-signal-forms-abstract-control-tree-shaking branch from 5d0d4ef to 3e96a2b Compare November 2, 2026 20:26
@JeanMeche

Copy link
Copy Markdown
Member Author

Split into two commits as requested: the first commit introduces the bundling test and baseline golden, and the second commit applies the optimization so the dropped symbols are clearly visible in the diff.

@JeanMeche JeanMeche added action: merge The PR is ready for merge by the caretaker target: patch This PR is targeted for the next patch release labels Nov 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

action: merge The PR is ready for merge by the caretaker area: forms area: performance Issues related to performance target: patch This PR is targeted for the next patch release

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Signal forms: every app that uses form() ships AbstractControl and Validators from reactive forms

2 participants