Skip to content

Commit ee8d209

Browse files
alxhubleonsenft
authored andcommitted
fix(forms): change FieldState optional properties to non-optional | undefined
This improves compatibility with TypeScript's exactOptionalPropertyTypes. Fixes #67246
1 parent 37b1c5d commit ee8d209

File tree

8 files changed

+85
-36
lines changed

8 files changed

+85
-36
lines changed

goldens/public-api/forms/signals/index.api.md

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -79,10 +79,10 @@ export type CompatSchemaPath<TControl extends AbstractControl, TPathKind extends
7979
};
8080

8181
// @public
82-
export function createManagedMetadataKey<TRead, TWrite>(create: (s: Signal<TWrite | undefined>) => TRead): MetadataKey<TRead, TWrite, TWrite | undefined>;
82+
export function createManagedMetadataKey<TRead, TWrite>(create: (state: FieldState<unknown>, data: Signal<TWrite | undefined>) => TRead): MetadataKey<TRead, TWrite, TWrite | undefined>;
8383

8484
// @public
85-
export function createManagedMetadataKey<TRead, TWrite, TAcc>(create: (s: Signal<TAcc>) => TRead, reducer: MetadataReducer<TAcc, TWrite>): MetadataKey<TRead, TWrite, TAcc>;
85+
export function createManagedMetadataKey<TRead, TWrite, TAcc>(create: (state: FieldState<unknown>, data: Signal<TAcc>) => TRead, reducer: MetadataReducer<TAcc, TWrite>): MetadataKey<TRead, TWrite, TAcc>;
8686

8787
// @public
8888
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
345345

346346
// @public
347347
export class MetadataKey<TRead, TWrite, TAcc> {
348-
protected constructor(reducer: MetadataReducer<TAcc, TWrite>, create: ((s: Signal<TAcc>) => TRead) | undefined);
348+
protected constructor(reducer: MetadataReducer<TAcc, TWrite>, create: ((state: FieldState<unknown>, data: Signal<TAcc>) => TRead) | undefined);
349349
// (undocumented)
350-
readonly create: ((s: Signal<TAcc>) => TRead) | undefined;
350+
readonly create: ((state: FieldState<unknown>, data: Signal<TAcc>) => TRead) | undefined;
351351
// (undocumented)
352352
readonly reducer: MetadataReducer<TAcc, TWrite>;
353353
}
@@ -509,11 +509,11 @@ export interface ReadonlyFieldState<TValue, TKey extends string | number = strin
509509
readonly hidden: Signal<boolean>;
510510
readonly invalid: Signal<boolean>;
511511
readonly keyInParent: Signal<TKey>;
512-
readonly max?: Signal<number | undefined>;
513-
readonly maxLength?: Signal<number | undefined>;
512+
readonly max: Signal<number | undefined> | undefined;
513+
readonly maxLength: Signal<number | undefined> | undefined;
514514
metadata<M>(key: MetadataKey<M, any, any>): M | undefined;
515-
readonly min?: Signal<number | undefined>;
516-
readonly minLength?: Signal<number | undefined>;
515+
readonly min: Signal<number | undefined> | undefined;
516+
readonly minLength: Signal<number | undefined> | undefined;
517517
readonly name: Signal<string>;
518518
readonly pattern: Signal<readonly RegExp[]>;
519519
readonly pending: Signal<boolean>;

packages/forms/signals/src/api/rules/metadata.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import {type Signal} from '@angular/core';
1010
import {FieldPathNode} from '../../schema/path_node';
1111
import {assertPathIsCurrent} from '../../schema/schema';
12-
import type {LogicFn, PathKind, SchemaPath, SchemaPathRules} from '../types';
12+
import type {FieldState, LogicFn, PathKind, SchemaPath, SchemaPathRules} from '../types';
1313

1414
/**
1515
* Sets a value for the {@link MetadataKey} for this field.
@@ -150,7 +150,7 @@ export class MetadataKey<TRead, TWrite, TAcc> {
150150
/** Use {@link reducedMetadataKey}. */
151151
protected constructor(
152152
readonly reducer: MetadataReducer<TAcc, TWrite>,
153-
readonly create: ((s: Signal<TAcc>) => TRead) | undefined,
153+
readonly create: ((state: FieldState<unknown>, data: Signal<TAcc>) => TRead) | undefined,
154154
) {}
155155
}
156156

@@ -211,7 +211,7 @@ export function createMetadataKey<TWrite, TAcc>(
211211
* @experimental 21.0.0
212212
*/
213213
export function createManagedMetadataKey<TRead, TWrite>(
214-
create: (s: Signal<TWrite | undefined>) => TRead,
214+
create: (state: FieldState<unknown>, data: Signal<TWrite | undefined>) => TRead,
215215
): MetadataKey<TRead, TWrite, TWrite | undefined>;
216216
/**
217217
* 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>(
229229
* @experimental 21.0.0
230230
*/
231231
export function createManagedMetadataKey<TRead, TWrite, TAcc>(
232-
create: (s: Signal<TAcc>) => TRead,
232+
create: (state: FieldState<unknown>, data: Signal<TAcc>) => TRead,
233233
reducer: MetadataReducer<TAcc, TWrite>,
234234
): MetadataKey<TRead, TWrite, TAcc>;
235235
export function createManagedMetadataKey<TRead, TWrite, TAcc>(
236-
create: (s: Signal<TAcc>) => TRead,
236+
create: (state: FieldState<unknown>, data: Signal<TAcc>) => TRead,
237237
reducer?: MetadataReducer<TAcc, TWrite>,
238238
): MetadataKey<TRead, TWrite, TAcc> {
239239
return new (MetadataKey as new (
240240
reducer: MetadataReducer<TAcc, TWrite>,
241-
create: (s: Signal<TAcc>) => TRead,
241+
create: (state: FieldState<unknown>, data: Signal<TAcc>) => TRead,
242242
) => MetadataKey<TRead, TWrite, TAcc>)(reducer ?? MetadataReducer.override<any>(), create);
243243
}
244244

packages/forms/signals/src/api/rules/validation/validate_async.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ export function validateAsync<TValue, TParams, TResult, TPathKind extends PathKi
118118
const pathNode = FieldPathNode.unwrapFieldPath(path);
119119

120120
const RESOURCE = createManagedMetadataKey<ReturnType<typeof opts.factory>, TParams | undefined>(
121-
opts.factory,
121+
(_state, params) => opts.factory(params),
122122
);
123123
RESOURCE[IS_ASYNC_VALIDATION_RESOURCE] = true;
124124

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -346,28 +346,28 @@ export interface ReadonlyFieldState<TValue, TKey extends string | number = strin
346346
*
347347
* Applies to `<input>` with a numeric or date `type` attribute and custom controls.
348348
*/
349-
readonly max?: Signal<number | undefined>;
349+
readonly max: Signal<number | undefined> | undefined;
350350

351351
/**
352352
* A signal indicating the field's maximum string length, if applicable.
353353
*
354354
* Applies to `<input>`, `<textarea>`, and custom controls.
355355
*/
356-
readonly maxLength?: Signal<number | undefined>;
356+
readonly maxLength: Signal<number | undefined> | undefined;
357357

358358
/**
359359
* A signal indicating the field's minimum value, if applicable.
360360
*
361361
* Applies to `<input>` with a numeric or date `type` attribute and custom controls.
362362
*/
363-
readonly min?: Signal<number | undefined>;
363+
readonly min: Signal<number | undefined> | undefined;
364364

365365
/**
366366
* A signal indicating the field's minimum string length, if applicable.
367367
*
368368
* Applies to `<input>`, `<textarea>`, and custom controls.
369369
*/
370-
readonly minLength?: Signal<number | undefined>;
370+
readonly minLength: Signal<number | undefined> | undefined;
371371

372372
/**
373373
* A signal of a unique name for the field, by default based on the name of its parent field.

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

Lines changed: 24 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -23,20 +23,31 @@ export class FieldMetadataState {
2323
/** A map of all `MetadataKey` that have been defined for this field. */
2424
private readonly metadata = new Map<MetadataKey<unknown, unknown, unknown>, unknown>();
2525

26-
constructor(private readonly node: FieldNode) {
27-
// Force eager creation of managed keys,
28-
// as managed keys have a `create` function that needs to run during construction.
29-
for (const key of this.node.logicNode.logic.getMetadataKeys()) {
30-
if (key.create) {
31-
const logic = this.node.logicNode.logic.getMetadata(key);
32-
const result = untracked(() =>
33-
runInInjectionContext(this.node.structure.injector, () =>
34-
key.create!(computed(() => logic.compute(this.node.context))),
35-
),
36-
);
37-
this.metadata.set(key, result);
38-
}
26+
constructor(private readonly node: FieldNode) {}
27+
28+
/**
29+
* Force eager creation of managed keys,
30+
* as managed keys have a `create` function that needs to run during construction.
31+
*/
32+
runMetadataCreateLifecycle(): void {
33+
if (!this.node.logicNode.logic.hasMetadataKeys()) {
34+
return;
3935
}
36+
37+
untracked(() =>
38+
runInInjectionContext(this.node.structure.injector, () => {
39+
for (const key of this.node.logicNode.logic.getMetadataKeys()) {
40+
if (key.create) {
41+
const logic = this.node.logicNode.logic.getMetadata(key);
42+
const result = key.create!(
43+
this.node,
44+
computed(() => logic.compute(this.node.context)),
45+
);
46+
this.metadata.set(key, result);
47+
}
48+
}
49+
}),
50+
);
4051
}
4152

4253
/** Gets the value of an `MetadataKey` for the field. */

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,9 @@ export class FieldNode implements FieldState<unknown> {
9595
this.metadataState = new FieldMetadataState(this);
9696
this.submitState = new FieldSubmitState(this);
9797
this.controlValue = this.controlValueSignal();
98+
// We eagerly create metadata at the end of construction so that the node is fully constructed
99+
// before metadata creation logic runs (which may access other states on the node).
100+
this.metadataState.runMetadataCreateLifecycle();
98101
}
99102

100103
focusBoundControl(options?: FocusOptions): void {

packages/forms/signals/src/schema/logic.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,10 @@ export class LogicContainer {
308308
return this.metadata.has(key);
309309
}
310310

311+
hasMetadataKeys(): boolean {
312+
return this.metadata.size > 0;
313+
}
314+
311315
/**
312316
* Gets an iterable of [metadata key, logic function] pairs.
313317
* @returns An iterable of metadata keys.

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

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,14 @@
77
*/
88
import {provideHttpClient} from '@angular/common/http';
99
import {HttpTestingController, provideHttpClientTesting} from '@angular/common/http/testing';
10-
import {ApplicationRef, Injector, resource, signal, type Signal} from '@angular/core';
10+
import {
11+
ApplicationRef,
12+
assertNotInReactiveContext,
13+
Injector,
14+
resource,
15+
signal,
16+
type Signal,
17+
} from '@angular/core';
1118
import {TestBed} from '@angular/core/testing';
1219
import {isNode} from '@angular/private/testing';
1320

@@ -57,7 +64,7 @@ describe('resources', () => {
5764

5865
it('Takes a simple resource which reacts to data changes', async () => {
5966
const s: SchemaOrSchemaFn<Cat> = function (p) {
60-
const RES = createManagedMetadataKey((params: Signal<{x: string} | undefined>) =>
67+
const RES = createManagedMetadataKey((_state, params: Signal<{x: string} | undefined>) =>
6168
resource({
6269
params,
6370
loader: async ({params}) => `got: ${params.x}`,
@@ -102,7 +109,7 @@ describe('resources', () => {
102109
it('should create a resource per entry in an array', async () => {
103110
const s: SchemaOrSchemaFn<Cat[]> = function (p) {
104111
applyEach(p, (p) => {
105-
const RES = createManagedMetadataKey((params: Signal<{x: string} | undefined>) =>
112+
const RES = createManagedMetadataKey((_state, params: Signal<{x: string} | undefined>) =>
106113
resource({
107114
params,
108115
loader: async ({params}) => `got: ${params.x}`,
@@ -388,7 +395,7 @@ describe('resources', () => {
388395
});
389396

390397
it('should not allow accessing resource metadata on a field that does not define its params', () => {
391-
const RES = createManagedMetadataKey((params: Signal<string | undefined>) =>
398+
const RES = createManagedMetadataKey((_state, params: Signal<string | undefined>) =>
392399
resource({params, loader: async () => 'hi'}),
393400
);
394401

@@ -439,4 +446,28 @@ describe('resources', () => {
439446
expect(usernameForm().pending()).toBe(false);
440447
});
441448
});
449+
450+
it('should allow accessing basic field state properties during creation without reading them', () => {
451+
let success = false;
452+
453+
const RES = createManagedMetadataKey((state, _params: Signal<string | undefined>) => {
454+
// We shouldn't be captured in the reactive context of node creation here.
455+
assertNotInReactiveContext(createManagedMetadataKey);
456+
457+
state.value();
458+
state.disabled();
459+
460+
success = true;
461+
});
462+
463+
form(
464+
signal(''),
465+
(p) => {
466+
metadata(p, RES, () => 'trigger');
467+
},
468+
{injector: TestBed.inject(Injector)},
469+
);
470+
471+
expect(success).toBeTrue();
472+
});
442473
});

0 commit comments

Comments
 (0)