Skip to content

Commit effccff

Browse files
leonsenftmmalerba
authored andcommitted
refactor(core): add framework support for binding form controls (angular#63773)
Move most of the implementation of the `Control` directive into core framework instructions. This allows field state changes to be propagated to their corresponding UI controls directly during execution of a template update block, instead of relying on `effect()`s to synchronize each change later during the update (and too late in the case of required inputs). * Define a private API in `@angular/core` for signal forms to implement: * `ɵControl` for the `Control` directive. * `ɵFieldState` for the control's associated `FieldState`. * Emit specialized instructions when compiling a `[control]` binding: * `ɵɵcontrolCreate` sets up the `ɵControl` directive if present, determines whether it's bound to a native control element or a custom control component, and adds the appropriate event listeners to notify the `ɵFieldState` of UI changes. * `ɵɵcontrol` propagates changes from `ɵFieldState` properties to their corresponding UI control properties (in additional to binding the `control` property itself). PR Close angular#63773
1 parent 42e1062 commit effccff

File tree

23 files changed

+761
-623
lines changed

23 files changed

+761
-623
lines changed

goldens/public-api/core/errors.api.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,8 @@ export const enum RuntimeErrorCode {
7676
// (undocumented)
7777
INVALID_BINDING_TARGET = 316,
7878
// (undocumented)
79+
INVALID_CONTROL_HOST = 318,
80+
// (undocumented)
7981
INVALID_DIFFER_INPUT = 900,
8082
// (undocumented)
8183
INVALID_EVENT_BINDING = 306,

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

Lines changed: 14 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -4,22 +4,22 @@
44
55
```ts
66

7-
import { ControlValueAccessor } from '@angular/forms';
87
import { DestroyableInjector } from '@angular/core';
9-
import { ElementRef } from '@angular/core';
108
import { HttpResourceOptions } from '@angular/common/http';
119
import { HttpResourceRequest } from '@angular/common/http';
1210
import * as i0 from '@angular/core';
1311
import { InjectionToken } from '@angular/core';
1412
import { Injector } from '@angular/core';
1513
import { InputSignal } from '@angular/core';
1614
import { ModelSignal } from '@angular/core';
17-
import { NgControl } from '@angular/forms';
1815
import { OutputRef } from '@angular/core';
1916
import { ResourceRef } from '@angular/core';
2017
import { Signal } from '@angular/core';
2118
import { StandardSchemaV1 } from '@standard-schema/spec';
2219
import { WritableSignal } from '@angular/core';
20+
import { ɵCONTROL } from '@angular/core';
21+
import { ɵControl } from '@angular/core';
22+
import { ɵFieldState } from '@angular/core';
2323

2424
// @public
2525
export class AggregateProperty<TAcc, TItem> {
@@ -69,17 +69,17 @@ export interface ChildFieldContext<TValue> extends RootFieldContext<TValue> {
6969
export const CONTROL: InjectionToken<Control<unknown>>;
7070

7171
// @public
72-
export class Control<T> {
73-
get cva(): ControlValueAccessor | undefined;
74-
readonly cvaArray: ControlValueAccessor[] | null;
75-
readonly el: ElementRef<HTMLElement>;
76-
readonly field: i0.WritableSignal<FieldTree<T>>;
72+
export class Control<T> implements ɵControl<T> {
73+
// (undocumented)
74+
readonlyCONTROL]: undefined;
75+
// (undocumented)
76+
readonly field: i0.InputSignal<FieldTree<T>>;
77+
// (undocumented)
78+
register(): void;
7779
// (undocumented)
78-
set _field(value: FieldTree<T>);
79-
get ngControl(): NgControl;
8080
readonly state: i0.Signal<FieldState<T, string | number>>;
8181
// (undocumented)
82-
static ɵdir: i0.ɵɵDirectiveDeclaration<Control<any>, "[control]", never, { "_field": { "alias": "control"; "required": true; }; }, {}, never, never, true, never>;
82+
static ɵdir: i0.ɵɵDirectiveDeclaration<Control<any>, "[control]", never, { "field": { "alias": "control"; "required": true; "isSignal": true; }; }, {}, never, never, true, never>;
8383
// (undocumented)
8484
static ɵfac: i0.ɵɵFactoryDeclaration<Control<any>, never>;
8585
}
@@ -137,29 +137,24 @@ export type FieldPath<TValue, TPathKind extends PathKind = PathKind.Root> = {
137137
} : unknown);
138138

139139
// @public
140-
export interface FieldState<TValue, TKey extends string | number = string | number> {
140+
export interface FieldState<TValue, TKey extends string | number = string | number> extends ɵFieldState<TValue> {
141141
readonly controls: Signal<readonly Control<unknown>[]>;
142142
readonly dirty: Signal<boolean>;
143-
readonly disabled: Signal<boolean>;
143+
// (undocumented)
144144
readonly disabledReasons: Signal<readonly DisabledReason[]>;
145+
// (undocumented)
145146
readonly errors: Signal<ValidationError[]>;
146147
readonly errorSummary: Signal<ValidationError[]>;
147148
hasProperty(key: Property<any> | AggregateProperty<any, any>): boolean;
148149
readonly hidden: Signal<boolean>;
149150
readonly invalid: Signal<boolean>;
150151
readonly keyInParent: Signal<TKey>;
151-
markAsDirty(): void;
152-
markAsTouched(): void;
153-
readonly name: Signal<string>;
154152
readonly pending: Signal<boolean>;
155153
property<M>(prop: AggregateProperty<M, any>): Signal<M>;
156154
property<M>(prop: Property<M>): M | undefined;
157-
readonly readonly: Signal<boolean>;
158155
reset(): void;
159156
readonly submitting: Signal<boolean>;
160-
readonly touched: Signal<boolean>;
161157
readonly valid: Signal<boolean>;
162-
readonly value: WritableSignal<TValue>;
163158
}
164159

165160
// @public

packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_bindings/control_bindings/GOLDEN_PARTIAL.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,16 @@ export class MyComponent {
2121
}
2222
MyComponent.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
2323
MyComponent.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "0.0.0-PLACEHOLDER", type: MyComponent, isStandalone: true, selector: "ng-component", ngImport: i0, template: `
24+
<div control="Not a form control"></div>
25+
<div [attr.control]="value">Not a form control either.</div>
2426
<input [control]="value">
2527
`, isInline: true, dependencies: [{ kind: "directive", type: Control, selector: "[control]", inputs: ["control"] }] });
2628
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyComponent, decorators: [{
2729
type: Component,
2830
args: [{
2931
template: `
32+
<div control="Not a form control"></div>
33+
<div [attr.control]="value">Not a form control either.</div>
3034
<input [control]="value">
3135
`,
3236
imports: [Control],

packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_bindings/control_bindings/control_bindings.js

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,23 @@
11
MyComponent.ɵcmp = /* @__PURE__ */i0.ɵɵdefineComponent({
22
type: MyComponent,
33
selectors: [["ng-component"]],
4-
decls: 1,
5-
vars: 1,
6-
consts: [[3, "control"]],
4+
decls: 4,
5+
vars: 2,
6+
consts: [["control", "Not a form control"], [3, "control"]],
77
template: function MyComponent_Template(rf, ctx) {
88
if (rf & 1) {
9-
i0.ɵɵelement(0, "input", 0);
9+
i0.ɵɵelement(0, "div", 0);
10+
i0.ɵɵelementStart(1, "div");
11+
i0.ɵɵtext(2, "Not a form control either.");
12+
i0.ɵɵelementEnd();
13+
i0.ɵɵelementStart(3, "input", 1);
14+
i0.ɵɵcontrolCreate();
15+
i0.ɵɵelementEnd();
1016
}
1117
if (rf & 2) {
18+
i0.ɵɵadvance();
19+
i0.ɵɵattribute("control", ctx.value);
20+
i0.ɵɵadvance(2);
1221
i0.ɵɵcontrol(ctx.value);
1322
}
1423
},

packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_bindings/control_bindings/control_bindings.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ export class Control {
77

88
@Component({
99
template: `
10+
<div control="Not a form control"></div>
11+
<div [attr.control]="value">Not a form control either.</div>
1012
<input [control]="value">
1113
`,
1214
imports: [Control],

packages/compiler/src/render3/r3_identifiers.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,7 +254,9 @@ export class Identifiers {
254254

255255
static ariaProperty: o.ExternalReference = {name: 'ɵɵariaProperty', moduleName: CORE};
256256
static property: o.ExternalReference = {name: 'ɵɵproperty', moduleName: CORE};
257+
257258
static control: o.ExternalReference = {name: 'ɵɵcontrol', moduleName: CORE};
259+
static controlCreate: o.ExternalReference = {name: 'ɵɵcontrolCreate', moduleName: CORE};
258260

259261
static animationEnterListener: o.ExternalReference = {
260262
name: 'ɵɵanimateEnterListener',

packages/compiler/src/template/pipeline/ir/src/enums.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -301,9 +301,17 @@ export enum OpKind {
301301
AnimationListener,
302302

303303
/**
304-
* An operation to bind a signal form field to a control directive.
304+
* An operation to bind an expression to a `control` property of an element.
305305
*/
306306
Control,
307+
308+
/**
309+
* An operation to set up a corresponding {@link Control} operation.
310+
*
311+
* This is responsible for setting up event listeners on a native or custom form control when
312+
* bound to a specialized control directive.
313+
*/
314+
ControlCreate,
307315
}
308316

309317
/**

packages/compiler/src/template/pipeline/ir/src/expression.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1234,6 +1234,7 @@ export function transformExpressionsInOp(
12341234
case OpKind.SourceLocation:
12351235
case OpKind.ConditionalCreate:
12361236
case OpKind.ConditionalBranchCreate:
1237+
case OpKind.ControlCreate:
12371238
// These operations contain no expressions.
12381239
break;
12391240
default:

packages/compiler/src/template/pipeline/ir/src/ops/create.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ import {
3333

3434
import {ListEndOp, NEW_OP, StatementOp, VariableOp} from './shared';
3535

36-
import type {Interpolation, UpdateOp} from './update';
36+
import type {BindingOp, Interpolation, UpdateOp} from './update';
3737

3838
/**
3939
* An operation usable on the creation side of the IR.
@@ -77,7 +77,8 @@ export type CreateOp =
7777
| AnimationListenerOp
7878
| AnimationStringOp
7979
| AnimationOp
80-
| SourceLocationOp;
80+
| SourceLocationOp
81+
| ControlCreateOp;
8182

8283
/**
8384
* An operation representing the creation of an element or container.
@@ -1950,6 +1951,21 @@ export function createSourceLocationOp(
19501951
};
19511952
}
19521953

1954+
/**
1955+
* An operation that determines whether a `[control]` binding targets a specialized control
1956+
* directive on a native or custom form control, and if so, adds event listeners to synchronize the
1957+
* bound form field to the form control.
1958+
*/
1959+
export interface ControlCreateOp extends Op<CreateOp> {
1960+
kind: OpKind.ControlCreate;
1961+
sourceSpan: ParseSourceSpan;
1962+
}
1963+
1964+
/** Creates a {@link ControlCreateOp}. */
1965+
export function createControlCreateOp(sourceSpan: ParseSourceSpan): ControlCreateOp {
1966+
return {kind: OpKind.ControlCreate, sourceSpan, ...NEW_OP};
1967+
}
1968+
19531969
/**
19541970
* An index into the `consts` array which is shared across the compilation of all views in a
19551971
* component.

packages/compiler/src/template/pipeline/src/ingest.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1344,6 +1344,12 @@ function ingestElementBindings(
13441344
input.sourceSpan,
13451345
),
13461346
);
1347+
1348+
// If the input name is 'control', this could be a form control binding which requires a
1349+
// `ControlCreateOp` to properly initialize.
1350+
if (input.type === e.BindingType.Property && input.name === 'control') {
1351+
unit.create.push(ir.createControlCreateOp(input.sourceSpan));
1352+
}
13471353
}
13481354

13491355
unit.create.push(

0 commit comments

Comments
 (0)