Skip to content

Commit a1ac9a6

Browse files
JeanMechecrisbeto
authored andcommitted
fix(forms): interop supports CVAs with signals (#64618)
The directive implemnetation might set CVA values during the template evaluation. Since the template is a reactive context we need to untrack when setting the CVA values to prevent writing to signals in a reactive context. fixes #64614 PR Close #64618
1 parent aa389a6 commit a1ac9a6

File tree

2 files changed

+70
-2
lines changed

2 files changed

+70
-2
lines changed

packages/forms/signals/src/api/field_directive.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
InjectionToken,
1515
Injector,
1616
input,
17+
untracked,
1718
ɵCONTROL,
1819
ɵControl,
1920
} from '@angular/core';
@@ -95,8 +96,18 @@ export class Field<T> implements ɵControl<T> {
9596
const controlValueAccessor = this.controlValueAccessor!;
9697
// TODO: https://github.com/orgs/angular/projects/60/views/1?pane=issue&itemId=131711472
9798
// * check if values changed since last update before writing.
98-
controlValueAccessor.writeValue(this.state().value());
99-
controlValueAccessor.setDisabledState?.(this.state().disabled());
99+
100+
// These values remain reactive
101+
const value = this.state().value();
102+
const disabled = this.state().disabled();
103+
104+
// The CVA is accessed in a reactive context (the template executation)
105+
// Since we don't control the implementation of the CVA and it can have underlying signals
106+
// We need to untrack to prevent writing to a signal in a reactive context
107+
untracked(() => {
108+
controlValueAccessor.writeValue(value);
109+
controlValueAccessor.setDisabledState?.(disabled);
110+
});
100111
}
101112

102113
// TODO: https://github.com/orgs/angular/projects/60/views/1?pane=issue&itemId=131861631

packages/forms/signals/test/web/interop.spec.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,63 @@ describe('ControlValueAccessor', () => {
220220
});
221221
expect(fixture.componentInstance.f().value()).toBe('typing');
222222
});
223+
224+
it('should not throw if the ControlValueAccessor implementation uses signals', () => {
225+
@Component({
226+
selector: 'signal-custom-control',
227+
template: `<input [value]="value()" [disabled]="disabled()" />`,
228+
providers: [{provide: NG_VALUE_ACCESSOR, useExisting: CustomControl, multi: true}],
229+
})
230+
class CustomControl implements ControlValueAccessor {
231+
value = signal('');
232+
disabled = signal(false);
233+
234+
private onChangeFn?: (value: string) => void;
235+
private onTouchedFn?: () => void;
236+
237+
writeValue(newValue: string): void {
238+
this.value.set(newValue);
239+
}
240+
241+
registerOnChange(fn: (value: string) => void): void {
242+
this.onChangeFn = fn;
243+
}
244+
245+
registerOnTouched(fn: () => void): void {
246+
this.onTouchedFn = fn;
247+
}
248+
249+
setDisabledState(disabled: boolean): void {
250+
this.disabled.set(disabled);
251+
}
252+
253+
onBlur() {
254+
this.onTouchedFn?.();
255+
}
256+
257+
onInput(newValue: string) {
258+
this.value.set(newValue);
259+
this.onChangeFn?.(newValue);
260+
}
261+
}
262+
263+
@Component({
264+
selector: 'app-root',
265+
imports: [CustomControl, Field],
266+
template: `<signal-custom-control [field]="f" />`,
267+
})
268+
class App {
269+
disabled = signal(false);
270+
readonly f = form(signal('test'), (f) => {
271+
disabled(f, () => this.disabled());
272+
});
273+
}
274+
275+
const fixture = TestBed.createComponent(App);
276+
expect(() => fixture.detectChanges()).not.toThrowError(/NG0600/);
277+
278+
expect(() => fixture.componentInstance.disabled.set(true)).not.toThrowError(/NG0600/);
279+
});
223280
});
224281

225282
function act<T>(fn: () => T): T {

0 commit comments

Comments
 (0)