Skip to content
Merged
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
14 changes: 8 additions & 6 deletions packages/forms/src/model/form_array.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
* found in the LICENSE file at https://angular.dev/license
*/

import {ɵWritable as Writable} from '@angular/core';
import {untracked, ɵWritable as Writable} from '@angular/core';

import {AsyncValidatorFn, ValidatorFn} from '../directives/validators';

Expand Down Expand Up @@ -321,12 +321,14 @@ export class FormArray<TControl extends AbstractControl<any> = any> extends Abst
emitEvent?: boolean;
} = {},
): void {
Comment thread
JeanMeche marked this conversation as resolved.
assertAllValuesPresent(this, false, value);
value.forEach((newValue: any, index: number) => {
assertControlPresent(this, false, index);
this.at(index).setValue(newValue, {onlySelf: true, emitEvent: options.emitEvent});
untracked(() => {
assertAllValuesPresent(this, false, value);
value.forEach((newValue: any, index: number) => {
assertControlPresent(this, false, index);
this.at(index).setValue(newValue, {onlySelf: true, emitEvent: options.emitEvent});
});
this.updateValueAndValidity(options);
});
this.updateValueAndValidity(options);
}

/**
Expand Down
18 changes: 10 additions & 8 deletions packages/forms/src/model/form_control.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
* found in the LICENSE file at https://angular.dev/license
*/

import {ɵWritable as Writable} from '@angular/core';
import {untracked, ɵWritable as Writable} from '@angular/core';

import {AsyncValidatorFn, ValidatorFn} from '../directives/validators';
import {removeListItem} from '../util';
Expand Down Expand Up @@ -505,13 +505,15 @@ export const FormControl: ɵFormControlCtor = class FormControl<TValue = any>
emitViewToModelChange?: boolean;
} = {},
): void {
(this as Writable<this>).value = this._pendingValue = value;
if (this._onChange.length && options.emitModelToViewChange !== false) {
this._onChange.forEach((changeFn) =>
changeFn(this.value, options.emitViewToModelChange !== false),
);
}
this.updateValueAndValidity(options);
untracked(() => {
(this as Writable<this>).value = this._pendingValue = value;
if (this._onChange.length && options.emitModelToViewChange !== false) {
this._onChange.forEach((changeFn) =>
changeFn(this.value, options.emitViewToModelChange !== false),
);
}
this.updateValueAndValidity(options);
});
}

override patchValue(
Expand Down
18 changes: 10 additions & 8 deletions packages/forms/src/model/form_group.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
* found in the LICENSE file at https://angular.dev/license
*/

import {ɵWritable as Writable} from '@angular/core';
import {untracked, ɵWritable as Writable} from '@angular/core';

import {AsyncValidatorFn, ValidatorFn} from '../directives/validators';

Expand Down Expand Up @@ -430,15 +430,17 @@ export class FormGroup<
emitEvent?: boolean;
} = {},
): void {
assertAllValuesPresent(this, true, value);
(Object.keys(value) as Array<keyof TControl>).forEach((name) => {
assertControlPresent(this, true, name as any);
(this.controls as any)[name].setValue((value as any)[name], {
onlySelf: true,
emitEvent: options.emitEvent,
untracked(() => {
assertAllValuesPresent(this, true, value);
(Object.keys(value) as Array<keyof TControl>).forEach((name) => {
assertControlPresent(this, true, name as any);
(this.controls as any)[name].setValue((value as any)[name], {
onlySelf: true,
emitEvent: options.emitEvent,
});
});
this.updateValueAndValidity(options);
});
this.updateValueAndValidity(options);
}

/**
Expand Down
136 changes: 134 additions & 2 deletions packages/forms/test/form_array_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,23 @@
* found in the LICENSE file at https://angular.dev/license
*/

import {Component, Directive, effect, forwardRef, signal} from '@angular/core';
import {TestBed} from '@angular/core/testing';
import {of} from 'rxjs';
import {
AbstractControl,
ControlValueAccessor,
FormArray,
FormControl,
FormGroup,
NG_VALUE_ACCESSOR,
ReactiveFormsModule,
ValidationErrors,
ValidatorFn,
} from '../index';
import {Validators} from '../src/validators';
import {of} from 'rxjs';

import {useAutoTick, timeout} from '@angular/private/testing';
import {timeout, useAutoTick} from '@angular/private/testing';
import {asyncValidator} from './util';

(function () {
Expand Down Expand Up @@ -1626,5 +1631,132 @@ import {asyncValidator} from './util';
});
});
});

describe('FormArray.setValue is untracked', () => {
@Directive({
selector: '[testCva]',
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => TestCvaDirective),
multi: true,
},
],
})
class TestCvaDirective implements ControlValueAccessor {
// This is the “dangerous” signal read that must NOT get tracked by the caller of setValue().
static cvaSignal = signal(0);

writeValue(value: unknown): void {
// If setValue() is not untracked, the *caller* effect/computed may accidentally track this read.
TestCvaDirective.cvaSignal();
}

registerOnChange(_: (value: unknown) => void): void {}
registerOnTouched(_: () => void): void {}
setDisabledState(_: boolean): void {}
}

@Component({
imports: [ReactiveFormsModule, TestCvaDirective],
template: `<input testCva [formControl]="control.at(0)" />`,
})
class HostComponent {
control = new FormArray([new FormControl('')]);
}

it('should NOT track signals read inside CVA.writeValue when setValue is called inside an effect', async () => {
const fixture = TestBed.createComponent(HostComponent);
fixture.detectChanges(); // wires up FormControlDirective + CVA

const driver = signal('A');
let runs = 0;

// Create the effect inside the Angular injection context.
TestBed.runInInjectionContext(() => {
effect(() => {
runs++;

// Only dependency we *want* is `driver()`.
fixture.componentInstance.control.setValue([driver()]);
});
});

await fixture.whenStable();
expect(runs).toBe(1);

// Changing the CVA signal should NOT re-run the effect.
TestCvaDirective.cvaSignal.set(1);
await fixture.whenStable();
expect(runs).toBe(1);

// Changing the driver signal SHOULD re-run the effect.
driver.set('B');
await fixture.whenStable();
expect(runs).toBe(2);
});

it('should NOT track signals read inside CVA.writeValue when patchValue is called inside an effect', async () => {
const fixture = TestBed.createComponent(HostComponent);
fixture.detectChanges(); // wires up FormControlDirective + CVA

const driver = signal('A');
let runs = 0;

// Create the effect inside the Angular injection context.
TestBed.runInInjectionContext(() => {
effect(() => {
runs++;

// Only dependency we *want* is `driver()`.
fixture.componentInstance.control.patchValue([driver()]);
});
});

await fixture.whenStable();
expect(runs).toBe(1);

// Changing the CVA signal should NOT re-run the effect.
TestCvaDirective.cvaSignal.set(1);
await fixture.whenStable();
expect(runs).toBe(1);

// Changing the driver signal SHOULD re-run the effect.
driver.set('B');
await fixture.whenStable();
expect(runs).toBe(2);
});

it('should NOT track signals read inside CVA.writeValue when reset is called inside an effect', async () => {
const fixture = TestBed.createComponent(HostComponent);
fixture.detectChanges(); // wires up FormControlDirective + CVA

const driver = signal('A');
let runs = 0;

// Create the effect inside the Angular injection context.
TestBed.runInInjectionContext(() => {
effect(() => {
runs++;

// Only dependency we *want* is `driver()`.
fixture.componentInstance.control.reset([driver()]);
});
});

await fixture.whenStable();
expect(runs).toBe(1);

// Changing the CVA signal should NOT re-run the effect.
TestCvaDirective.cvaSignal.set(1);
await fixture.whenStable();
expect(runs).toBe(1);

// Changing the driver signal SHOULD re-run the effect.
driver.set('B');
await fixture.whenStable();
expect(runs).toBe(2);
});
});
});
})();
Loading