From 1a6bef43c5601040321dadb340c88ec55b9c543e Mon Sep 17 00:00:00 2001 From: hawkgs Date: Tue, 12 May 2026 16:28:33 +0300 Subject: [PATCH] refactor(forms): add a debug name to form and FormField Add a debug name to `form` and `FormField` that can be used for the clustering of all internal signal nodes in Angular DevTools. Note: Due to limitations related to the reactive nature of `FormField`, each form field gets a default index-based debug name. --- goldens/public-api/forms/signals/index.api.md | 3 +- .../compat/src/compat_field_adapter.ts | 8 +- .../signals/compat/src/compat_field_node.ts | 9 +- .../signals/compat/src/compat_node_state.ts | 10 +- .../signals/compat/src/compat_structure.ts | 19 +- .../compat/src/compat_validation_state.ts | 63 +++- .../signal_form_control.ts | 32 +- packages/forms/signals/src/api/structure.ts | 8 +- .../signals/src/api/transformed_value.ts | 9 +- .../signals/src/directive/control_cva.ts | 22 +- .../signals/src/directive/control_native.ts | 2 + .../forms/signals/src/directive/form_field.ts | 55 ++- packages/forms/signals/src/field/context.ts | 113 +++--- .../forms/signals/src/field/field_adapter.ts | 3 + packages/forms/signals/src/field/manager.ts | 9 +- packages/forms/signals/src/field/metadata.ts | 11 +- packages/forms/signals/src/field/node.ts | 55 ++- packages/forms/signals/src/field/state.ts | 252 +++++++------ packages/forms/signals/src/field/structure.ts | 151 +++++--- packages/forms/signals/src/field/submit.ts | 30 +- .../forms/signals/src/field/validation.ts | 336 +++++++++++------- packages/forms/signals/src/util/debug.ts | 31 ++ .../forms/signals/src/util/deep_signal.ts | 8 +- packages/forms/signals/src/util/parser.ts | 3 + 24 files changed, 808 insertions(+), 434 deletions(-) create mode 100644 packages/forms/signals/src/util/debug.ts diff --git a/goldens/public-api/forms/signals/index.api.md b/goldens/public-api/forms/signals/index.api.md index 55b6feb3b273..ef449768e9d8 100644 --- a/goldens/public-api/forms/signals/index.api.md +++ b/goldens/public-api/forms/signals/index.api.md @@ -205,6 +205,7 @@ export interface FormFieldBindingOptions { // @public export interface FormOptions { + debugName?: string; injector?: Injector; name?: string; submission?: FormSubmitOptions; @@ -717,7 +718,7 @@ export function submit(form: FieldTree, options?: NoInfer(form: FieldTree, action: NoInfer['action']>): Promise; // @public -export function transformedValue(value: ModelSignal, options: TransformedValueOptions): TransformedValueSignal; +export function transformedValue(value: ModelSignal, options: TransformedValueOptions, debugFormFieldName?: string): TransformedValueSignal; // @public export interface TransformedValueOptions { diff --git a/packages/forms/signals/compat/src/compat_field_adapter.ts b/packages/forms/signals/compat/src/compat_field_adapter.ts index c88f4497fac4..b370ab79b5bb 100644 --- a/packages/forms/signals/compat/src/compat_field_adapter.ts +++ b/packages/forms/signals/compat/src/compat_field_adapter.ts @@ -23,6 +23,7 @@ import {CompatFieldNode} from './compat_field_node'; import {CompatNodeState} from './compat_node_state'; import {CompatChildFieldNodeOptions, CompatStructure} from './compat_structure'; import {CompatValidationState} from './compat_validation_state'; +import {formDebugObj} from '../../src/util/debug'; /** * This is a tree-shakable Field adapter that can create a compat node @@ -120,9 +121,10 @@ export function createCompatNode(options: FieldNodeOptions) { const control = ( options.kind === 'root' ? options.value - : computed(() => { - return options.parent.value()[options.initialKeyInParent]; - }) + : computed( + () => options.parent.value()[options.initialKeyInParent], + ngDevMode ? formDebugObj(options.debugName, 'control') : undefined, + ) ) as Signal; return new CompatFieldNode({ diff --git a/packages/forms/signals/compat/src/compat_field_node.ts b/packages/forms/signals/compat/src/compat_field_node.ts index d11aaa0003fd..3a52d75622cd 100644 --- a/packages/forms/signals/compat/src/compat_field_node.ts +++ b/packages/forms/signals/compat/src/compat_field_node.ts @@ -14,6 +14,7 @@ import {map, takeUntil} from 'rxjs/operators'; import {FieldNode} from '../../src/field/node'; import {getInjectorFromOptions} from '../../src/field/util'; import type {CompatFieldNodeOptions} from './compat_structure'; +import {formDebugObj} from '../../src/util/debug'; /** * Field node with additional control property. @@ -72,11 +73,15 @@ export function extractControlPropToSignal( return runInInjectionContext(injector, () => makeSignal(control, createDestroySubject())); }); }, + ...(ngDevMode ? formDebugObj(options.debugName, 'signalOfControlSignal') : undefined), }); // We have to have computed, because we need to react to both: // linked signal changes as well as the inner signal changes. - return computed(() => signalOfControlSignal()()); + return computed( + () => signalOfControlSignal()(), + ngDevMode ? formDebugObj(options.debugName, 'controlPropToSignal') : undefined, + ); } /** @@ -99,6 +104,7 @@ export const getControlStatusSignal = ( ), { initialValue: getValue(c), + ...(ngDevMode ? formDebugObj(options.debugName, 'controlStatus') : undefined), }, ), ); @@ -126,6 +132,7 @@ export const getControlEventsSignal = ( ), { initialValue: getValue(c), + ...(ngDevMode ? formDebugObj(options.debugName, 'controlEvents') : undefined), }, ), ); diff --git a/packages/forms/signals/compat/src/compat_node_state.ts b/packages/forms/signals/compat/src/compat_node_state.ts index fcf2cebe6447..4bb57da03cdc 100644 --- a/packages/forms/signals/compat/src/compat_node_state.ts +++ b/packages/forms/signals/compat/src/compat_node_state.ts @@ -11,6 +11,7 @@ import {AbstractControl} from '@angular/forms'; import {FieldNodeState} from '../../src/field/state'; import {CompatFieldNode, getControlEventsSignal, getControlStatusSignal} from './compat_field_node'; import {CompatFieldNodeOptions} from './compat_structure'; +import {formDebugObj} from '../../src/util/debug'; /** * A FieldNodeState class wrapping a FormControl and proxying it's state. @@ -31,9 +32,12 @@ export class CompatNodeState extends FieldNodeState { this.dirty = getControlEventsSignal(options, (c) => c.dirty); const controlDisabled = getControlStatusSignal(options, (c) => c.disabled); - this.disabled = computed(() => { - return controlDisabled() || this.disabledReasons().length > 0; - }); + this.disabled = computed( + () => { + return controlDisabled() || this.disabledReasons().length > 0; + }, + ngDevMode ? formDebugObj(options.debugName, 'disabled') : undefined, + ); } override markAsDirty() { diff --git a/packages/forms/signals/compat/src/compat_structure.ts b/packages/forms/signals/compat/src/compat_structure.ts index 6f706924d8eb..5987bf558417 100644 --- a/packages/forms/signals/compat/src/compat_structure.ts +++ b/packages/forms/signals/compat/src/compat_structure.ts @@ -18,6 +18,7 @@ import {FormFieldManager} from '../../src/field/manager'; import {FieldNode, ParentFieldNode} from '../../src/field/node'; import { ChildFieldNodeOptions, + ChildrenData, FieldNodeOptions, FieldNodeStructure, RootFieldNodeOptions, @@ -27,6 +28,7 @@ import {toSignal} from '@angular/core/rxjs-interop'; import {AbstractControl} from '@angular/forms'; import {map, takeUntil} from 'rxjs/operators'; import {extractControlPropToSignal} from './compat_field_node'; +import {formDebugObj} from '../../src/util/debug'; /** * Child Field Node options also exposing control property. @@ -86,6 +88,7 @@ function getControlValueSignal(options: CompatFieldNodeOptions) { ), { initialValue: control.getRawValue(), + ...(ngDevMode ? formDebugObj(options.debugName, 'value') : undefined), }, ); }) as WritableSignal; @@ -111,8 +114,8 @@ export class CompatStructure extends FieldNodeStructure { override keyInParent: Signal; override root: FieldNode; override pathKeys: Signal; - override readonly children = signal([]); - override readonly childrenMap = computed(() => undefined); + override readonly children: WritableSignal; + override readonly childrenMap: Signal; override readonly parent: ParentFieldNode | undefined; override readonly fieldManager: FormFieldManager; override readonly isOrphaned: Signal; @@ -140,8 +143,16 @@ export class CompatStructure extends FieldNodeStructure { this.keyInParent = signals.keyInParent; this.isOrphaned = signals.isOrphaned; - this.pathKeys = computed(() => - this.parent ? [...this.parent.structure.pathKeys(), this.keyInParent()] : [], + this.pathKeys = computed( + () => (this.parent ? [...this.parent.structure.pathKeys(), this.keyInParent()] : []), + ngDevMode ? formDebugObj(this.node.debugName, 'pathKeys') : undefined, + ); + + this.children = signal([], ngDevMode ? formDebugObj(node.debugName, 'children') : undefined); + + this.childrenMap = computed( + () => undefined, + ngDevMode ? formDebugObj(node.debugName, 'childrenMap') : undefined, ); } diff --git a/packages/forms/signals/compat/src/compat_validation_state.ts b/packages/forms/signals/compat/src/compat_validation_state.ts index 09a5360f8845..bd807fadee97 100644 --- a/packages/forms/signals/compat/src/compat_validation_state.ts +++ b/packages/forms/signals/compat/src/compat_validation_state.ts @@ -16,6 +16,7 @@ import { } from '../../src/compat/validation_errors'; import {CompatFieldNode, getControlStatusSignal} from './compat_field_node'; import {CompatFieldNodeOptions} from './compat_structure'; +import {formDebugObj} from '../../src/util/debug'; // Readonly signal containing an empty array, used for optimization. const EMPTY_ARRAY_SIGNAL = computed(() => []); @@ -33,7 +34,25 @@ export class CompatValidationState implements ValidationState { readonly invalid: Signal; readonly valid: Signal; - readonly parseErrors: Signal = computed(() => []); + readonly parseErrors: Signal; + + // Compat fields can't have validation rules applied to them; however, there are other + // features that depend on this property, such as `markAsTouched()`. + readonly shouldSkipValidation: Signal; + + /** + * Computes status based on whether the field is valid/invalid/pending. + */ + readonly status: Signal<'valid' | 'invalid' | 'unknown'>; + + readonly rawSyncTreeErrors: Signal; + readonly syncErrors: Signal; + readonly rawAsyncErrors: Signal<(ValidationError.WithFieldTree | 'pending')[]>; + + asyncErrors: Signal<(ValidationError.WithFieldTree | 'pending')[]>; + errorSummary: Signal; + + private readonly emptyArraySignal: Signal; constructor( private readonly node: CompatFieldNode, @@ -50,26 +69,32 @@ export class CompatValidationState implements ValidationState { this.invalid = getControlStatusSignal(options, (c) => { return c.invalid; }); - } - asyncErrors: Signal<(ValidationError.WithFieldTree | 'pending')[]> = EMPTY_ARRAY_SIGNAL; - errorSummary: Signal = EMPTY_ARRAY_SIGNAL; + this.parseErrors = computed( + () => [], + ngDevMode ? formDebugObj(node.debugName, 'parseErrors') : undefined, + ); - // Those are irrelevant for compat mode, as it has no children - rawSyncTreeErrors = EMPTY_ARRAY_SIGNAL; - syncErrors = EMPTY_ARRAY_SIGNAL; - rawAsyncErrors = EMPTY_ARRAY_SIGNAL; + this.shouldSkipValidation = computed( + () => this.node.hidden() || this.node.disabled() || this.node.readonly(), + ngDevMode ? formDebugObj(node.debugName, 'shouldSkipValidation') : undefined, + ); - // Compat fields can't have validation rules applied to them; however, there are other - // features that depend on this property, such as `markAsTouched()`. - readonly shouldSkipValidation = computed( - () => this.node.hidden() || this.node.disabled() || this.node.readonly(), - ); + this.status = computed( + () => calculateValidationSelfStatus(this), + ngDevMode ? formDebugObj(node.debugName, 'status') : undefined, + ); - /** - * Computes status based on whether the field is valid/invalid/pending. - */ - readonly status: Signal<'valid' | 'invalid' | 'unknown'> = computed(() => { - return calculateValidationSelfStatus(this); - }); + this.emptyArraySignal = ngDevMode + ? computed(() => [], formDebugObj(node.debugName, 'EMPTY_ARRAY_SIGNAL')) + : EMPTY_ARRAY_SIGNAL; + + this.asyncErrors = this.emptyArraySignal; + this.errorSummary = this.emptyArraySignal; + + // Those are irrelevant for compat mode, as it has no children + this.rawSyncTreeErrors = this.emptyArraySignal; + this.syncErrors = this.emptyArraySignal; + this.rawAsyncErrors = this.emptyArraySignal; + } } diff --git a/packages/forms/signals/compat/src/signal_form_control/signal_form_control.ts b/packages/forms/signals/compat/src/signal_form_control/signal_form_control.ts index 4cb86b518e9f..4df7fe3f699f 100644 --- a/packages/forms/signals/compat/src/signal_form_control/signal_form_control.ts +++ b/packages/forms/signals/compat/src/signal_form_control/signal_form_control.ts @@ -37,6 +37,7 @@ import {RuntimeErrorCode} from '../../../src/errors'; import {FieldNode} from '../../../src/field/node'; import {normalizeFormArgs} from '../../../src/util/normalize_form_args'; import {compatForm} from '../api/compat_form'; +import {formDebugObj} from '../../../src/util/debug'; /** Options used to update the control value. */ export type ValueUpdateOptions = { @@ -96,7 +97,11 @@ export class SignalFormControl extends AbstractControl { constructor(value: T, schemaOrOptions?: SchemaFn | FormOptions, options?: FormOptions) { super(null, null); - const [model, schema, opts] = normalizeFormArgs([signal(value), schemaOrOptions, options]); + const [model, schema, opts] = normalizeFormArgs([ + signal(value, ngDevMode ? formDebugObj(options?.debugName, 'value') : undefined), + schemaOrOptions, + options, + ]); this.sourceValue = model; this.initialValue = value; const injector = opts?.injector ?? inject(Injector); @@ -122,7 +127,10 @@ export class SignalFormControl extends AbstractControl { this.emitControlEvent(new ValueChangeEvent(value, this)); }); }, - {injector}, + { + injector, + ...(ngDevMode ? formDebugObj(options?.debugName, 'valueChanges') : undefined), + }, ); // Status changes effect @@ -134,7 +142,10 @@ export class SignalFormControl extends AbstractControl { }); this.emitControlEvent(new StatusChangeEvent(status, this)); }, - {injector}, + { + injector, + ...(ngDevMode ? formDebugObj(options?.debugName, 'statusChanges') : undefined), + }, ); // Disabled changes effect @@ -147,7 +158,10 @@ export class SignalFormControl extends AbstractControl { } }); }, - {injector}, + { + injector, + ...(ngDevMode ? formDebugObj(options?.debugName, 'disabledChanges') : undefined), + }, ); // Touched changes effect @@ -165,7 +179,10 @@ export class SignalFormControl extends AbstractControl { parent.markAsTouched(); } }, - {injector}, + { + injector, + ...(ngDevMode ? formDebugObj(options?.debugName, 'touchedChanges') : undefined), + }, ); // Dirty changes effect @@ -183,7 +200,10 @@ export class SignalFormControl extends AbstractControl { parent.markAsPristine(); } }, - {injector}, + { + injector, + ...(ngDevMode ? formDebugObj(options?.debugName, 'dirtyChanges') : undefined), + }, ); } diff --git a/packages/forms/signals/src/api/structure.ts b/packages/forms/signals/src/api/structure.ts index 7793de998849..ad868e01810e 100644 --- a/packages/forms/signals/src/api/structure.ts +++ b/packages/forms/signals/src/api/structure.ts @@ -15,7 +15,7 @@ import { WritableSignal, } from '@angular/core'; import {RuntimeErrorCode} from '../errors'; -import {BasicFieldAdapter, FieldAdapter} from '../field/field_adapter'; +import {BasicFieldAdapter} from '../field/field_adapter'; import {FormFieldManager} from '../field/manager'; import {FieldNode} from '../field/node'; import {addDefaultField} from '../field/validation'; @@ -55,6 +55,8 @@ export interface FormOptions { name?: string; /** Options that define how to handle form submission. */ submission?: FormSubmitOptions; + /** A debug name for the form. Used in Angular DevTools to identify the node. */ + debugName?: string; } /** @@ -194,8 +196,8 @@ export function form(...args: any[]): FieldTree { options?.submission as FormSubmitOptions | undefined, ); const adapter = options?.adapter ?? new BasicFieldAdapter(); - const fieldRoot = FieldNode.newRoot(fieldManager, model, pathNode, adapter); - fieldManager.createFieldManagementEffect(fieldRoot.structure); + const fieldRoot = FieldNode.newRoot(fieldManager, model, pathNode, adapter, options?.debugName); + fieldManager.createFieldManagementEffect(fieldRoot.structure, options?.debugName); return fieldRoot.fieldTree as FieldTree; } diff --git a/packages/forms/signals/src/api/transformed_value.ts b/packages/forms/signals/src/api/transformed_value.ts index daa5fd9251c0..7f474ed666f7 100644 --- a/packages/forms/signals/src/api/transformed_value.ts +++ b/packages/forms/signals/src/api/transformed_value.ts @@ -17,6 +17,7 @@ import {createParser} from '../util/parser'; import type {ValidationError} from './rules'; import type {OneOrMany} from './types'; import {ɵFORM_CONTROL_INTEGRATION as FORM_CONTROL_INTEGRATION} from '@angular/forms'; +import {formFieldDebugObj} from '../util/debug'; /** * Result of parsing a raw value into a model value. @@ -114,12 +115,16 @@ export interface TransformedValueSignal extends WritableSignal { export function transformedValue( value: ModelSignal, options: TransformedValueOptions, + debugFormFieldName?: string, ): TransformedValueSignal { const {parse, format} = options; - const parser = createParser(value, value.set, parse); + const parser = createParser(value, value.set, parse, debugFormFieldName); // Create the result signal with overridden set/update and a `parseErrors` property. - const rawValue = linkedSignal(() => format(value())); + const rawValue = linkedSignal( + () => format(value()), + ngDevMode ? formFieldDebugObj(debugFormFieldName, 'rawValue') : undefined, + ); const result = rawValue as WritableSignal & { parseErrors: Signal; }; diff --git a/packages/forms/signals/src/directive/control_cva.ts b/packages/forms/signals/src/directive/control_cva.ts index 38f95eb4632f..b2e7147f3411 100644 --- a/packages/forms/signals/src/directive/control_cva.ts +++ b/packages/forms/signals/src/directive/control_cva.ts @@ -26,6 +26,7 @@ import { } from './bindings'; import {setNativeDomProperty} from './native'; import type {FormField} from './form_field'; +import {formFieldDebugObj} from '../util/debug'; function isValidatorObject(v: Function | Validator): v is Validator { return typeof v === 'object' && v !== null; @@ -34,6 +35,7 @@ function isValidatorObject(v: Function | Validator): v is Validator { export function cvaControlCreate( host: ControlDirectiveHost, parent: FormField, + debugFormFieldName?: string, ): () => void { const bindings = createBindings(); @@ -52,7 +54,10 @@ export function cvaControlCreate( for (const v of legacyValidators) { if (isValidatorObject(v) && v.registerOnValidatorChange) { - version ??= signal(0); + version ??= signal( + 0, + ngDevMode ? formFieldDebugObj(debugFormFieldName, 'version') : undefined, + ); v.registerOnValidatorChange(() => { version!.update((n) => n + 1); }); @@ -64,12 +69,15 @@ export function cvaControlCreate( ); const mergedValidator = Validators.compose(validatorFns); - const parseErrors = computed(() => { - // Read the `version` signal to re-run the validator when legacy validators trigger their change callbacks. - version?.(); - const errors = mergedValidator ? mergedValidator(parent.interopNgControl.control) : null; - return reactiveErrorsToSignalErrors(errors, parent.interopNgControl.control); - }); + const parseErrors = computed( + () => { + // Read the `version` signal to re-run the validator when legacy validators trigger their change callbacks. + version?.(); + const errors = mergedValidator ? mergedValidator(parent.interopNgControl.control) : null; + return reactiveErrorsToSignalErrors(errors, parent.interopNgControl.control); + }, + ngDevMode ? formFieldDebugObj(debugFormFieldName, 'parseErrors') : undefined, + ); // We must cast here because `CompatValidationError` claims to have `fieldTree` statically (to // satisfy `ValidationState` elsewhere), but at construction it is created without it and acts as // `WithoutFieldTree` initially. diff --git a/packages/forms/signals/src/directive/control_native.ts b/packages/forms/signals/src/directive/control_native.ts index 6c12e3ce76bf..1c73182675f0 100644 --- a/packages/forms/signals/src/directive/control_native.ts +++ b/packages/forms/signals/src/directive/control_native.ts @@ -38,6 +38,7 @@ export function nativeControlCreate( Signal | undefined >, validityMonitor: InputValidityMonitor, + debugFormFieldName?: string, ): () => void { let updateMode = false; const input = parent.nativeFormElement; @@ -51,6 +52,7 @@ export function nativeControlCreate( // Our parse function doesn't care about the raw value that gets passed in, // It just reads the newly parsed value directly off the input element. (_rawValue: unknown) => getNativeControlValue(input, parent.state().value, validityMonitor), + debugFormFieldName, ); parseErrorsSource.set(parser.errors); diff --git a/packages/forms/signals/src/directive/form_field.ts b/packages/forms/signals/src/directive/form_field.ts index 3f38bec61564..f383633c6453 100644 --- a/packages/forms/signals/src/directive/form_field.ts +++ b/packages/forms/signals/src/directive/form_field.ts @@ -38,7 +38,6 @@ import {InteropNgControl} from '../controls/interop_ng_control'; import {RuntimeErrorCode} from '../errors'; import {SIGNAL_FORMS_CONFIG} from '../field/di'; import type {FieldNode} from '../field/node'; -import type {FormUiControl} from '../api/control'; import {shallowArrayEquals} from '../util/array'; import {bindingUpdated, type ControlBindingKey, createBindings} from './bindings'; import {customControlCreate} from './control_custom'; @@ -51,6 +50,7 @@ import { type NativeFormControl, } from './native'; import {InputValidityMonitor} from './input_validity_monitor'; +import {formFieldDebugObj} from '../util/debug'; export const ɵNgFieldDirective: unique symbol = Symbol(); @@ -78,6 +78,9 @@ export const FORM_FIELD = new InjectionToken>( typeof ngDevMode !== 'undefined' && ngDevMode ? 'FORM_FIELD' : '', ); +// Index used for the creation of FormField instance debug name. +let debugFormFieldIdx = 0; + /** * Binds a form `FieldTree` to a UI control that edits it. A UI control can be one of several things: * 1. A native HTML input or textarea @@ -110,15 +113,31 @@ export const FORM_FIELD = new InjectionToken>( ], }) export class FormField { + // Due to the reactive nature of the form field and the inability + // to create a debug name that relies on another signal, since + // that name must be passed to consumer signals options (those that + // need to be identified), we resort to an index-based debug name that, + // at the very least, will help for internal signal grouping/clustering + // in Angular DevTools. + private readonly debugName: string | undefined = ngDevMode + ? 'formField_' + debugFormFieldIdx++ + : undefined; + /** * The field to bind to the underlying form control. */ - readonly field = input.required>({alias: 'formField'}); + readonly field = input.required>({ + alias: 'formField', + ...(ngDevMode ? formFieldDebugObj(this.debugName, 'field') : undefined), + }); /** * `FieldState` for the currently bound field. */ - readonly state = computed>(() => this.field()()); + readonly state = computed>( + () => this.field()(), + ngDevMode ? formFieldDebugObj(this.debugName, 'state') : undefined, + ); /** @internal */ readonly renderer = inject(Renderer2); @@ -165,7 +184,7 @@ export class FormField { /** @internal */ readonly parseErrorsSource = signal< Signal | undefined - >(undefined); + >(undefined, ngDevMode ? formFieldDebugObj(this.debugName, 'parseErrorsSource') : undefined); /** A lazily instantiated fake `NgControl`. */ private _interopNgControl: InteropNgControl | undefined; @@ -184,6 +203,7 @@ export class FormField { fieldTree: untracked(this.state).fieldTree, formField: this as FormField, })) ?? [], + ngDevMode ? formFieldDebugObj(this.debugName, 'parseErrors') : undefined, ); /** Errors associated with this form field. */ @@ -192,7 +212,10 @@ export class FormField { this.state() .errors() .filter((err) => !err.formField || err.formField === this), - {equal: shallowArrayEquals}, + { + equal: shallowArrayEquals, + ...(ngDevMode ? formFieldDebugObj(this.debugName, 'errors') : undefined), + }, ); /** Whether this `FormField` has been registered as a binding on its associated `FieldState`. */ @@ -246,7 +269,14 @@ export class FormField { */ private installClassBindingEffect(): void { const classes = Object.entries(this.config?.classes ?? {}).map( - ([className, computation]) => [className, computed(() => computation(this))] as const, + ([className, computation]) => + [ + className, + computed( + () => computation(this), + ngDevMode ? formFieldDebugObj(this.debugName, className) : undefined, + ), + ] as const, ); if (classes.length === 0) { return; @@ -269,7 +299,10 @@ export class FormField { } }, }, - {injector: this.injector}, + { + injector: this.injector, + ...(ngDevMode ? formFieldDebugObj(this.debugName, 'applyClassNames') : undefined), + }, ); } @@ -352,7 +385,10 @@ export class FormField { ); } }, - {injector: this.injector}, + { + injector: this.injector, + ...formFieldDebugObj(this.debugName, 'registerAsBinding'), + }, ); } } @@ -379,7 +415,7 @@ export class FormField { } if (this.controlValueAccessor) { - this.ɵngControlUpdate = cvaControlCreate(host, this as FormField); + this.ɵngControlUpdate = cvaControlCreate(host, this as FormField, this.debugName); } else if (host.customControl) { this.ɵngControlUpdate = customControlCreate(host, this as FormField); } else if (this.elementIsNativeFormElement) { @@ -388,6 +424,7 @@ export class FormField { this as FormField, this.parseErrorsSource, this.validityMonitor, + this.debugName, ); } else { throw new RuntimeError( diff --git a/packages/forms/signals/src/field/context.ts b/packages/forms/signals/src/field/context.ts index f1e183e31eac..1aaec502e72b 100644 --- a/packages/forms/signals/src/field/context.ts +++ b/packages/forms/signals/src/field/context.ts @@ -17,7 +17,6 @@ import {RuntimeErrorCode} from '../errors'; import {AbstractControl} from '@angular/forms'; import { CompatSchemaPath, - CompatFieldState, FieldContext, ReadonlyCompatFieldState, ReadonlyFieldState, @@ -28,6 +27,7 @@ import { } from '../api/types'; import {FieldPathNode} from '../schema/path_node'; import {isArray} from '../util/type_guards'; +import {formDebugObj} from '../util/debug'; import type {FieldNode} from './node'; import {getBoundPathDepth} from './resolution'; @@ -35,6 +35,8 @@ import {getBoundPathDepth} from './resolution'; * `FieldContext` implementation, backed by a `FieldNode`. */ export class FieldNodeContext implements FieldContext { + readonly index: Signal; + /** * Cache of paths that have been resolved for this context. * @@ -52,6 +54,23 @@ export class FieldNodeContext implements FieldContext { /** The field node this context corresponds to. */ private readonly node: FieldNode, ) { + this.index = computed( + () => { + // Attempt to read the key first, this will throw an error if we're on a root field. + const key = this.key(); + // Assert that the parent is actually an array. + if (!isArray(untracked(this.node.structure.parent!.value))) { + throw new RuntimeError( + RuntimeErrorCode.PARENT_NOT_ARRAY, + ngDevMode && 'Cannot access index, parent field is not an array.', + ); + } + // Return the key as a number if we are indeed inside an array field. + return Number(key); + }, + ngDevMode ? formDebugObj(this.node.debugName, 'index') : undefined, + ); + // These methods are explicitly bound to the context instance so that they // safely retain their `this` reference if destructured by consumers // (e.g., during validation when `stateOf` or `fieldTreeOf` are extracted). @@ -66,46 +85,52 @@ export class FieldNodeContext implements FieldContext { */ private resolve(target: SchemaPath): ReadonlyFieldTree { if (!this.cache.has(target)) { - const resolver = computed>(() => { - const targetPathNode = FieldPathNode.unwrapFieldPath(target); - - // First, find the field where the root our target path was merged in. - // We determine this by walking up the field tree from the current field and looking for - // the place where the LogicNodeBuilder from the target path's root was merged in. - // We always make sure to walk up at least as far as the depth of the path we were bound to. - // This ensures that we do not accidentally match on the wrong application of a recursively - // applied schema. - let field: FieldNode | undefined = this.node; - let stepsRemaining = getBoundPathDepth(); - while (stepsRemaining > 0 || !field.structure.logic.hasLogic(targetPathNode.root.builder)) { - stepsRemaining--; - field = field.structure.parent; - if (field === undefined) { - throw new RuntimeError( - RuntimeErrorCode.PATH_NOT_IN_FIELD_TREE, - ngDevMode && 'Path is not part of this field tree.', - ); + const resolver = computed>( + () => { + const targetPathNode = FieldPathNode.unwrapFieldPath(target); + + // First, find the field where the root our target path was merged in. + // We determine this by walking up the field tree from the current field and looking for + // the place where the LogicNodeBuilder from the target path's root was merged in. + // We always make sure to walk up at least as far as the depth of the path we were bound to. + // This ensures that we do not accidentally match on the wrong application of a recursively + // applied schema. + let field: FieldNode | undefined = this.node; + let stepsRemaining = getBoundPathDepth(); + while ( + stepsRemaining > 0 || + !field.structure.logic.hasLogic(targetPathNode.root.builder) + ) { + stepsRemaining--; + field = field.structure.parent; + if (field === undefined) { + throw new RuntimeError( + RuntimeErrorCode.PATH_NOT_IN_FIELD_TREE, + ngDevMode && 'Path is not part of this field tree.', + ); + } } - } - // Now, we can navigate to the target field using the relative path in the target path node - // to traverse down from the field we just found. - for (let key of targetPathNode.keys) { - field = field.structure.getChild(key); - if (field === undefined) { - throw new RuntimeError( - RuntimeErrorCode.PATH_RESOLUTION_FAILED, - ngDevMode && - `Cannot resolve path .${targetPathNode.keys.join('.')} relative to field ${[ - '', - ...this.node.structure.pathKeys(), - ].join('.')}.`, - ); + // Now, we can navigate to the target field using the relative path in the target path node + // to traverse down from the field we just found. + for (let key of targetPathNode.keys) { + field = field.structure.getChild(key); + if (field === undefined) { + throw new RuntimeError( + RuntimeErrorCode.PATH_RESOLUTION_FAILED, + ngDevMode && + `Cannot resolve path .${targetPathNode.keys.join('.')} relative to field ${[ + '', + ...this.node.structure.pathKeys(), + ].join('.')}.`, + ); + } } - } - return field.fieldTree; - }); + return field.fieldTree; + }, + ngDevMode ? formDebugObj(this.node.debugName, 'resolver') : undefined, + ); this.cache.set(target, resolver); } @@ -132,20 +157,6 @@ export class FieldNodeContext implements FieldContext { return this.node.structure.pathKeys; } - readonly index = computed(() => { - // Attempt to read the key first, this will throw an error if we're on a root field. - const key = this.key(); - // Assert that the parent is actually an array. - if (!isArray(untracked(this.node.structure.parent!.value))) { - throw new RuntimeError( - RuntimeErrorCode.PARENT_NOT_ARRAY, - ngDevMode && 'Cannot access index, parent field is not an array.', - ); - } - // Return the key as a number if we are indeed inside an array field. - return Number(key); - }); - // Note: `fieldTreeOf` and `stateOf` are purposefully defined as overloaded class // methods rather than arrow-function properties. This allows their signatures // to successfully satisfy the complex, deferred conditional generic typings diff --git a/packages/forms/signals/src/field/field_adapter.ts b/packages/forms/signals/src/field/field_adapter.ts index 737ac8e8ce0c..0125478ca90b 100644 --- a/packages/forms/signals/src/field/field_adapter.ts +++ b/packages/forms/signals/src/field/field_adapter.ts @@ -59,6 +59,7 @@ export interface FieldAdapter { model: WritableSignal, pathNode: FieldPathNode, adapter: FieldAdapter, + debugName?: string, ): FieldNode; } @@ -78,6 +79,7 @@ export class BasicFieldAdapter implements FieldAdapter { value: WritableSignal, pathNode: FieldPathNode, adapter: FieldAdapter, + debugName?: string, ): FieldNode { return new FieldNode({ kind: 'root', @@ -86,6 +88,7 @@ export class BasicFieldAdapter implements FieldAdapter { pathNode, logic: pathNode.builder.build(), fieldAdapter: adapter, + debugName, }); } diff --git a/packages/forms/signals/src/field/manager.ts b/packages/forms/signals/src/field/manager.ts index 18a9376b7a83..5969375b8ef5 100644 --- a/packages/forms/signals/src/field/manager.ts +++ b/packages/forms/signals/src/field/manager.ts @@ -9,6 +9,7 @@ import {APP_ID, effect, Injector, untracked} from '@angular/core'; import type {FormSubmitOptions} from '../api/types'; import type {FieldNodeStructure} from './structure'; +import {formDebugObj} from '../util/debug'; /** * Manages the collection of fields associated with a given `form`. @@ -51,8 +52,9 @@ export class FormFieldManager { * elements are now orphaned and not connected to the root. Thus they will be destroyed. * * @param root The root field structure. + * @param debugName Debug name of the effect. */ - createFieldManagementEffect(root: FieldNodeStructure): void { + createFieldManagementEffect(root: FieldNodeStructure, debugName?: string): void { effect( () => { const liveStructures = new Set(); @@ -66,7 +68,10 @@ export class FormFieldManager { } } }, - {injector: this.injector}, + { + injector: this.injector, + ...(ngDevMode ? formDebugObj(debugName, 'fieldManagement') : undefined), + }, ); } diff --git a/packages/forms/signals/src/field/metadata.ts b/packages/forms/signals/src/field/metadata.ts index 78a2f2181ff4..f56ad5d1abdc 100644 --- a/packages/forms/signals/src/field/metadata.ts +++ b/packages/forms/signals/src/field/metadata.ts @@ -15,6 +15,7 @@ import { import {MetadataKey} from '../api/rules/metadata'; import {RuntimeErrorCode} from '../errors'; import type {FieldNode} from './node'; +import {formDebugObj} from '../util/debug'; /** * Tracks custom metadata associated with a `FieldNode`. @@ -41,7 +42,10 @@ export class FieldMetadataState { const logic = this.node.logicNode.logic.getMetadata(key); const result = key.create!( this.node, - computed(() => logic.compute(this.node.context)), + computed( + () => logic.compute(this.node.context), + ngDevMode ? formDebugObj(this.node.debugName, 'result') : undefined, + ), ); this.metadata.set(key, result); } @@ -64,7 +68,10 @@ export class FieldMetadataState { const logic = this.node.logicNode.logic.getMetadata(key); this.metadata.set( key, - computed(() => logic.compute(this.node.context)), + computed( + () => logic.compute(this.node.context), + ngDevMode ? formDebugObj(this.node.debugName, 'metadata') : undefined, + ), ); } } diff --git a/packages/forms/signals/src/field/node.ts b/packages/forms/signals/src/field/node.ts index bf47c280d0cb..8787aea77d0f 100644 --- a/packages/forms/signals/src/field/node.ts +++ b/packages/forms/signals/src/field/node.ts @@ -52,6 +52,7 @@ import { } from './structure'; import {FieldSubmitState} from './submit'; import {ValidationState} from './validation'; +import {formDebugObj} from '../util/debug'; export interface ControlValueSignal extends WritableSignal { rawSet(value: T): void; @@ -78,6 +79,7 @@ export class FieldNode implements FieldState { readonly submitState: FieldSubmitState; readonly fieldAdapter: FieldAdapter; readonly controlValue: ControlValueSignal; + readonly debugName: string | undefined; private _context: FieldContext | undefined = undefined; get context(): FieldContext { @@ -90,7 +92,26 @@ export class FieldNode implements FieldState { readonly fieldProxy = new Proxy(() => this, FIELD_PROXY_HANDLER) as unknown as FieldTree; private readonly pathNode: FieldPathNode; + /** + * The `AbortController` for the currently debounced sync, or `undefined` if there is none. + * + * This is used to cancel a pending debounced sync when {@link setControlValue} is called again + * before the pending debounced sync resolves. It will also cancel any pending debounced sync + * automatically when recomputed due to `value` being set directly from others sources. + */ + private readonly pendingSync: WritableSignal; + constructor(options: FieldNodeOptions) { + this.debugName = options.debugName; + this.pendingSync = linkedSignal({ + source: () => this.value(), + computation: (_source, previous) => { + previous?.value?.abort(); + return undefined; + }, + ...(ngDevMode ? formDebugObj(this.debugName, 'pendingSync') : undefined), + }); + this.pathNode = options.pathNode; this.fieldAdapter = options.fieldAdapter; this.structure = this.fieldAdapter.createStructure(this, options); @@ -137,21 +158,6 @@ export class FieldNode implements FieldState { .reduce(firstInDom, undefined); } - /** - * The `AbortController` for the currently debounced sync, or `undefined` if there is none. - * - * This is used to cancel a pending debounced sync when {@link setControlValue} is called again - * before the pending debounced sync resolves. It will also cancel any pending debounced sync - * automatically when recomputed due to `value` being set directly from others sources. - */ - private readonly pendingSync: WritableSignal = linkedSignal({ - source: () => this.value(), - computation: (_source, previous) => { - previous?.value?.abort(); - return undefined; - }, - }); - get fieldTree(): FieldTree { return this.fieldProxy; } @@ -247,11 +253,17 @@ export class FieldNode implements FieldState { } get pattern(): Signal { - return this.metadata(PATTERN) ?? EMPTY; + return ( + this.metadata(PATTERN) ?? + (ngDevMode ? computed(() => [], formDebugObj(this.debugName, 'EMPTY')) : EMPTY) + ); } get required(): Signal { - return this.metadata(REQUIRED) ?? FALSE; + return ( + this.metadata(REQUIRED) ?? + (ngDevMode ? computed(() => false, formDebugObj(this.debugName, 'FALSE')) : FALSE) + ); } metadata(key: MetadataKey): M | undefined { @@ -375,7 +387,10 @@ export class FieldNode implements FieldState { * Creates a linked signal that initiates a {@link debounceSync} when set. */ private controlValueSignal(): ControlValueSignal { - const controlValue = linkedSignal(this.value) as ControlValueSignal; + const controlValue = linkedSignal( + this.value, + ngDevMode ? formDebugObj(this.debugName, 'controlValue') : undefined, + ) as ControlValueSignal; controlValue.rawSet = controlValue.set; controlValue.set = (newValue) => { @@ -456,8 +471,9 @@ export class FieldNode implements FieldState { value: WritableSignal, pathNode: FieldPathNode, adapter: FieldAdapter, + debugName?: string, ): FieldNode { - return adapter.newRoot(fieldManager, value, pathNode, adapter); + return adapter.newRoot(fieldManager, value, pathNode, adapter, debugName); } createStructure(options: FieldNodeOptions) { @@ -502,6 +518,7 @@ export class FieldNode implements FieldState { initialKeyInParent: key, identityInParent: trackingId, fieldAdapter: this.fieldAdapter, + debugName: this.debugName, }); } } diff --git a/packages/forms/signals/src/field/state.ts b/packages/forms/signals/src/field/state.ts index 71a9652cc49d..51e8c1da6d01 100644 --- a/packages/forms/signals/src/field/state.ts +++ b/packages/forms/signals/src/field/state.ts @@ -6,13 +6,14 @@ * found in the LICENSE file at https://angular.dev/license */ -import {computed, signal, Signal} from '@angular/core'; +import {computed, signal, Signal, WritableSignal} from '@angular/core'; import type {FormField} from '../directive/form_field'; import type {Debouncer, DisabledReason} from '../api/types'; import {DEBOUNCER} from './debounce'; import type {FieldNode} from './node'; import {shallowArrayEquals} from '../util/array'; import {shortCircuitTrue} from './util'; +import {formDebugObj} from '../util/debug'; /** * The non-validation and non-submit state associated with a `FieldNode`, such as touched and dirty @@ -25,7 +26,7 @@ export class FieldNodeState { * * A field is considered directly touched when a user stops editing it for the first time (i.e. on blur) */ - private readonly selfTouched = signal(false); + private readonly selfTouched: WritableSignal; /** * Indicates whether this field has been dirtied directly by the user (as opposed to indirectly by @@ -33,40 +34,10 @@ export class FieldNodeState { * * A field is considered directly dirtied if a user changed the value of the field at least once. */ - private readonly selfDirty = signal(false); - - /** - * Marks this specific field as touched. - */ - markAsTouched(): void { - this.selfTouched.set(true); - } - - /** - * Marks this specific field as dirty. - */ - markAsDirty(): void { - this.selfDirty.set(true); - } - - /** - * Marks this specific field as not dirty. - */ - markAsPristine(): void { - this.selfDirty.set(false); - } - - /** - * Marks this specific field as not touched. - */ - markAsUntouched(): void { - this.selfTouched.set(false); - } + private readonly selfDirty: WritableSignal; /** The {@link FormField} directives that bind this field to a UI control. */ - readonly formFieldBindings = signal[]>([]); - - constructor(private readonly node: FieldNode) {} + readonly formFieldBindings: WritableSignal[]>; /** * Whether this field is considered dirty. @@ -75,14 +46,7 @@ export class FieldNodeState { * - It was directly dirtied and is interactive * - One of its children is considered dirty */ - readonly dirty: Signal = computed(() => { - const selfDirtyValue = this.selfDirty() && !this.isNonInteractive(); - return this.node.structure.reduceChildren( - selfDirtyValue, - (child, value) => value || child.nodeState.dirty(), - shortCircuitTrue, - ); - }); + readonly dirty: Signal; /** * Whether this field is considered touched. @@ -91,14 +55,7 @@ export class FieldNodeState { * - It was directly touched and is interactive * - One of its children is considered touched */ - readonly touched: Signal = computed(() => { - const selfTouchedValue = this.selfTouched() && !this.isNonInteractive(); - return this.node.structure.reduceChildren( - selfTouchedValue, - (child, value) => value || child.nodeState.touched(), - shortCircuitTrue, - ); - }); + readonly touched: Signal; /** * The reasons for this field's disablement. This includes disabled reasons for any parent field @@ -106,13 +63,7 @@ export class FieldNodeState { * The `field` property of the `DisabledReason` can be used to determine which field ultimately * caused the disablement. */ - readonly disabledReasons: Signal = computed( - () => [ - ...(this.node.structure.parent?.nodeState.disabledReasons() ?? []), - ...this.node.logicNode.logic.disabledReasons.compute(this.node.context), - ], - {equal: shallowArrayEquals}, - ); + readonly disabledReasons: Signal; /** * Whether this field is considered disabled. @@ -121,7 +72,7 @@ export class FieldNodeState { * - The schema contains logic that directly disabled it * - Its parent field is considered disabled */ - readonly disabled: Signal = computed(() => !!this.disabledReasons().length); + readonly disabled: Signal; /** * Whether this field is considered readonly. @@ -130,12 +81,7 @@ export class FieldNodeState { * - The schema contains logic that directly made it readonly * - Its parent field is considered readonly */ - readonly readonly: Signal = computed( - () => - (this.node.structure.parent?.nodeState.readonly() || - this.node.logicNode.logic.readonly.compute(this.node.context)) ?? - false, - ); + readonly readonly: Signal; /** * Whether this field is considered hidden. @@ -144,43 +90,14 @@ export class FieldNodeState { * - The schema contains logic that directly hides it * - Its parent field is considered hidden */ - readonly hidden: Signal = computed( - () => - (this.node.structure.parent?.nodeState.hidden() || - this.node.logicNode.logic.hidden.compute(this.node.context)) ?? - false, - ); + readonly hidden: Signal; - readonly name: Signal = computed(() => { - const parent = this.node.structure.parent; - if (!parent) { - return this.node.structure.fieldManager.rootName; - } - - return `${parent.name()}.${this.node.structure.keyInParent()}`; - }); + readonly name: Signal; /** * An optional {@link Debouncer} factory for this field. */ - readonly debouncer: Signal<((signal: AbortSignal) => Promise | void) | undefined> = - computed(() => { - if (this.node.logicNode.logic.hasMetadata(DEBOUNCER)) { - const debouncerLogic = this.node.logicNode.logic.getMetadata(DEBOUNCER); - const debouncer = debouncerLogic.compute(this.node.context); - - // Even if this field has a `debounce()` rule, it could be applied conditionally and currently - // inactive, in which case `compute()` will return undefined. - if (debouncer) { - return (signal) => debouncer(this.node.context, signal); - } - } - - // Fallback to the parent's debouncer, if any. If there is no debouncer configured all the way - // up to the root field, this simply returns `undefined` indicating that the operation should - // not be debounced. - return this.node.structure.parent?.nodeState.debouncer?.(); - }); + readonly debouncer: Signal<((signal: AbortSignal) => Promise | void) | undefined>; /** Whether this field is considered non-interactive. * @@ -189,7 +106,144 @@ export class FieldNodeState { * - It is disabled * - It is readonly */ - private readonly isNonInteractive = computed( - () => this.hidden() || this.disabled() || this.readonly(), - ); + private readonly isNonInteractive: Signal; + + constructor(node: FieldNode) { + this.selfTouched = signal( + false, + ngDevMode ? formDebugObj(node.debugName, 'selfTouched') : undefined, + ); + + this.selfDirty = signal( + false, + ngDevMode ? formDebugObj(node.debugName, 'selfDirty') : undefined, + ); + + this.formFieldBindings = signal[]>( + [], + ngDevMode ? formDebugObj(node.debugName, 'formFieldBindings') : undefined, + ); + + this.dirty = computed( + () => { + const selfDirtyValue = this.selfDirty() && !this.isNonInteractive(); + return node.structure.reduceChildren( + selfDirtyValue, + (child, value) => value || child.nodeState.dirty(), + shortCircuitTrue, + ); + }, + ngDevMode ? formDebugObj(node.debugName, 'dirty') : undefined, + ); + + this.touched = computed( + () => { + const selfTouchedValue = this.selfTouched() && !this.isNonInteractive(); + return node.structure.reduceChildren( + selfTouchedValue, + (child, value) => value || child.nodeState.touched(), + shortCircuitTrue, + ); + }, + ngDevMode ? formDebugObj(node.debugName, 'touched') : undefined, + ); + + this.disabledReasons = computed( + () => [ + ...(node.structure.parent?.nodeState.disabledReasons() ?? []), + ...node.logicNode.logic.disabledReasons.compute(node.context), + ], + { + equal: shallowArrayEquals, + ...(ngDevMode ? formDebugObj(node.debugName, 'disabledReasons') : undefined), + }, + ); + + this.disabled = computed( + () => !!this.disabledReasons().length, + ngDevMode ? formDebugObj(node.debugName, 'disabled') : undefined, + ); + + this.readonly = computed( + () => + (node.structure.parent?.nodeState.readonly() || + node.logicNode.logic.readonly.compute(node.context)) ?? + false, + ngDevMode ? formDebugObj(node.debugName, 'readonly') : undefined, + ); + + this.hidden = computed( + () => + (node.structure.parent?.nodeState.hidden() || + node.logicNode.logic.hidden.compute(node.context)) ?? + false, + ngDevMode ? formDebugObj(node.debugName, 'hidden') : undefined, + ); + + this.name = computed( + () => { + const parent = node.structure.parent; + if (!parent) { + return node.structure.fieldManager.rootName; + } + + return `${parent.name()}.${node.structure.keyInParent()}`; + }, + ngDevMode ? formDebugObj(node.debugName, 'name') : undefined, + ); + + this.debouncer = computed( + () => { + if (node.logicNode.logic.hasMetadata(DEBOUNCER)) { + const debouncerLogic = node.logicNode.logic.getMetadata(DEBOUNCER); + const debouncer = debouncerLogic.compute(node.context); + + // Even if this field has a `debounce()` rule, it could be applied conditionally and currently + // inactive, in which case `compute()` will return undefined. + if (debouncer) { + return (signal) => debouncer(node.context, signal); + } + } + + // Fallback to the parent's debouncer, if any. If there is no debouncer configured all the way + // up to the root field, this simply returns `undefined` indicating that the operation should + // not be debounced. + return node.structure.parent?.nodeState.debouncer?.(); + }, + ngDevMode ? formDebugObj(node.debugName, 'debouncer') : undefined, + ); + + this.isNonInteractive = computed( + () => this.hidden() || this.disabled() || this.readonly(), + ngDevMode ? formDebugObj(node.debugName, 'isNonInteractive') : undefined, + ); + } + + /** + * Marks this specific field as touched. + */ + markAsTouched(): void { + this.selfTouched.set(true); + } + + /** + * Marks this specific field as dirty. + */ + markAsDirty(): void { + this.selfDirty.set(true); + } + + /** + * Marks this specific field as not dirty. + */ + markAsPristine(): void { + this.selfDirty.set(false); + } + + /** + * Marks this specific field as not touched. + */ + markAsUntouched(): void { + this.selfTouched.set(false); + } } diff --git a/packages/forms/signals/src/field/structure.ts b/packages/forms/signals/src/field/structure.ts index f0ae623becd0..5f9988d179e5 100644 --- a/packages/forms/signals/src/field/structure.ts +++ b/packages/forms/signals/src/field/structure.ts @@ -23,6 +23,7 @@ import {LogicNode} from '../schema/logic_node'; import type {FieldPathNode} from '../schema/path_node'; import {deepSignal} from '../util/deep_signal'; import {isArray, isObject} from '../util/type_guards'; +import {formDebugObj} from '../util/debug'; import type {FieldAdapter} from './field_adapter'; import type {FormFieldManager} from './manager'; import type {FieldNode, ParentFieldNode} from './node'; @@ -226,64 +227,78 @@ export abstract class FieldNodeStructure { initialKeyInParent: string | undefined, ): {keyInParent: Signal; isOrphaned: Signal} { if (kind === 'root') { - return {keyInParent: ROOT_KEY_IN_PARENT, isOrphaned: FALSE_SIGNAL}; + return { + keyInParent: ROOT_KEY_IN_PARENT, + isOrphaned: ngDevMode + ? computed(() => false, formDebugObj(this.node.debugName, 'FALSE_SIGNAL')) + : FALSE_SIGNAL, + }; } const parent = this.parent!; let lastKnownKey = initialKeyInParent!; - const keyOrOrphan = computed(() => { - if (parent.structure.isOrphaned()) { - return ORPHAN_TOKEN; - } - - const map = parent.structure.childrenMap(); - if (!map) { - return ORPHAN_TOKEN; - } - - // Fast path: check last known key - const lastKnownChild = map.byPropertyKey.get(lastKnownKey); - if (lastKnownChild && lastKnownChild.node === this.node) { - return lastKnownKey; - } + const keyOrOrphan = computed( + () => { + if (parent.structure.isOrphaned()) { + return ORPHAN_TOKEN; + } - if (identityInParent === undefined) { - // Object property: if not at last known key, it's orphaned - return ORPHAN_TOKEN; - } else { - // Array element: scan for node in childrenMap - for (const [key, child] of map.byPropertyKey) { - if (child.node === this.node) { - return (lastKnownKey = key); - } + const map = parent.structure.childrenMap(); + if (!map) { + return ORPHAN_TOKEN; } - return ORPHAN_TOKEN; - } - }); - const isOrphaned = computed(() => keyOrOrphan() === ORPHAN_TOKEN); + // Fast path: check last known key + const lastKnownChild = map.byPropertyKey.get(lastKnownKey); + if (lastKnownChild && lastKnownChild.node === this.node) { + return lastKnownKey; + } - const keyInParent = computed(() => { - const key = keyOrOrphan(); - if (key === ORPHAN_TOKEN) { if (identityInParent === undefined) { - throw new RuntimeError( - RuntimeErrorCode.ORPHAN_FIELD_PROPERTY, - ngDevMode && - `Orphan field, looking for property '${initialKeyInParent}' of ${getDebugName( - parent, - )}`, - ); + // Object property: if not at last known key, it's orphaned + return ORPHAN_TOKEN; } else { - throw new RuntimeError( - RuntimeErrorCode.ORPHAN_FIELD_NOT_FOUND, - ngDevMode && `Orphan field, can't find element in array ${getDebugName(parent)}`, - ); + // Array element: scan for node in childrenMap + for (const [key, child] of map.byPropertyKey) { + if (child.node === this.node) { + return (lastKnownKey = key); + } + } + return ORPHAN_TOKEN; } - } - return key; - }); + }, + ngDevMode ? formDebugObj(this.node.debugName, 'keyOrOrphan') : undefined, + ); + + const isOrphaned = computed( + () => keyOrOrphan() === ORPHAN_TOKEN, + ngDevMode ? formDebugObj(this.node.debugName, 'isOrphaned') : undefined, + ); + + const keyInParent = computed( + () => { + const key = keyOrOrphan(); + if (key === ORPHAN_TOKEN) { + if (identityInParent === undefined) { + throw new RuntimeError( + RuntimeErrorCode.ORPHAN_FIELD_PROPERTY, + ngDevMode && + `Orphan field, looking for property '${initialKeyInParent}' of ${getDebugName( + parent, + )}`, + ); + } else { + throw new RuntimeError( + RuntimeErrorCode.ORPHAN_FIELD_NOT_FOUND, + ngDevMode && `Orphan field, can't find element in array ${getDebugName(parent)}`, + ); + } + } + return key; + }, + ngDevMode ? formDebugObj(this.node.debugName, 'keyInParent') : undefined, + ); return {keyInParent, isOrphaned}; } @@ -295,6 +310,7 @@ export abstract class FieldNodeStructure { value: unknown, previous: {source: unknown; value: ChildrenData | undefined} | undefined, ): ChildrenData | undefined => this.computeChildrenMap(value, previous?.value, false), + ...(ngDevMode ? formDebugObj(this.node.debugName, 'childrenMap') : undefined), }); } @@ -424,7 +440,10 @@ export abstract class FieldNodeStructure { * reactive consumers aren't notified unless the field at a key actually changes. */ private createReader(key: string): Signal { - return computed(() => this.childrenMap()?.byPropertyKey.get(key)?.node); + return computed( + () => this.childrenMap()?.byPropertyKey.get(key)?.node, + ngDevMode ? formDebugObj(this.node.debugName, 'reader') : undefined, + ); } } @@ -439,14 +458,28 @@ export class RootFieldNodeStructure extends FieldNodeStructure { } override get pathKeys(): Signal { - return ROOT_PATH_KEYS; + return ngDevMode + ? computed(() => [], formDebugObj(this.node.debugName, 'ROOT_PATH_KEYS')) + : ROOT_PATH_KEYS; } override get keyInParent(): Signal { - return ROOT_KEY_IN_PARENT; + return ngDevMode + ? computed( + () => { + throw new RuntimeError( + RuntimeErrorCode.ROOT_FIELD_NO_PARENT, + 'The top-level field in the form has no parent.', + ); + }, + formDebugObj(this.node.debugName, 'ROOT_KEY_IN_PARENT'), + ) + : ROOT_KEY_IN_PARENT; } - override readonly isOrphaned = FALSE_SIGNAL; + override readonly isOrphaned = ngDevMode + ? computed(() => false, formDebugObj(this.node.debugName, 'FALSE_SIGNAL')) + : FALSE_SIGNAL; /** @internal */ override readonly childrenMap: Signal; @@ -518,9 +551,12 @@ export class ChildFieldNodeStructure extends FieldNodeStructure { this.isOrphaned = signals.isOrphaned; this.keyInParent = signals.keyInParent; - this.pathKeys = computed(() => [...parent.structure.pathKeys(), this.keyInParent()]); + this.pathKeys = computed( + () => [...parent.structure.pathKeys(), this.keyInParent()], + ngDevMode ? formDebugObj(node.debugName, 'pathKeys') : undefined, + ); - this.value = deepSignal(this.parent.structure.value, this.keyInParent); + this.value = deepSignal(this.parent.structure.value, this.keyInParent, node.debugName); this.childrenMap = this.createChildrenMap(); this.fieldManager.structures.add(this); } @@ -543,6 +579,8 @@ export interface RootFieldNodeOptions { readonly fieldManager: FormFieldManager; /** This allows for more granular field and state management, and is currently used for compat. */ readonly fieldAdapter: FieldAdapter; + /** The debug name of the form. Used in Angular DevTools to cluster all internal form nodes. */ + readonly debugName?: string; } /** Options passed when constructing a child field node. */ @@ -561,6 +599,8 @@ export interface ChildFieldNodeOptions { readonly identityInParent: TrackingKey | undefined; /** This allows for more granular field and state management, and is currently used for compat. */ readonly fieldAdapter: FieldAdapter; + /** The debug name of the form. Used in Angular DevTools to cluster all internal form nodes. */ + readonly debugName?: string; } /** Options passed when constructing a field node. */ @@ -574,10 +614,7 @@ const ROOT_PATH_KEYS = computed(() => []); * do not have a parent. This signal will throw if it is read. */ const ROOT_KEY_IN_PARENT = computed(() => { - throw new RuntimeError( - RuntimeErrorCode.ROOT_FIELD_NO_PARENT, - ngDevMode && 'The top-level field in the form has no parent.', - ); + throw new RuntimeError(RuntimeErrorCode.ROOT_FIELD_NO_PARENT, null); }); /** Gets a human readable name for a field node for use in error messages. */ @@ -593,7 +630,7 @@ interface MutableChildrenData { /** * Derived data regarding child fields for a specific parent field. */ -interface ChildrenData { +export interface ChildrenData { /** * Tracks `ChildData` for each property key within the parent. */ diff --git a/packages/forms/signals/src/field/submit.ts b/packages/forms/signals/src/field/submit.ts index 64adb2549116..b7455e86fbf0 100644 --- a/packages/forms/signals/src/field/submit.ts +++ b/packages/forms/signals/src/field/submit.ts @@ -9,6 +9,7 @@ import {computed, linkedSignal, Signal, signal, WritableSignal} from '@angular/core'; import {ValidationError} from '../api/rules/validation/validation_errors'; import type {FieldNode} from './node'; +import {formDebugObj} from '../util/debug'; /** * State of a `FieldNode` that's associated with form submission. @@ -18,23 +19,34 @@ export class FieldSubmitState { * Whether this field was directly submitted (as opposed to indirectly by a parent field being submitted) * and is still in the process of submitting. */ - readonly selfSubmitting = signal(false); + readonly selfSubmitting: WritableSignal; /** Submission errors that are associated with this field. */ readonly submissionErrors: WritableSignal; + /** + * Whether this form is currently in the process of being submitted. + * Either because the field was submitted directly, or because a parent field was submitted. + */ + readonly submitting: Signal; + constructor(private readonly node: FieldNode) { + this.selfSubmitting = signal( + false, + ngDevMode ? formDebugObj(this.node.debugName, 'selfSubmitting') : undefined, + ); + this.submissionErrors = linkedSignal({ source: this.node.structure.value, computation: () => [] as readonly ValidationError.WithFieldTree[], + ...(ngDevMode ? formDebugObj(this.node.debugName, 'submissionErrors') : undefined), }); - } - /** - * Whether this form is currently in the process of being submitted. - * Either because the field was submitted directly, or because a parent field was submitted. - */ - readonly submitting: Signal = computed(() => { - return this.selfSubmitting() || (this.node.structure.parent?.submitting() ?? false); - }); + this.submitting = computed( + () => { + return this.selfSubmitting() || (this.node.structure.parent?.submitting() ?? false); + }, + ngDevMode ? formDebugObj(this.node.debugName, 'submitting') : undefined, + ); + } } diff --git a/packages/forms/signals/src/field/validation.ts b/packages/forms/signals/src/field/validation.ts index 4f65624ae5df..593f92d46293 100644 --- a/packages/forms/signals/src/field/validation.ts +++ b/packages/forms/signals/src/field/validation.ts @@ -13,6 +13,7 @@ import {isArray} from '../util/type_guards'; import type {FieldNode} from './node'; import {shortCircuitFalse} from './util'; import {shallowArrayEquals} from '../util/array'; +import {formDebugObj} from '../util/debug'; /** * Helper function taking validation state, and returning own state of the node. @@ -148,25 +149,11 @@ export interface ValidationState { * form submit fails with errors. */ export class FieldValidationState implements ValidationState { - constructor(readonly node: FieldNode) {} - /** * The full set of synchronous tree errors visible to this field. This includes ones that are * targeted at a descendant field rather than at this field. */ - readonly rawSyncTreeErrors: Signal = computed( - () => { - if (this.shouldSkipValidation()) { - return []; - } - - return [ - ...this.node.logicNode.logic.syncTreeErrors.compute(this.node.context), - ...(this.node.structure.parent?.validationState.rawSyncTreeErrors() ?? []), - ]; - }, - {equal: shallowArrayEquals}, - ); + readonly rawSyncTreeErrors: Signal; /** * The full set of synchronous errors for this field, including synchronous tree errors and @@ -174,128 +161,47 @@ export class FieldValidationState implements ValidationState { * added. From the perspective of the field state they are either there or not, they are never in a * pending state. */ - readonly syncErrors: Signal = computed( - () => { - // Short-circuit running validators if validation doesn't apply to this field. - if (this.shouldSkipValidation()) { - return []; - } - - return [ - ...this.node.logicNode.logic.syncErrors.compute(this.node.context), - ...this.syncTreeErrors(), - ...normalizeErrors(this.node.submitState.submissionErrors()), - ]; - }, - {equal: shallowArrayEquals}, - ); + readonly syncErrors: Signal; /** * Whether the field is considered valid according solely to its synchronous validators. * Errors resulting from a previous submit attempt are also considered for this state. */ - readonly syncValid: Signal = computed(() => { - // Short-circuit checking children if validation doesn't apply to this field. - if (this.shouldSkipValidation()) { - return true; - } - - return this.node.structure.reduceChildren( - this.syncErrors().length === 0, - (child, value) => value && child.validationState.syncValid(), - shortCircuitFalse, - ); - }); + readonly syncValid: Signal; /** * The synchronous tree errors visible to this field that are specifically targeted at this field * rather than a descendant. */ - readonly syncTreeErrors: Signal = computed( - () => this.rawSyncTreeErrors().filter((err) => err.fieldTree === this.node.fieldTree), - {equal: shallowArrayEquals}, - ); + readonly syncTreeErrors: Signal; /** * The full set of asynchronous tree errors visible to this field. This includes ones that are * targeted at a descendant field rather than at this field, as well as sentinel 'pending' values * indicating that the validator is still running and an error could still occur. */ - readonly rawAsyncErrors: Signal<(ValidationError.WithFieldTree | 'pending')[]> = computed( - () => { - // Short-circuit running validators if validation doesn't apply to this field. - if (this.shouldSkipValidation()) { - return []; - } - - return [ - // TODO: add field in `validateAsync` and remove this map - ...this.node.logicNode.logic.asyncErrors.compute(this.node.context), - // TODO: does it make sense to filter this to errors in this subtree? - ...(this.node.structure.parent?.validationState.rawAsyncErrors() ?? []), - ]; - }, - {equal: shallowArrayEquals}, - ); + readonly rawAsyncErrors: Signal<(ValidationError.WithFieldTree | 'pending')[]>; /** * The asynchronous tree errors visible to this field that are specifically targeted at this field * rather than a descendant. This also includes all 'pending' sentinel values, since those could * theoretically result in errors for this field. */ - readonly asyncErrors: Signal<(ValidationError.WithFieldTree | 'pending')[]> = computed( - () => { - if (this.shouldSkipValidation()) { - return []; - } - return this.rawAsyncErrors().filter( - (err) => err === 'pending' || err.fieldTree === this.node.fieldTree, - ); - }, - {equal: shallowArrayEquals}, - ); - - readonly parseErrors: Signal = computed( - () => this.node.formFieldBindings().flatMap((field) => field.parseErrors()), - {equal: shallowArrayEquals}, - ); + readonly asyncErrors: Signal<(ValidationError.WithFieldTree | 'pending')[]>; + + readonly parseErrors: Signal; /** * The combined set of all errors that currently apply to this field. */ - readonly errors = computed( - () => [ - ...this.parseErrors(), - ...this.syncErrors(), - ...this.asyncErrors().filter((err) => err !== 'pending'), - ], - {equal: shallowArrayEquals}, - ); - - readonly errorSummary = computed( - () => { - const errors = this.node.structure.reduceChildren(this.errors(), (child, result) => [ - ...result, - ...child.errorSummary(), - ]); - // Sort by DOM order on client-side only. - if (typeof ngServerMode === 'undefined' || !ngServerMode) { - untracked(() => errors.sort(compareErrorPosition)); - } - return errors; - }, - {equal: shallowArrayEquals}, - ); + readonly errors: Signal; + + readonly errorSummary: Signal; /** * Whether this field has any asynchronous validators still pending. */ - readonly pending = computed(() => - this.node.structure.reduceChildren( - this.asyncErrors().includes('pending'), - (child, value) => value || child.validationState.asyncErrors().includes('pending'), - ), - ); + readonly pending: Signal; /** * The validation status of the field. @@ -314,26 +220,7 @@ export class FieldValidationState implements ValidationState { * - Any of its children is considered invalid * A field is considered to have unknown validity status if it is not valid or invalid. */ - readonly status: Signal<'valid' | 'invalid' | 'unknown'> = computed(() => { - // Short-circuit checking children if validation doesn't apply to this field. - if (this.shouldSkipValidation()) { - return 'valid'; - } - let ownStatus = calculateValidationSelfStatus(this); - - return this.node.structure.reduceChildren<'valid' | 'invalid' | 'unknown'>( - ownStatus, - (child, value) => { - if (value === 'invalid' || child.validationState.status() === 'invalid') { - return 'invalid'; - } else if (value === 'unknown' || child.validationState.status() === 'unknown') { - return 'unknown'; - } - return 'valid'; - }, - (v) => v === 'invalid', // short-circuit on 'invalid' - ); - }); + readonly status: Signal<'valid' | 'invalid' | 'unknown'>; /** * Whether the field is considered valid. @@ -345,7 +232,7 @@ export class FieldValidationState implements ValidationState { * Note: `!valid()` is *not* the same as `invalid()`. Both `valid()` and `invalid()` can be false * if there are currently no errors, but validators are still pending. */ - readonly valid = computed(() => this.status() === 'valid'); + readonly valid: Signal; /** * Whether the field is considered invalid. @@ -357,19 +244,196 @@ export class FieldValidationState implements ValidationState { * Note: `!invalid()` is *not* the same as `valid()`. Both `valid()` and `invalid()` can be false * if there are currently no errors, but validators are still pending. */ - readonly invalid = computed(() => this.status() === 'invalid'); + readonly invalid: Signal; /** * Indicates whether validation should be skipped for this field because it is hidden, disabled, * or readonly. */ - readonly shouldSkipValidation = computed( - () => - this.node.hidden() || - this.node.disabled() || - this.node.readonly() || - this.node.structure.isOrphaned(), - ); + readonly shouldSkipValidation: Signal; + + constructor(node: FieldNode) { + this.rawSyncTreeErrors = computed( + () => { + if (this.shouldSkipValidation()) { + return []; + } + + return [ + ...node.logicNode.logic.syncTreeErrors.compute(node.context), + ...(node.structure.parent?.validationState.rawSyncTreeErrors() ?? []), + ]; + }, + { + equal: shallowArrayEquals, + ...(ngDevMode ? formDebugObj(node.debugName, 'rawSyncTreeErrors') : undefined), + }, + ); + + this.syncErrors = computed( + () => { + // Short-circuit running validators if validation doesn't apply to this field. + if (this.shouldSkipValidation()) { + return []; + } + + return [ + ...node.logicNode.logic.syncErrors.compute(node.context), + ...this.syncTreeErrors(), + ...normalizeErrors(node.submitState.submissionErrors()), + ]; + }, + { + equal: shallowArrayEquals, + ...(ngDevMode ? formDebugObj(node.debugName, 'syncErrors') : undefined), + }, + ); + + this.syncValid = computed( + () => { + // Short-circuit checking children if validation doesn't apply to this field. + if (this.shouldSkipValidation()) { + return true; + } + + return node.structure.reduceChildren( + this.syncErrors().length === 0, + (child, value) => value && child.validationState.syncValid(), + shortCircuitFalse, + ); + }, + ngDevMode ? formDebugObj(node.debugName, 'syncValid') : undefined, + ); + + this.syncTreeErrors = computed( + () => this.rawSyncTreeErrors().filter((err) => err.fieldTree === node.fieldTree), + { + equal: shallowArrayEquals, + ...(ngDevMode ? formDebugObj(node.debugName, 'syncTreeErrors') : undefined), + }, + ); + + this.rawAsyncErrors = computed( + () => { + // Short-circuit running validators if validation doesn't apply to this field. + if (this.shouldSkipValidation()) { + return []; + } + + return [ + // TODO: add field in `validateAsync` and remove this map + ...node.logicNode.logic.asyncErrors.compute(node.context), + // TODO: does it make sense to filter this to errors in this subtree? + ...(node.structure.parent?.validationState.rawAsyncErrors() ?? []), + ]; + }, + { + equal: shallowArrayEquals, + ...(ngDevMode ? formDebugObj(node.debugName, 'rawAsyncErrors') : undefined), + }, + ); + + this.asyncErrors = computed( + () => { + if (this.shouldSkipValidation()) { + return []; + } + return this.rawAsyncErrors().filter( + (err) => err === 'pending' || err.fieldTree === node.fieldTree, + ); + }, + { + equal: shallowArrayEquals, + ...(ngDevMode ? formDebugObj(node.debugName, 'asyncErrors') : undefined), + }, + ); + + this.parseErrors = computed( + () => node.formFieldBindings().flatMap((field) => field.parseErrors()), + { + equal: shallowArrayEquals, + ...(ngDevMode ? formDebugObj(node.debugName, 'parseErrors') : undefined), + }, + ); + + this.errors = computed( + () => [ + ...this.parseErrors(), + ...this.syncErrors(), + ...this.asyncErrors().filter((err) => err !== 'pending'), + ], + { + equal: shallowArrayEquals, + ...(ngDevMode ? formDebugObj(node.debugName, 'errors') : undefined), + }, + ); + + this.errorSummary = computed( + () => { + const errors = node.structure.reduceChildren(this.errors(), (child, result) => [ + ...result, + ...child.errorSummary(), + ]); + // Sort by DOM order on client-side only. + if (typeof ngServerMode === 'undefined' || !ngServerMode) { + untracked(() => errors.sort(compareErrorPosition)); + } + return errors; + }, + { + equal: shallowArrayEquals, + ...(ngDevMode ? formDebugObj(node.debugName, 'errorSummary') : undefined), + }, + ); + + this.pending = computed( + () => + node.structure.reduceChildren( + this.asyncErrors().includes('pending'), + (child, value) => value || child.validationState.asyncErrors().includes('pending'), + ), + ngDevMode ? formDebugObj(node.debugName, 'pending') : undefined, + ); + + this.status = computed( + () => { + // Short-circuit checking children if validation doesn't apply to this field. + if (this.shouldSkipValidation()) { + return 'valid'; + } + let ownStatus = calculateValidationSelfStatus(this); + + return node.structure.reduceChildren<'valid' | 'invalid' | 'unknown'>( + ownStatus, + (child, value) => { + if (value === 'invalid' || child.validationState.status() === 'invalid') { + return 'invalid'; + } else if (value === 'unknown' || child.validationState.status() === 'unknown') { + return 'unknown'; + } + return 'valid'; + }, + (v) => v === 'invalid', // short-circuit on 'invalid' + ); + }, + ngDevMode ? formDebugObj(node.debugName, 'status') : undefined, + ); + + this.valid = computed( + () => this.status() === 'valid', + ngDevMode ? formDebugObj(node.debugName, 'valid') : undefined, + ); + + this.invalid = computed( + () => this.status() === 'invalid', + ngDevMode ? formDebugObj(node.debugName, 'invalid') : undefined, + ); + + this.shouldSkipValidation = computed( + () => node.hidden() || node.disabled() || node.readonly() || node.structure.isOrphaned(), + ngDevMode ? formDebugObj(node.debugName, 'shouldSkipValidation') : undefined, + ); + } } /** Normalizes a validation result to a list of validation errors. */ diff --git a/packages/forms/signals/src/util/debug.ts b/packages/forms/signals/src/util/debug.ts new file mode 100644 index 000000000000..b223f3495a7a --- /dev/null +++ b/packages/forms/signals/src/util/debug.ts @@ -0,0 +1,31 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +/** + * Creates a debug name object for an internal signal specific to a `form`. + */ +export function formDebugObj( + formDebugName: string | undefined, + internalSignalDebugName: string, +): {debugName?: string} { + return { + debugName: `Form${formDebugName ? '#' + formDebugName : ''}.${internalSignalDebugName}`, + }; +} + +/** + * Creates a debug name object for an internal signal specific to a `[formField]`. + */ +export function formFieldDebugObj( + formFieldDebugName: string | undefined, + internalSignalDebugName: string, +): {debugName?: string} { + return { + debugName: `FormField${formFieldDebugName ? '#' + formFieldDebugName : ''}.${internalSignalDebugName}`, + }; +} diff --git a/packages/forms/signals/src/util/deep_signal.ts b/packages/forms/signals/src/util/deep_signal.ts index c55bfa205c8f..65b91810e263 100644 --- a/packages/forms/signals/src/util/deep_signal.ts +++ b/packages/forms/signals/src/util/deep_signal.ts @@ -9,11 +9,13 @@ import {computed, Signal, untracked, WritableSignal} from '@angular/core'; import {SIGNAL} from '@angular/core/primitives/signals'; import {isArray} from './type_guards'; +import {formDebugObj} from './debug'; /** * Creates a writable signal for a specific property on a source writeable signal. * @param source A writeable signal to derive from * @param prop A signal of a property key of the source value + * @param debugName Debug name of the signal * @returns A writeable signal for the given property of the source value. * @template S The source value type * @template K The key type for S @@ -21,9 +23,13 @@ import {isArray} from './type_guards'; export function deepSignal( source: WritableSignal, prop: Signal, + debugName?: string, ): WritableSignal { // Memoize the property. - const read = computed(() => source()[prop()]) as WritableSignal; + const read = computed( + () => source()[prop()], + ngDevMode ? formDebugObj(debugName, '') : undefined, + ) as WritableSignal; read[SIGNAL] = source[SIGNAL]; read.set = (value: S[K]) => { diff --git a/packages/forms/signals/src/util/parser.ts b/packages/forms/signals/src/util/parser.ts index b7120d36b69b..c54c60e982cb 100644 --- a/packages/forms/signals/src/util/parser.ts +++ b/packages/forms/signals/src/util/parser.ts @@ -10,6 +10,7 @@ import {type Signal, linkedSignal} from '@angular/core'; import type {ValidationError} from '../api/rules'; import {normalizeErrors} from '../api/rules/validation/util'; import type {ParseResult} from '../api/transformed_value'; +import {formFieldDebugObj} from './debug'; /** * An object that handles parsing raw UI values into model values. @@ -41,10 +42,12 @@ export function createParser( getValue: () => TValue, setValue: (value: TValue) => void, parse: (raw: TRaw) => ParseResult, + debugFormFieldName?: string, ): Parser { const errors = linkedSignal({ source: getValue, computation: () => [] as readonly ValidationError.WithoutFieldTree[], + ...(ngDevMode ? formFieldDebugObj(debugFormFieldName, 'errors') : undefined), }); const setRawValue = (rawValue: TRaw) => {