Skip to content

test(forms): add repro specs for per-control parse errors - #71629

Closed
Flarette wants to merge 1 commit into
angular:mainfrom
Flarette:triage/forms-errors-per-control
Closed

Flarette wants to merge 1 commit into
angular:mainfrom
Flarette:triage/forms-errors-per-control

Conversation

@Flarette

@Flarette Flarette commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

Issue #71632 | Triage notes | Repro specs

Why the change

When one field has several controls, CVA and native hosts receive every control's parse errors instead of only their own, a regression from the refactor that moved control logic into FormField; this draft adds failing repro specs and asks you to confirm the intended behavior before I write the fix.

Special things to note

  • This is a triage draft with no fix. The 2 repro specs fail on purpose, so CI is red.
  • The intent is already on record: ebae211add filtered errors per control for every host, and
    a67e00741c kept the filter only for custom controls.
  • invalid stays field-level. Every control still receives invalid: true.

Change outline

What the branch adds:

packages/forms/signals/
├── test/web/form_field_errors_per_binding.spec.ts   # 2 failing repros
└── triage/errors-per-control.md                     # observed output, history, NgControl path

How each host reads errors today:

custom host   control_custom.ts   parent.errors()                            filtered per binding
CVA host      control_cva.ts      readFieldStateBindingValue(fieldState)     unfiltered
native host   control_native.ts   readFieldStateBindingValue(state)          unfiltered
NgControl     InteropNgControl    field().errors()                           unfiltered

Recommended fix: one code path for every host

src/directive/bindings.ts:

 export function readFieldStateBindingValue(
-  fieldState: ReadonlyFieldState<unknown>,
+  parent: FormField<unknown>,
   key: ControlBindingKey,
 ): unknown {
+  // `FormField.errors` drops parse errors that belong to other bindings of the same field.
+  if (key === FIELD_STATE_KEY_TO_CONTROL_BINDING.errors) {
+    return parent.errors();
+  }
   const property = CONTROL_BINDING_TO_FIELD_STATE_KEY[key];
-  return fieldState[property]?.();
+  return parent.state()[property]?.();
 }

src/directive/control_custom.ts drops its special case:

     for (const name of CONTROL_BINDING_NAMES) {
-      let value: unknown;
-      if (name === 'errors') {
-        value = parent.errors();
-      } else {
-        value = readFieldStateBindingValue(state, name);
-      }
+      const value = readFieldStateBindingValue(parent, name);

src/directive/control_cva.ts and src/directive/control_native.ts:

-      const value = readFieldStateBindingValue(fieldState, name);
+      const value = readFieldStateBindingValue(parent, name);

Optional, same intent: NgControl.errors

FormField creates one InteropNgControl per binding, but builds it from the field state only, so
inject(NgControl).errors has the same leak:

 // src/directive/form_field.ts
   get interopNgControl(): InteropNgControl {
-    return (this._interopNgControl ??= new InteropNgControl(this.state));
+    return (this._interopNgControl ??= new InteropNgControl(this.state, this.errors));
   }

 // src/controls/interop_ng_control.ts
-  constructor(protected field: () => ReadonlyFieldState<unknown>) {}
+  constructor(
+    protected field: () => ReadonlyFieldState<unknown>,
+    private readonly bindingErrors: () => readonly ValidationError[],
+  ) {}

   get errors(): ValidationErrors | null {
-    return signalErrorsToValidationErrors(this.field().errors());
+    return signalErrorsToValidationErrors(this.bindingErrors());
   }

Alternative considered

Copy the special case from control_custom.ts into control_cva.ts and control_native.ts. That's
the smallest diff, but keeping three copies is how the behavior drifted.

Design decisions

  1. Per-control errors for every host. Recommended, because it restores the documented intent.
    ebae211add says: "we only want to report parse errors that are relevant for this particular UI
    control." 22afbb2f36 says a native parse error "is added for the control that received the
    un-parsable value."
  2. invalid stays field-level. It was never filtered, even in ebae211add. A control can
    receive invalid: true with an empty errors, which is what custom controls get today.
  3. Scope. Include NgControl.errors in the same fix, or leave it for a follow-up. CVA
    components written for reactive forms often read NgControl.errors to show their own error
    state.

Questions for maintainers

  1. Can you confirm that every host should receive only its own binding's parse errors, as custom
    controls do today?
  2. Should NgControl.errors be filtered in the same PR?
  3. Is it intended that a control receives invalid: true with no errors when another control on
    the same field has the parse error?

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: triage. Failing repro specs and triage notes, with no library changes.

What is the current behavior?

Issue Number: #71632

What is the new behavior?

No behavior changes yet. This PR adds failing repro specs and triage notes. The fix will replace the
notes on this branch once the questions above are answered.

Does this PR introduce a breaking change?

  • Yes
  • No

CVA and native hosts receive the field's unfiltered `errors`, so a
parse error from one control shows up on every other control bound to
the same field. `NgControl.errors` has the same leak. Custom controls
still receive the filtered errors.

These specs assert the expected behavior and fail on purpose. The
triage notes trace the regression to a refactor. A fix will replace
the notes once maintainers confirm the intended behavior.
@JeanMeche

Copy link
Copy Markdown
Member

Going to close the PRs as they're won't me merged as is. Let's move the discussions to the issues.

@JeanMeche JeanMeche closed this Oct 26, 2026
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.

2 participants