Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions packages/forms/src/directives/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,12 @@ export function cleanUpValidators(

function setUpViewChangePipeline(control: FormControl, dir: NgControl): void {
dir.valueAccessor!.registerOnChange((newValue: any) => {
// The directive may have been reattached to a different control since this callback was
// registered. `cleanUpControl` disconnects the previous pipeline by registering a noop,
// but value accessors that accumulate callbacks passed to `registerOnChange` (instead of
// replacing them) may still invoke this stale callback. See #68744.
if (dir.control !== control) return;

control._pendingValue = newValue;
control._pendingChange = true;
control._pendingDirty = true;
Expand All @@ -275,6 +281,9 @@ function setUpViewChangePipeline(control: FormControl, dir: NgControl): void {

function setUpBlurPipeline(control: FormControl, dir: NgControl): void {
dir.valueAccessor!.registerOnTouched(() => {
// See the comment in `setUpViewChangePipeline` on why this check is needed.
if (dir.control !== control) return;

control._pendingTouched = true;

if (control.updateOn === 'blur' && control._pendingChange) updateControl(control, dir);
Expand Down
56 changes: 56 additions & 0 deletions packages/forms/test/reactive_integration_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4912,6 +4912,62 @@ describe('reactive forms integration tests', () => {
expectValidatorsToBeCalled(validatorSpy, asyncValidatorSpy, {ctx: sharedControl, count: 1});
});

it('should not sync the previously bound control when the `formControl` input changes and the value accessor accumulates `registerOnChange` callbacks', () => {
@Component({
selector: 'accumulating-cva',
template: '<input type="text" [formControl]="innerControl" />',
providers: [
{provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => AccumulatingCva), multi: true},
],
standalone: false,
})
class AccumulatingCva implements ControlValueAccessor {
innerControl = new FormControl('');
writeValue(value: any): void {
this.innerControl.setValue(value);
}
// Callbacks are accumulated (instead of replaced) on each `registerOnChange` call,
// mimicking value accessors that subscribe to an inner control's `valueChanges`.
registerOnChange(fn: (value: any) => void): void {
this.innerControl.valueChanges.subscribe(fn);
}
registerOnTouched(fn: () => void): void {}
}

@Component({
template: '<accumulating-cva [formControl]="control"></accumulating-cva>',
standalone: false,
changeDetection: ChangeDetectionStrategy.Eager,
})
class App {
control = new FormControl('A');
}

const fixture = initTest(App, AccumulatingCva);
fixture.detectChanges();

const controlA = fixture.componentInstance.control;
const controlB = new FormControl('B');

fixture.componentInstance.control = controlB;
fixture.changeDetectorRef.markForCheck();
fixture.detectChanges();

// Rebinding the directive writes `controlB`'s value into the view. The stale
// view-to-model callback registered for `controlA` must not pick that value up.
expect(controlA.value).toBe('A');
expect(controlB.value).toBe('B');
expect(controlB.dirty).toBe(false);

// View changes should only be propagated to the currently bound control.
const cva: AccumulatingCva = fixture.debugElement.query(
By.directive(AccumulatingCva),
).componentInstance;
cva.innerControl.setValue('C');
expect(controlA.value).toBe('A');
expect(controlB.value).toBe('C');
});

it('should clean up callbacks when FormControlDirective is destroyed (simple)', () => {
// Scenario:
// ---------
Expand Down
Loading