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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions goldens/public-api/forms/signals/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -507,6 +507,7 @@ export interface ReadonlyFieldState<TValue, TKey extends string | number = strin
hasMetadata(key: MetadataKey<any, any, any>): boolean;
readonly hidden: Signal<boolean>;
readonly invalid: Signal<boolean>;
readonly isOrphaned: Signal<boolean>;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We might want this to be public.

readonly keyInParent: Signal<TKey>;
readonly max: Signal<number | undefined> | undefined;
readonly maxLength: Signal<number | undefined> | undefined;
Expand Down
13 changes: 11 additions & 2 deletions packages/forms/signals/compat/src/compat_structure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,10 @@

import {
computed,
ɵRuntimeError as RuntimeError,
Signal,
signal,
WritableSignal,
ɵRuntimeError as RuntimeError,
} from '@angular/core';
import {RuntimeErrorCode} from '../../src/errors';
import {FormFieldManager} from '../../src/field/manager';
Expand Down Expand Up @@ -109,6 +109,7 @@ function getControlValueSignal<T>(options: CompatFieldNodeOptions) {
export class CompatStructure extends FieldNodeStructure {
override value: WritableSignal<unknown>;
override keyInParent: Signal<string>;
override isOrphaned: Signal<boolean>;
override root: FieldNode;
override pathKeys: Signal<readonly string[]>;
override readonly children = signal([]);
Expand All @@ -130,7 +131,15 @@ export class CompatStructure extends FieldNodeStructure {

const identityInParent = options.kind === 'child' ? options.identityInParent : undefined;
const initialKeyInParent = options.kind === 'child' ? options.initialKeyInParent : undefined;
this.keyInParent = this.createKeyInParent(options, identityInParent, initialKeyInParent);
const keyState = this.createKeyInParentState(options, identityInParent, initialKeyInParent);
this.keyInParent = computed(() => {
const s = keyState();
if (s.type === 'error') {
throw new RuntimeError(s.error, s.msg);
}
return s.key;
});
this.isOrphaned = computed(() => keyState().type === 'error');

this.pathKeys = computed(() =>
this.parent ? [...this.parent.structure.pathKeys(), this.keyInParent()] : [],
Expand Down
5 changes: 5 additions & 0 deletions packages/forms/signals/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -462,6 +462,11 @@ export interface ReadonlyFieldState<TValue, TKey extends string | number = strin
*/
readonly keyInParent: Signal<TKey>;

/**
* A signal indicating whether the field is orphaned.
*/
readonly isOrphaned: Signal<boolean>;

/**
* The {@link FormField} directives that bind this field to a UI control.
*/
Expand Down
12 changes: 8 additions & 4 deletions packages/forms/signals/src/directive/control_custom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,23 +7,27 @@
*/

import type {ɵControlDirectiveHost as ControlDirectiveHost} from '@angular/core';
import type {FormField} from './form_field';
import {FormUiControl} from '../api/control';
import {
bindingUpdated,
CONTROL_BINDING_NAMES,
type ControlBindingKey,
createBindings,
readFieldStateBindingValue,
} from './bindings';
import type {FormField} from './form_field';
import {setNativeDomProperty} from './native';
import {FormUiControl} from '../api/control';

export function customControlCreate(
host: ControlDirectiveHost,
parent: FormField<unknown>,
): () => void {
host.listenToCustomControlModel((value) => parent.state().controlValue.set(value));
host.listenToCustomControlOutput('touch', () => parent.state().markAsTouched());
host.listenToCustomControlModel((value) => {
if (!parent.state().isOrphaned()) parent.state().controlValue.set(value);
});
host.listenToCustomControlOutput('touch', () => {
if (!parent.state().isOrphaned()) parent.state().markAsTouched();
});

parent.registerAsBinding(host.customControl as FormUiControl);

Expand Down
15 changes: 9 additions & 6 deletions packages/forms/signals/src/directive/control_cva.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,22 +10,22 @@ import {
computed,
signal,
untracked,
type ɵControlDirectiveHost as ControlDirectiveHost,
type Signal,
type WritableSignal,
type ɵControlDirectiveHost as ControlDirectiveHost,
} from '@angular/core';
import {NG_VALIDATORS, type Validator, Validators, type ValidatorFn} from '@angular/forms';
import {reactiveErrorsToSignalErrors} from '../compat/validation_errors';
import {NG_VALIDATORS, Validators, type Validator, type ValidatorFn} from '@angular/forms';
import {type ValidationError} from '../api/rules';
import {reactiveErrorsToSignalErrors} from '../compat/validation_errors';
import {
bindingUpdated,
CONTROL_BINDING_NAMES,
type ControlBindingKey,
createBindings,
readFieldStateBindingValue,
type ControlBindingKey,
} from './bindings';
import {setNativeDomProperty} from './native';
import type {FormField} from './form_field';
import {setNativeDomProperty} from './native';

function isValidatorObject(v: Function | Validator): v is Validator {
return typeof v === 'object' && v !== null;
Expand All @@ -38,13 +38,16 @@ export function cvaControlCreate(
const bindings = createBindings<ControlBindingKey | 'controlValue'>();

parent.controlValueAccessor!.registerOnChange((value: unknown) => {
if (parent.state().isOrphaned()) return;
// Update tracking for 'controlValue' here so that when the effect runs,
// `bindingUpdated` sees that the model value matches the last seen view value.
// This prevents the framework from writing the same value back to the CVA (CVA loopback).
bindings['controlValue'] = value;
parent.state().controlValue.set(value as any);
});
parent.controlValueAccessor!.registerOnTouched(() => parent.state().markAsTouched());
parent.controlValueAccessor!.registerOnTouched(() => {
if (!parent.state().isOrphaned()) parent.state().markAsTouched();
});

const legacyValidators = parent.injector.get(NG_VALIDATORS, null, {optional: true, self: true});
if (legacyValidators) {
Expand Down
10 changes: 7 additions & 3 deletions packages/forms/signals/src/directive/control_native.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ import type {FormField} from './form_field';
import {InputValidityMonitor} from './input_validity_monitor';
import {
getNativeControlValue,
isInput,
inputRequiresValidityTracking,
isInput,
setNativeControlValue,
setNativeDomProperty,
} from './native';
Expand Down Expand Up @@ -54,8 +54,12 @@ export function nativeControlCreate(

parseErrorsSource.set(parser.errors);
// Pass undefined as the raw value since the parse function doesn't care about it.
host.listenToDom('input', () => parser.setRawValue(undefined));
host.listenToDom('blur', () => parent.state().markAsTouched());
host.listenToDom('input', () => {
if (!parent.state().isOrphaned()) parser.setRawValue(undefined);
});
host.listenToDom('blur', () => {
if (!parent.state().isOrphaned()) parent.state().markAsTouched();
});

// TODO: move extraction to first update pass?
if (isInput(input) && inputRequiresValidityTracking(input)) {
Expand Down
4 changes: 4 additions & 0 deletions packages/forms/signals/src/field/node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,10 @@ export class FieldNode implements FieldState<unknown> {
return this.structure.keyInParent;
}

get isOrphaned(): Signal<boolean> {
return this.structure.isOrphaned;
}

get errors(): Signal<ValidationError.WithFieldTree[]> {
return this.validationState.errors;
}
Expand Down
75 changes: 54 additions & 21 deletions packages/forms/signals/src/field/structure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,17 @@ export type ChildNodeCtor = (
isArray: boolean,
) => FieldNode;

export type KeyInParentState =
| {
type: 'error';
error: RuntimeErrorCode;
msg: string | false | null;
}
| {
type: 'key';
key: string;
};

/** Structural component of a `FieldNode` which tracks its path, parent, and children. */
export abstract class FieldNodeStructure {
/**
Expand All @@ -59,6 +70,9 @@ export abstract class FieldNodeStructure {
*/
abstract readonly keyInParent: Signal<string>;

/** Whether this field currently has an associated parent node that considers it a child. */
abstract readonly isOrphaned: Signal<boolean>;

/** The field manager responsible for managing this field. */
abstract readonly fieldManager: FormFieldManager;

Expand Down Expand Up @@ -207,30 +221,32 @@ export abstract class FieldNodeStructure {
* @param initialKeyInParent The initial key in parent (only for child nodes)
* @returns A signal representing the field's key in its parent
*/
protected createKeyInParent(
protected createKeyInParentState(
options: FieldNodeOptions,
identityInParent: TrackingKey | undefined,
initialKeyInParent: string | undefined,
): Signal<string> {
): Signal<KeyInParentState> {
if (options.kind === 'root') {
return ROOT_KEY_IN_PARENT;
return computed(() => ({key: ROOT_KEY_IN_PARENT(), type: 'key'}));
}

if (identityInParent === undefined) {
const key = initialKeyInParent!;
return computed(() => {
return computed<KeyInParentState>(() => {
if (this.parent!.structure.getChild(key) !== this.node) {
throw new RuntimeError(
RuntimeErrorCode.ORPHAN_FIELD_PROPERTY,
ngDevMode &&
return {
type: 'error',
error: RuntimeErrorCode.ORPHAN_FIELD_PROPERTY,
msg:
ngDevMode &&
`Orphan field, looking for property '${key}' of ${getDebugName(this.parent!)}`,
);
};
}
return key;
return {key, type: 'key'};
});
} else {
let lastKnownKey = initialKeyInParent!;
return computed(() => {
return computed<KeyInParentState>(() => {
// TODO(alxhub): future perf optimization: here we depend on the parent's value, but most
// changes to the value aren't structural - they aren't moving around objects and thus
// shouldn't affect `keyInParent`. We currently mitigate this issue via `lastKnownKey`
Expand All @@ -240,10 +256,11 @@ export abstract class FieldNodeStructure {
// It should not be possible to encounter this error. It would require the parent to
// change from an array field to non-array field. However, in the current implementation
// a field's parent can never change.
throw new RuntimeError(
RuntimeErrorCode.ORPHAN_FIELD_ARRAY,
ngDevMode && `Orphan field, expected ${getDebugName(this.parent!)} to be an array`,
);
return {
type: 'error',
error: RuntimeErrorCode.ORPHAN_FIELD_ARRAY,
msg: ngDevMode && `Orphan field, expected ${getDebugName(this.parent!)} to be an array`,
};
}

// Check the parent value at the last known key to avoid a scan.
Expand All @@ -255,7 +272,7 @@ export abstract class FieldNodeStructure {
data.hasOwnProperty(this.parent!.structure.identitySymbol) &&
data[this.parent!.structure.identitySymbol] === identityInParent
) {
return lastKnownKey;
return {key: lastKnownKey, type: 'key'};
}

// Otherwise, we need to check all the keys in the parent.
Expand All @@ -266,14 +283,17 @@ export abstract class FieldNodeStructure {
data.hasOwnProperty(this.parent!.structure.identitySymbol) &&
data[this.parent!.structure.identitySymbol] === identityInParent
) {
return (lastKnownKey = i.toString());
lastKnownKey = i.toString();
return {key: lastKnownKey, type: 'key'};
}
}

throw new RuntimeError(
RuntimeErrorCode.ORPHAN_FIELD_NOT_FOUND,
ngDevMode && `Orphan field, can't find element in array ${getDebugName(this.parent!)}`,
);
return {
type: 'error',
error: RuntimeErrorCode.ORPHAN_FIELD_NOT_FOUND,
msg:
ngDevMode && `Orphan field, can't find element in array ${getDebugName(this.parent!)}`,
};
});
}
}
Expand Down Expand Up @@ -436,6 +456,10 @@ export class RootFieldNodeStructure extends FieldNodeStructure {
return ROOT_KEY_IN_PARENT;
}

override get isOrphaned(): Signal<boolean> {
return computed(() => false);
}

protected override readonly childrenMap: Signal<ChildrenData | undefined>;

/**
Expand Down Expand Up @@ -467,6 +491,7 @@ export class ChildFieldNodeStructure extends FieldNodeStructure {
override readonly root: FieldNode;
override readonly pathKeys: Signal<readonly string[]>;
override readonly keyInParent: Signal<string>;
override readonly isOrphaned: Signal<boolean>;
override readonly value: WritableSignal<unknown>;
override readonly childrenMap: Signal<ChildrenData | undefined>;

Expand Down Expand Up @@ -498,7 +523,7 @@ export class ChildFieldNodeStructure extends FieldNodeStructure {

this.root = this.parent.structure.root;

this.keyInParent = this.createKeyInParent(
const keyState = this.createKeyInParentState(
{
kind: 'child',
parent,
Expand All @@ -511,6 +536,14 @@ export class ChildFieldNodeStructure extends FieldNodeStructure {
identityInParent,
initialKeyInParent,
);
this.keyInParent = computed(() => {
const s = keyState();
if (s.type === 'error') {
throw new RuntimeError(s.error, s.msg || false);
}
return s.key!;
});
this.isOrphaned = computed(() => keyState().type === 'error');

this.pathKeys = computed(() => [...parent.structure.pathKeys(), this.keyInParent()]);

Expand Down
22 changes: 22 additions & 0 deletions packages/forms/signals/test/web/focus.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,28 @@ describe('FieldState focus behavior', () => {
await act(() => fixture.componentInstance.f().focusBoundControl({preventScroll: true}));
expect(receivedOptions).toEqual({preventScroll: true});
});

it('should handle gracefully a removal+blur on the removed element', async () => {
@Component({
selector: 'app-root',
imports: [FormField],
template: `
@for (field of form; track field) {
<input [formField]="field" />
}
`,
})
class App {
form = form(signal(['foo']));
}

const fixture = TestBed.createComponent(App);

await fixture.whenStable();

fixture.componentInstance.form().value.set([]); // remove
document.querySelector('input')!.dispatchEvent(new Event('blur'));
});
});

async function act<T>(fn: () => T): Promise<T> {
Expand Down
Loading