-
-
Notifications
You must be signed in to change notification settings - Fork 109
Description
Description:
When using NgxControlValueAccessor with a FormControl that is configured with { updateOn: 'blur' }, there is a timing issue. If the custom control's blur handler sets the CVA's value (value$.set()) and immediately calls markAsTouched(), the FormControl's value is not updated on the first blur. The value is only propagated correctly on the second blur event.
This happens because markAsTouched() triggers the FormControl's value update mechanism before the rxEffect inside NgxControlValueAccessor has had a chance to call onChange() with the new pending value.
Minimal Reproduction
Here is the code needed to reproduce the issue.
- app.component.ts (The Parent)
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { TestControlComponent } from './test-control.component';
@Component({
selector: 'app-root',
standalone: true,
imports: [CommonModule, ReactiveFormsModule, TestControlComponent],
template: `
<h2>Bug Report: NgxControlValueAccessor with updateOn: 'blur'</h2>
<p>
Type in the input and blur. The value below will not update on the first blur.
</p>
<ct-test-control [formControl]="formControlTest"></ct-test-control>
<p>
<strong>FormControl Value:</strong> {{ formControlTest.value }}
</p>
`,
})
export class AppComponent {
// CRITICAL: The FormControl is configured with updateOn: 'blur'
formControlTest = new FormControl('', { updateOn: 'blur' });
}test-control.component.ts (The Custom Control)
import { Component, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { NgxControlValueAccessor } from 'ngxtension/control-value-accessor';
@Component({
selector: 'ct-test-control',
standalone: true,
imports: [CommonModule],
hostDirectives: [NgxControlValueAccessor],
template: `
<input
type="text"
(blur)="onBlur($event)"
[value]="controlValueAccessor.value$() ?? ''"
>
`,
})
export class TestControlComponent {
controlValueAccessor = inject(NgxControlValueAccessor);
// This implementation causes the bug
onBlur(event: FocusEvent) {
const value = (event.target as HTMLInputElement).value;
this.controlValueAccessor.value$.set(value);
this.controlValueAccessor.markAsTouched();
}
}Steps to Reproduce
Set up the components as described above.
Type "Hello World" into the input field.
Click outside the input field to trigger the blur event.
Expected Result
The text below the input should immediately update to:
FormControl Value: Hello World
Actual Result
The text below the input does not change. It remains empty or shows the previous value. If you click into the input and blur out a second time, the value then updates correctly to "Hello World".
Analysis
The issue is a race condition specific to the updateOn: 'blur' strategy.
The onBlur handler synchronously calls value$.set() and then markAsTouched().
markAsTouched() triggers the FormControl's onTouched callback. Because of updateOn: 'blur', this tells the control to update its value.
However, the rxEffect within NgxControlValueAccessor (which calls onChange() to provide the new pending value) has not executed yet. It is scheduled to run later in the same change detection cycle.
As a result, the FormControl attempts to update its value before it has received the new value, causing the update to fail on the first attempt.
Proposed Solution
Edit: I will leave the old solution there for "understanding" reasons. I however do not think the old proposed solution is a good solution anymore. I think the rxEffect makes the value change async is the main cause. Perhaps it should be thought about if the rxEffect implementation in NgxControlValueAccessor is even required as I do not see any necessaty to use this. Perhaps it should have a new method setValue() which performs the compareTo check and if the value is new calls onChange to propagate the internal state to the CAV? I think this is the cleaner solution and treats the root problem more consice. Also we would avoid the problem that valueChanges might be triggered twice.
See code which might cause the issue: https://github.com/ngxtension/ngxtension-platform/blob/ca1520ec1428ab899438174976d6ccea9cb6e917/libs/ngxtension/control-value-accessor/src/control-value-accessor.ts#L250C5-L254C1
Old Solution
The root cause is that markAsTouched currently only propagates the "touched" state, without ensuring the latest value has been delivered first. A more robust implementation would be for markAsTouched to also push the current value from the signal.
This would make the library resilient to this common pattern and handle the updateOn: 'blur' case correctly without requiring a workaround from the consumer.
Current Implementation in NgxControlValueAccessor:
public markAsTouched = () => this.onTouched();Suggested Change:
import { untracked } from '@angular/core'; // ... public markAsTouched = () => { // First, explicitly call onChange with the signal's current value. // This ensures that a FormControl with `updateOn: 'blur'` // receives the pending value *before* onTouched is called to apply it. this.onChange(untracked(this.value$())); // Then, propagate the touched state as before. this.onTouched(); };This change ensures that whenever a developer marks the control as touched, the FormControl has access to the most recent value, resolving the race condition natively within the library.
Potential issues:
It is probably useful to test how often a pipe would be called when we subscribe to valueChanges as for none blur FormControls it might be the case that now two changes are triggered?