Skip to content

Commit e053609

Browse files
authored
perf(forms): optimize reactivity by using shallow array equality
Add `shallowArrayEquals` to computed signals returning arrays of errors or reasons in Signal Forms. This prevents unnecessary downstream invalidations when the content of the arrays remains unchanged.
1 parent 4c9afb6 commit e053609

5 files changed

Lines changed: 150 additions & 73 deletions

File tree

packages/forms/signals/src/directive/form_field.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import {InteropNgControl} from '../controls/interop_ng_control';
3939
import {RuntimeErrorCode} from '../errors';
4040
import {SIGNAL_FORMS_CONFIG} from '../field/di';
4141
import type {FieldNode} from '../field/node';
42+
import {shallowArrayEquals} from '../util/array';
4243
import {bindingUpdated, type ControlBindingKey, createBindings} from './bindings';
4344
import {customControlCreate} from './control_custom';
4445
import {cvaControlCreate} from './control_cva';
@@ -182,10 +183,12 @@ export class FormField<T> {
182183
);
183184

184185
/** Errors associated with this form field. */
185-
readonly errors = computed(() =>
186-
this.state()
187-
.errors()
188-
.filter((err) => !err.formField || err.formField === this),
186+
readonly errors = computed(
187+
() =>
188+
this.state()
189+
.errors()
190+
.filter((err) => !err.formField || err.formField === this),
191+
{equal: shallowArrayEquals},
189192
);
190193

191194
/** Whether this `FormField` has been registered as a binding on its associated `FieldState`. */

packages/forms/signals/src/field/state.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import type {FormField} from '../directive/form_field';
1111
import type {Debouncer, DisabledReason} from '../api/types';
1212
import {DEBOUNCER} from './debounce';
1313
import type {FieldNode} from './node';
14+
import {shallowArrayEquals} from '../util/array';
1415
import {shortCircuitTrue} from './util';
1516

1617
/**
@@ -105,10 +106,13 @@ export class FieldNodeState {
105106
* The `field` property of the `DisabledReason` can be used to determine which field ultimately
106107
* caused the disablement.
107108
*/
108-
readonly disabledReasons: Signal<readonly DisabledReason[]> = computed(() => [
109-
...(this.node.structure.parent?.nodeState.disabledReasons() ?? []),
110-
...this.node.logicNode.logic.disabledReasons.compute(this.node.context),
111-
]);
109+
readonly disabledReasons: Signal<readonly DisabledReason[]> = computed(
110+
() => [
111+
...(this.node.structure.parent?.nodeState.disabledReasons() ?? []),
112+
...this.node.logicNode.logic.disabledReasons.compute(this.node.context),
113+
],
114+
{equal: shallowArrayEquals},
115+
);
112116

113117
/**
114118
* Whether this field is considered disabled.

packages/forms/signals/src/field/validation.ts

Lines changed: 85 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import type {ReadonlyFieldTree, TreeValidationResult, ValidationResult} from '..
1212
import {isArray} from '../util/type_guards';
1313
import type {FieldNode} from './node';
1414
import {shortCircuitFalse} from './util';
15+
import {shallowArrayEquals} from '../util/array';
1516

1617
/**
1718
* Helper function taking validation state, and returning own state of the node.
@@ -153,35 +154,41 @@ export class FieldValidationState implements ValidationState {
153154
* The full set of synchronous tree errors visible to this field. This includes ones that are
154155
* targeted at a descendant field rather than at this field.
155156
*/
156-
readonly rawSyncTreeErrors: Signal<ValidationError.WithFieldTree[]> = computed(() => {
157-
if (this.shouldSkipValidation()) {
158-
return [];
159-
}
160-
161-
return [
162-
...this.node.logicNode.logic.syncTreeErrors.compute(this.node.context),
163-
...(this.node.structure.parent?.validationState.rawSyncTreeErrors() ?? []),
164-
];
165-
});
157+
readonly rawSyncTreeErrors: Signal<ValidationError.WithFieldTree[]> = computed(
158+
() => {
159+
if (this.shouldSkipValidation()) {
160+
return [];
161+
}
162+
163+
return [
164+
...this.node.logicNode.logic.syncTreeErrors.compute(this.node.context),
165+
...(this.node.structure.parent?.validationState.rawSyncTreeErrors() ?? []),
166+
];
167+
},
168+
{equal: shallowArrayEquals},
169+
);
166170

167171
/**
168172
* The full set of synchronous errors for this field, including synchronous tree errors and
169173
* submission errors. Submission errors are considered "synchronous" because they are imperatively
170174
* added. From the perspective of the field state they are either there or not, they are never in a
171175
* pending state.
172176
*/
173-
readonly syncErrors: Signal<ValidationError.WithFieldTree[]> = computed(() => {
174-
// Short-circuit running validators if validation doesn't apply to this field.
175-
if (this.shouldSkipValidation()) {
176-
return [];
177-
}
178-
179-
return [
180-
...this.node.logicNode.logic.syncErrors.compute(this.node.context),
181-
...this.syncTreeErrors(),
182-
...normalizeErrors(this.node.submitState.submissionErrors()),
183-
];
184-
});
177+
readonly syncErrors: Signal<ValidationError.WithFieldTree[]> = computed(
178+
() => {
179+
// Short-circuit running validators if validation doesn't apply to this field.
180+
if (this.shouldSkipValidation()) {
181+
return [];
182+
}
183+
184+
return [
185+
...this.node.logicNode.logic.syncErrors.compute(this.node.context),
186+
...this.syncTreeErrors(),
187+
...normalizeErrors(this.node.submitState.submissionErrors()),
188+
];
189+
},
190+
{equal: shallowArrayEquals},
191+
);
185192

186193
/**
187194
* Whether the field is considered valid according solely to its synchronous validators.
@@ -204,67 +211,81 @@ export class FieldValidationState implements ValidationState {
204211
* The synchronous tree errors visible to this field that are specifically targeted at this field
205212
* rather than a descendant.
206213
*/
207-
readonly syncTreeErrors: Signal<ValidationError.WithFieldTree[]> = computed(() =>
208-
this.rawSyncTreeErrors().filter((err) => err.fieldTree === this.node.fieldTree),
214+
readonly syncTreeErrors: Signal<ValidationError.WithFieldTree[]> = computed(
215+
() => this.rawSyncTreeErrors().filter((err) => err.fieldTree === this.node.fieldTree),
216+
{equal: shallowArrayEquals},
209217
);
210218

211219
/**
212220
* The full set of asynchronous tree errors visible to this field. This includes ones that are
213221
* targeted at a descendant field rather than at this field, as well as sentinel 'pending' values
214222
* indicating that the validator is still running and an error could still occur.
215223
*/
216-
readonly rawAsyncErrors: Signal<(ValidationError.WithFieldTree | 'pending')[]> = computed(() => {
217-
// Short-circuit running validators if validation doesn't apply to this field.
218-
if (this.shouldSkipValidation()) {
219-
return [];
220-
}
221-
222-
return [
223-
// TODO: add field in `validateAsync` and remove this map
224-
...this.node.logicNode.logic.asyncErrors.compute(this.node.context),
225-
// TODO: does it make sense to filter this to errors in this subtree?
226-
...(this.node.structure.parent?.validationState.rawAsyncErrors() ?? []),
227-
];
228-
});
224+
readonly rawAsyncErrors: Signal<(ValidationError.WithFieldTree | 'pending')[]> = computed(
225+
() => {
226+
// Short-circuit running validators if validation doesn't apply to this field.
227+
if (this.shouldSkipValidation()) {
228+
return [];
229+
}
230+
231+
return [
232+
// TODO: add field in `validateAsync` and remove this map
233+
...this.node.logicNode.logic.asyncErrors.compute(this.node.context),
234+
// TODO: does it make sense to filter this to errors in this subtree?
235+
...(this.node.structure.parent?.validationState.rawAsyncErrors() ?? []),
236+
];
237+
},
238+
{equal: shallowArrayEquals},
239+
);
229240

230241
/**
231242
* The asynchronous tree errors visible to this field that are specifically targeted at this field
232243
* rather than a descendant. This also includes all 'pending' sentinel values, since those could
233244
* theoretically result in errors for this field.
234245
*/
235-
readonly asyncErrors: Signal<(ValidationError.WithFieldTree | 'pending')[]> = computed(() => {
236-
if (this.shouldSkipValidation()) {
237-
return [];
238-
}
239-
return this.rawAsyncErrors().filter(
240-
(err) => err === 'pending' || err.fieldTree === this.node.fieldTree,
241-
);
242-
});
246+
readonly asyncErrors: Signal<(ValidationError.WithFieldTree | 'pending')[]> = computed(
247+
() => {
248+
if (this.shouldSkipValidation()) {
249+
return [];
250+
}
251+
return this.rawAsyncErrors().filter(
252+
(err) => err === 'pending' || err.fieldTree === this.node.fieldTree,
253+
);
254+
},
255+
{equal: shallowArrayEquals},
256+
);
243257

244-
readonly parseErrors: Signal<ValidationError.WithFormField[]> = computed(() =>
245-
this.node.formFieldBindings().flatMap((field) => field.parseErrors()),
258+
readonly parseErrors: Signal<ValidationError.WithFormField[]> = computed(
259+
() => this.node.formFieldBindings().flatMap((field) => field.parseErrors()),
260+
{equal: shallowArrayEquals},
246261
);
247262

248263
/**
249264
* The combined set of all errors that currently apply to this field.
250265
*/
251-
readonly errors = computed(() => [
252-
...this.parseErrors(),
253-
...this.syncErrors(),
254-
...this.asyncErrors().filter((err) => err !== 'pending'),
255-
]);
256-
257-
readonly errorSummary = computed(() => {
258-
const errors = this.node.structure.reduceChildren(this.errors(), (child, result) => [
259-
...result,
260-
...child.errorSummary(),
261-
]);
262-
// Sort by DOM order on client-side only.
263-
if (typeof ngServerMode === 'undefined' || !ngServerMode) {
264-
untracked(() => errors.sort(compareErrorPosition));
265-
}
266-
return errors;
267-
});
266+
readonly errors = computed(
267+
() => [
268+
...this.parseErrors(),
269+
...this.syncErrors(),
270+
...this.asyncErrors().filter((err) => err !== 'pending'),
271+
],
272+
{equal: shallowArrayEquals},
273+
);
274+
275+
readonly errorSummary = computed(
276+
() => {
277+
const errors = this.node.structure.reduceChildren(this.errors(), (child, result) => [
278+
...result,
279+
...child.errorSummary(),
280+
]);
281+
// Sort by DOM order on client-side only.
282+
if (typeof ngServerMode === 'undefined' || !ngServerMode) {
283+
untracked(() => errors.sort(compareErrorPosition));
284+
}
285+
return errors;
286+
},
287+
{equal: shallowArrayEquals},
288+
);
268289

269290
/**
270291
* Whether this field has any asynchronous validators still pending.
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
/**
10+
* Checks shallow equality of two arrays.
11+
* Returns true if both are arrays and have the same elements in the same order.
12+
*/
13+
export function shallowArrayEquals<T>(
14+
a: readonly T[] | undefined,
15+
b: readonly T[] | undefined,
16+
): boolean {
17+
if (a === b) return true;
18+
if (!a || !b) return false;
19+
if (a.length !== b.length) return false;
20+
for (let i = 0; i < a.length; i++) {
21+
if (!Object.is(a[i], b[i])) return false;
22+
}
23+
return true;
24+
}

packages/forms/signals/test/node/validation_status.spec.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9-
import {ApplicationRef, Injector, Resource, resource, signal} from '@angular/core';
9+
import {ApplicationRef, computed, Injector, Resource, resource, signal} from '@angular/core';
1010
import {TestBed} from '@angular/core/testing';
1111
import {
1212
form,
@@ -91,6 +91,31 @@ describe('validation status', () => {
9191
expect(f.child().valid()).toBe(true);
9292
expect(f.child().invalid()).toBe(false);
9393
});
94+
95+
it('should not notify dependents if errors are shallowly equal', () => {
96+
let computeCount = 0;
97+
const val = signal('VALID');
98+
const f = form(
99+
val,
100+
(p) => {
101+
validate(p, ({value}) => (value() === 'INVALID' ? [{kind: 'custom'}] : []));
102+
},
103+
{injector},
104+
);
105+
106+
const errorWatcher = computed(() => {
107+
computeCount++;
108+
return f().errors();
109+
});
110+
111+
errorWatcher();
112+
expect(computeCount).toBe(1);
113+
114+
val.set('STILL_VALID');
115+
116+
errorWatcher();
117+
expect(computeCount).toBe(1);
118+
});
94119
});
95120

96121
describe('tree validator', () => {

0 commit comments

Comments
 (0)