fix(forms): change FieldState optional properties to non-optional | undefined

This improves compatibility with TypeScript's exactOptionalPropertyTypes.

Fixes #67246
This commit is contained in:
Alex Rickabaugh 2026-03-23 13:01:09 -07:00 committed by Leon Senft
parent 37b1c5d0fe
commit ee8d2098cb
8 changed files with 85 additions and 36 deletions

View file

@ -79,10 +79,10 @@ export type CompatSchemaPath<TControl extends AbstractControl, TPathKind extends
};
// @public
export function createManagedMetadataKey<TRead, TWrite>(create: (s: Signal<TWrite | undefined>) => TRead): MetadataKey<TRead, TWrite, TWrite | undefined>;
export function createManagedMetadataKey<TRead, TWrite>(create: (state: FieldState<unknown>, data: Signal<TWrite | undefined>) => TRead): MetadataKey<TRead, TWrite, TWrite | undefined>;
// @public
export function createManagedMetadataKey<TRead, TWrite, TAcc>(create: (s: Signal<TAcc>) => TRead, reducer: MetadataReducer<TAcc, TWrite>): MetadataKey<TRead, TWrite, TAcc>;
export function createManagedMetadataKey<TRead, TWrite, TAcc>(create: (state: FieldState<unknown>, data: Signal<TAcc>) => TRead, reducer: MetadataReducer<TAcc, TWrite>): MetadataKey<TRead, TWrite, TAcc>;
// @public
export function createMetadataKey<TWrite>(): MetadataKey<Signal<TWrite | undefined>, TWrite, TWrite | undefined>;
@ -345,9 +345,9 @@ export function metadata<TValue, TKey extends MetadataKey<any, any, any>, TPathK
// @public
export class MetadataKey<TRead, TWrite, TAcc> {
protected constructor(reducer: MetadataReducer<TAcc, TWrite>, create: ((s: Signal<TAcc>) => TRead) | undefined);
protected constructor(reducer: MetadataReducer<TAcc, TWrite>, create: ((state: FieldState<unknown>, data: Signal<TAcc>) => TRead) | undefined);
// (undocumented)
readonly create: ((s: Signal<TAcc>) => TRead) | undefined;
readonly create: ((state: FieldState<unknown>, data: Signal<TAcc>) => TRead) | undefined;
// (undocumented)
readonly reducer: MetadataReducer<TAcc, TWrite>;
}
@ -509,11 +509,11 @@ export interface ReadonlyFieldState<TValue, TKey extends string | number = strin
readonly hidden: Signal<boolean>;
readonly invalid: Signal<boolean>;
readonly keyInParent: Signal<TKey>;
readonly max?: Signal<number | undefined>;
readonly maxLength?: Signal<number | undefined>;
readonly max: Signal<number | undefined> | undefined;
readonly maxLength: Signal<number | undefined> | undefined;
metadata<M>(key: MetadataKey<M, any, any>): M | undefined;
readonly min?: Signal<number | undefined>;
readonly minLength?: Signal<number | undefined>;
readonly min: Signal<number | undefined> | undefined;
readonly minLength: Signal<number | undefined> | undefined;
readonly name: Signal<string>;
readonly pattern: Signal<readonly RegExp[]>;
readonly pending: Signal<boolean>;

View file

@ -9,7 +9,7 @@
import {type Signal} from '@angular/core';
import {FieldPathNode} from '../../schema/path_node';
import {assertPathIsCurrent} from '../../schema/schema';
import type {LogicFn, PathKind, SchemaPath, SchemaPathRules} from '../types';
import type {FieldState, LogicFn, PathKind, SchemaPath, SchemaPathRules} from '../types';
/**
* Sets a value for the {@link MetadataKey} for this field.
@ -150,7 +150,7 @@ export class MetadataKey<TRead, TWrite, TAcc> {
/** Use {@link reducedMetadataKey}. */
protected constructor(
readonly reducer: MetadataReducer<TAcc, TWrite>,
readonly create: ((s: Signal<TAcc>) => TRead) | undefined,
readonly create: ((state: FieldState<unknown>, data: Signal<TAcc>) => TRead) | undefined,
) {}
}
@ -211,7 +211,7 @@ export function createMetadataKey<TWrite, TAcc>(
* @experimental 21.0.0
*/
export function createManagedMetadataKey<TRead, TWrite>(
create: (s: Signal<TWrite | undefined>) => TRead,
create: (state: FieldState<unknown>, data: Signal<TWrite | undefined>) => TRead,
): MetadataKey<TRead, TWrite, TWrite | undefined>;
/**
* Creates a metadata key that exposes a managed value based on the accumulated result of the values
@ -229,16 +229,16 @@ export function createManagedMetadataKey<TRead, TWrite>(
* @experimental 21.0.0
*/
export function createManagedMetadataKey<TRead, TWrite, TAcc>(
create: (s: Signal<TAcc>) => TRead,
create: (state: FieldState<unknown>, data: Signal<TAcc>) => TRead,
reducer: MetadataReducer<TAcc, TWrite>,
): MetadataKey<TRead, TWrite, TAcc>;
export function createManagedMetadataKey<TRead, TWrite, TAcc>(
create: (s: Signal<TAcc>) => TRead,
create: (state: FieldState<unknown>, data: Signal<TAcc>) => TRead,
reducer?: MetadataReducer<TAcc, TWrite>,
): MetadataKey<TRead, TWrite, TAcc> {
return new (MetadataKey as new (
reducer: MetadataReducer<TAcc, TWrite>,
create: (s: Signal<TAcc>) => TRead,
create: (state: FieldState<unknown>, data: Signal<TAcc>) => TRead,
) => MetadataKey<TRead, TWrite, TAcc>)(reducer ?? MetadataReducer.override<any>(), create);
}

View file

@ -118,7 +118,7 @@ export function validateAsync<TValue, TParams, TResult, TPathKind extends PathKi
const pathNode = FieldPathNode.unwrapFieldPath(path);
const RESOURCE = createManagedMetadataKey<ReturnType<typeof opts.factory>, TParams | undefined>(
opts.factory,
(_state, params) => opts.factory(params),
);
RESOURCE[IS_ASYNC_VALIDATION_RESOURCE] = true;

View file

@ -346,28 +346,28 @@ export interface ReadonlyFieldState<TValue, TKey extends string | number = strin
*
* Applies to `<input>` with a numeric or date `type` attribute and custom controls.
*/
readonly max?: Signal<number | undefined>;
readonly max: Signal<number | undefined> | undefined;
/**
* A signal indicating the field's maximum string length, if applicable.
*
* Applies to `<input>`, `<textarea>`, and custom controls.
*/
readonly maxLength?: Signal<number | undefined>;
readonly maxLength: Signal<number | undefined> | undefined;
/**
* A signal indicating the field's minimum value, if applicable.
*
* Applies to `<input>` with a numeric or date `type` attribute and custom controls.
*/
readonly min?: Signal<number | undefined>;
readonly min: Signal<number | undefined> | undefined;
/**
* A signal indicating the field's minimum string length, if applicable.
*
* Applies to `<input>`, `<textarea>`, and custom controls.
*/
readonly minLength?: Signal<number | undefined>;
readonly minLength: Signal<number | undefined> | undefined;
/**
* A signal of a unique name for the field, by default based on the name of its parent field.

View file

@ -23,20 +23,31 @@ export class FieldMetadataState {
/** A map of all `MetadataKey` that have been defined for this field. */
private readonly metadata = new Map<MetadataKey<unknown, unknown, unknown>, unknown>();
constructor(private readonly node: FieldNode) {
// Force eager creation of managed keys,
// as managed keys have a `create` function that needs to run during construction.
for (const key of this.node.logicNode.logic.getMetadataKeys()) {
if (key.create) {
const logic = this.node.logicNode.logic.getMetadata(key);
const result = untracked(() =>
runInInjectionContext(this.node.structure.injector, () =>
key.create!(computed(() => logic.compute(this.node.context))),
),
);
this.metadata.set(key, result);
}
constructor(private readonly node: FieldNode) {}
/**
* Force eager creation of managed keys,
* as managed keys have a `create` function that needs to run during construction.
*/
runMetadataCreateLifecycle(): void {
if (!this.node.logicNode.logic.hasMetadataKeys()) {
return;
}
untracked(() =>
runInInjectionContext(this.node.structure.injector, () => {
for (const key of this.node.logicNode.logic.getMetadataKeys()) {
if (key.create) {
const logic = this.node.logicNode.logic.getMetadata(key);
const result = key.create!(
this.node,
computed(() => logic.compute(this.node.context)),
);
this.metadata.set(key, result);
}
}
}),
);
}
/** Gets the value of an `MetadataKey` for the field. */

View file

@ -95,6 +95,9 @@ export class FieldNode implements FieldState<unknown> {
this.metadataState = new FieldMetadataState(this);
this.submitState = new FieldSubmitState(this);
this.controlValue = this.controlValueSignal();
// We eagerly create metadata at the end of construction so that the node is fully constructed
// before metadata creation logic runs (which may access other states on the node).
this.metadataState.runMetadataCreateLifecycle();
}
focusBoundControl(options?: FocusOptions): void {

View file

@ -308,6 +308,10 @@ export class LogicContainer {
return this.metadata.has(key);
}
hasMetadataKeys(): boolean {
return this.metadata.size > 0;
}
/**
* Gets an iterable of [metadata key, logic function] pairs.
* @returns An iterable of metadata keys.

View file

@ -7,7 +7,14 @@
*/
import {provideHttpClient} from '@angular/common/http';
import {HttpTestingController, provideHttpClientTesting} from '@angular/common/http/testing';
import {ApplicationRef, Injector, resource, signal, type Signal} from '@angular/core';
import {
ApplicationRef,
assertNotInReactiveContext,
Injector,
resource,
signal,
type Signal,
} from '@angular/core';
import {TestBed} from '@angular/core/testing';
import {isNode} from '@angular/private/testing';
@ -57,7 +64,7 @@ describe('resources', () => {
it('Takes a simple resource which reacts to data changes', async () => {
const s: SchemaOrSchemaFn<Cat> = function (p) {
const RES = createManagedMetadataKey((params: Signal<{x: string} | undefined>) =>
const RES = createManagedMetadataKey((_state, params: Signal<{x: string} | undefined>) =>
resource({
params,
loader: async ({params}) => `got: ${params.x}`,
@ -102,7 +109,7 @@ describe('resources', () => {
it('should create a resource per entry in an array', async () => {
const s: SchemaOrSchemaFn<Cat[]> = function (p) {
applyEach(p, (p) => {
const RES = createManagedMetadataKey((params: Signal<{x: string} | undefined>) =>
const RES = createManagedMetadataKey((_state, params: Signal<{x: string} | undefined>) =>
resource({
params,
loader: async ({params}) => `got: ${params.x}`,
@ -388,7 +395,7 @@ describe('resources', () => {
});
it('should not allow accessing resource metadata on a field that does not define its params', () => {
const RES = createManagedMetadataKey((params: Signal<string | undefined>) =>
const RES = createManagedMetadataKey((_state, params: Signal<string | undefined>) =>
resource({params, loader: async () => 'hi'}),
);
@ -439,4 +446,28 @@ describe('resources', () => {
expect(usernameForm().pending()).toBe(false);
});
});
it('should allow accessing basic field state properties during creation without reading them', () => {
let success = false;
const RES = createManagedMetadataKey((state, _params: Signal<string | undefined>) => {
// We shouldn't be captured in the reactive context of node creation here.
assertNotInReactiveContext(createManagedMetadataKey);
state.value();
state.disabled();
success = true;
});
form(
signal(''),
(p) => {
metadata(p, RES, () => 'trigger');
},
{injector: TestBed.inject(Injector)},
);
expect(success).toBeTrue();
});
});