From a67e00741c23d7ae4c93970c026bd7f9d41521ee Mon Sep 17 00:00:00 2001 From: Alex Rickabaugh Date: Wed, 14 Jan 2026 16:27:40 -0800 Subject: [PATCH] refactor(forms): move control logic into `FormField` directive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refactors the `ɵɵcontrolCreate` and `ɵɵcontrol` instructions to delegate control logic to the forms package via new `ɵngControlCreate` and `ɵngControlUpdate` lifecycle hooks. Previously, the logic for binding form state to native elements and custom controls was hardcoded within `@angular/core`. **Compiler Changes:** - Introduces a new compilation phase `specializeControlProperties` (in `control_directives.ts`). - This phase detects properties named `formField` and specializes them into `ControlCreate` and `Control` IR opcodes. - These opcodes emit `ɵɵcontrolCreate` and `ɵɵcontrol` instructions, respectively. **Runtime Changes:** - `ɵɵcontrolCreate` acts as the creation phase. It locates the control directive and invokes its `ɵngControlCreate` method. - `ɵɵcontrol` acts as the update phase, and invokes the control directive's `ɵngControlUpdate` method (if present). - Introduces a `passThroughInput` configuration in `ControlFeature`. This specifies the input name (e.g., `formField`) that triggers the control. If the runtime detects that this input is bound to multiple targets (e.g., the `FormField` directive *and* the host component), the control is flagged as "pass-through". In this state, `ɵngControlCreate` returns a no-op update function, deferring responsibility to the other consumer (e.g., the component managing the field itself). **Forms Changes:** - `FormField` directive implements `ɵngControlCreate` and `ɵngControlUpdate`. - Inside this hook, `FormField` determines the type of control it is attached to (Native, CVA, or Custom Signal Control) and delegates to the appropriate handler (`nativeControlCreate`, `cvaControlCreate`, or `customControlCreate`). - Consolidates all form binding logic within `@angular/forms/signals`, enabling support for new `FormValueControl` and `FormCheckboxControl` interfaces. - Reorganizes the codebase by moving `FormField` from `api/` to `directive/` and splitting the binding logic into semantic pieces: - `control_native.ts`, `control_cva.ts`, and `control_custom.ts` contain the specific handlers for each control type. - `native.ts` and `select.ts` provide helpers for native element discovery and select-specific synchronization. - `bindings.ts` manages the tracking and application of property/attribute bindings. --- goldens/public-api/core/errors.api.md | 2 - .../forms/signals/compat/index.api.md | 5 - goldens/public-api/forms/signals/index.api.md | 40 +- .../partial_directive_linker_1.ts | 12 + .../ngtsc/annotations/directive/src/shared.ts | 61 + .../ngtsc/typecheck/src/ops/signal_forms.ts | 2 +- .../control_bindings/control_bindings.js | 7 +- .../control_bindings/radio_bindings.js | 12 +- .../compiler/src/compiler_facade_interface.ts | 2 + packages/compiler/src/jit_compiler_facade.ts | 1 + packages/compiler/src/render3/partial/api.ts | 4 + .../compiler/src/render3/partial/directive.ts | 13 + .../compiler/src/render3/r3_identifiers.ts | 5 + packages/compiler/src/render3/view/api.ts | 10 +- .../compiler/src/render3/view/compiler.ts | 5 + .../template/pipeline/ir/src/expression.ts | 2 +- .../template/pipeline/ir/src/ops/create.ts | 6 +- .../template/pipeline/ir/src/ops/update.ts | 32 +- .../src/template/pipeline/src/emit.ts | 2 + .../src/template/pipeline/src/ingest.ts | 10 - .../src/template/pipeline/src/instruction.ts | 21 +- .../src/phases/attribute_extraction.ts | 16 - .../src/phases/binding_specialization.ts | 2 - .../pipeline/src/phases/control_directives.ts | 71 + .../src/template/pipeline/src/phases/reify.ts | 2 +- .../src/compiler/compiler_facade_interface.ts | 4 + .../core/src/core_render3_private_export.ts | 10 +- packages/core/src/errors.ts | 1 - packages/core/src/render3/definition.ts | 1 + packages/core/src/render3/dynamic_bindings.ts | 44 +- .../src/render3/features/control_feature.ts | 24 + packages/core/src/render3/index.ts | 5 +- .../core/src/render3/instructions/control.ts | 1171 +++-------------- .../render3/instructions/let_declaration.ts | 2 +- .../core/src/render3/interfaces/control.ts | 245 +--- .../core/src/render3/interfaces/definition.ts | 16 +- packages/core/src/render3/interfaces/node.ts | 31 +- packages/core/src/render3/jit/directive.ts | 1 + packages/core/src/render3/jit/environment.ts | 1 + .../core/src/render3/tnode_manipulation.ts | 2 +- packages/core/src/render3/view/listeners.ts | 6 +- .../bundle.golden_symbols.json | 24 +- packages/core/test/render3/is_shape_of.ts | 2 +- packages/forms/signals/public_api.ts | 2 +- packages/forms/signals/src/api/control.ts | 15 +- packages/forms/signals/src/api/di.ts | 2 +- .../signals/src/api/form_field_directive.ts | 203 --- .../api/rules/validation/validation_errors.ts | 2 +- packages/forms/signals/src/api/types.ts | 101 +- .../forms/signals/src/directive/bindings.ts | 84 ++ .../signals/src/directive/control_custom.ts | 63 + .../signals/src/directive/control_cva.ts | 58 + .../signals/src/directive/control_native.ts | 80 ++ .../src/directive/form_field_directive.ts | 344 +++++ .../forms/signals/src/directive/native.ts | 191 +++ .../forms/signals/src/directive/select.ts | 80 ++ packages/forms/signals/src/errors.ts | 1 + packages/forms/signals/src/field/node.ts | 2 +- packages/forms/signals/src/field/state.ts | 2 +- 59 files changed, 1547 insertions(+), 1618 deletions(-) create mode 100644 packages/compiler/src/template/pipeline/src/phases/control_directives.ts create mode 100644 packages/core/src/render3/features/control_feature.ts delete mode 100644 packages/forms/signals/src/api/form_field_directive.ts create mode 100644 packages/forms/signals/src/directive/bindings.ts create mode 100644 packages/forms/signals/src/directive/control_custom.ts create mode 100644 packages/forms/signals/src/directive/control_cva.ts create mode 100644 packages/forms/signals/src/directive/control_native.ts create mode 100644 packages/forms/signals/src/directive/form_field_directive.ts create mode 100644 packages/forms/signals/src/directive/native.ts create mode 100644 packages/forms/signals/src/directive/select.ts diff --git a/goldens/public-api/core/errors.api.md b/goldens/public-api/core/errors.api.md index 2471ef65509..4b59e70feea 100644 --- a/goldens/public-api/core/errors.api.md +++ b/goldens/public-api/core/errors.api.md @@ -86,8 +86,6 @@ export const enum RuntimeErrorCode { // (undocumented) INVALID_FACTORY_DEPENDENCY = 202, // (undocumented) - INVALID_FIELD_DIRECTIVE_HOST = 318, - // (undocumented) INVALID_I18N_STRUCTURE = 700, // (undocumented) INVALID_INHERITANCE = 903, diff --git a/goldens/public-api/forms/signals/compat/index.api.md b/goldens/public-api/forms/signals/compat/index.api.md index cb1c8bf349f..52c81d049b8 100644 --- a/goldens/public-api/forms/signals/compat/index.api.md +++ b/goldens/public-api/forms/signals/compat/index.api.md @@ -16,11 +16,6 @@ import { Signal } from '@angular/core'; import { ValidationErrors } from '@angular/forms'; import { ValidatorFn } from '@angular/forms'; import { WritableSignal } from '@angular/core'; -import { ɵCONTROL } from '@angular/core'; -import { ɵcontrolUpdate } from '@angular/core'; -import { ɵFieldState } from '@angular/core'; -import { ɵFormFieldBindingOptions } from '@angular/core'; -import { ɵɵcontrolCreate } from '@angular/core'; // @public export function compatForm(model: WritableSignal): FieldTree; diff --git a/goldens/public-api/forms/signals/index.api.md b/goldens/public-api/forms/signals/index.api.md index 8a7c9c5f46d..e660483e119 100644 --- a/goldens/public-api/forms/signals/index.api.md +++ b/goldens/public-api/forms/signals/index.api.md @@ -26,11 +26,6 @@ import { StandardSchemaV1 } from '@standard-schema/spec'; import { ValidationErrors } from '@angular/forms'; import { ValidatorFn } from '@angular/forms'; import { WritableSignal } from '@angular/core'; -import { ɵCONTROL } from '@angular/core'; -import { ɵcontrolUpdate } from '@angular/core'; -import { ɵFieldState } from '@angular/core'; -import { ɵFormFieldBindingOptions } from '@angular/core'; -import { ɵɵcontrolCreate } from '@angular/core'; // @public export function apply(path: SchemaPath, schema: NoInfer>): void; @@ -132,8 +127,10 @@ export class EmailValidationError extends BaseNgValidationError { export type FieldContext = TPathKind extends PathKind.Item ? ItemFieldContext : TPathKind extends PathKind.Child ? ChildFieldContext : RootFieldContext; // @public -export interface FieldState extends ɵFieldState { +export interface FieldState { + readonly controlValue: Signal; readonly dirty: Signal; + readonly disabled: Signal; // (undocumented) readonly disabledReasons: Signal; // (undocumented) @@ -144,11 +141,24 @@ export interface FieldState; readonly invalid: Signal; readonly keyInParent: Signal; + markAsDirty(): void; + markAsTouched(): void; + readonly max?: Signal; + readonly maxLength?: Signal; metadata(key: MetadataKey): M | undefined; + readonly min?: Signal; + readonly minLength?: Signal; + readonly name: Signal; + readonly pattern: Signal; readonly pending: Signal; + readonly readonly: Signal; + readonly required: Signal; reset(value?: TValue): void; + setControlValue(value: TValue): void; readonly submitting: Signal; + readonly touched: Signal; readonly valid: Signal; + readonly value: WritableSignal; } // @public @@ -177,22 +187,15 @@ export interface FormCheckboxControl extends FormUiControl { // @public export class FormField { - // (undocumented) - readonly [ɵCONTROL]: { - readonly create: typeof ɵɵcontrolCreate; - readonly update: typeof ɵcontrolUpdate; - }; - // (undocumented) + readonly [ɵNgFieldDirective]: true; readonly element: HTMLElement; readonly errors: Signal; // (undocumented) readonly fieldTree: i0.InputSignal>; focus(options?: FocusOptions): void; - protected getOrCreateNgControl(): InteropNgControl; - // (undocumented) readonly injector: Injector; - registerAsBinding(bindingOptions?: FormFieldBindingOptions): void; - // (undocumented) + protected get interopNgControl(): InteropNgControl; + registerAsBinding(bindingOptions?: FormFieldBindingOptions): void; readonly state: Signal<[T] extends [_angular_forms.AbstractControl] ? CompatFieldState : FieldState>; // (undocumented) static ɵdir: i0.ɵɵDirectiveDeclaration, "[formField]", ["formField"], { "fieldTree": { "alias": "formField"; "required": true; "isSignal": true; }; }, {}, never, never, true, never>; @@ -201,9 +204,8 @@ export class FormField { } // @public (undocumented) -export interface FormFieldBindingOptions extends ɵFormFieldBindingOptions { - focus?(options?: FocusOptions): void; - // (undocumented) +export interface FormFieldBindingOptions { + readonly focus?: (focusOptions?: FocusOptions) => void; readonly parseErrors?: Signal; } diff --git a/packages/compiler-cli/linker/src/file_linker/partial_linkers/partial_directive_linker_1.ts b/packages/compiler-cli/linker/src/file_linker/partial_linkers/partial_directive_linker_1.ts index 9a5080b0ede..e8316b21304 100644 --- a/packages/compiler-cli/linker/src/file_linker/partial_linkers/partial_directive_linker_1.ts +++ b/packages/compiler-cli/linker/src/file_linker/partial_linkers/partial_directive_linker_1.ts @@ -95,6 +95,9 @@ export function toR3DirectiveMeta( lifecycle: { usesOnChanges: metaObj.has('usesOnChanges') ? metaObj.getBoolean('usesOnChanges') : false, }, + controlCreate: metaObj.has('controlCreate') + ? toControlCreate(metaObj.getObject('controlCreate')) + : null, name: typeName, usesInheritance: metaObj.has('usesInheritance') ? metaObj.getBoolean('usesInheritance') : false, isStandalone: metaObj.has('isStandalone') @@ -133,6 +136,15 @@ function toInputMapping( ); } +function toControlCreate( + controlCreate: AstObject, TExpression>, +) { + const passThroughValue = controlCreate.getValue('passThroughInput'); + return { + passThroughInput: passThroughValue.isNull() ? null : passThroughValue.getString(), + }; +} + /** * Parses the legacy partial output for inputs. * diff --git a/packages/compiler-cli/src/ngtsc/annotations/directive/src/shared.ts b/packages/compiler-cli/src/ngtsc/annotations/directive/src/shared.ts index 093f9991e6f..0976853bfe1 100644 --- a/packages/compiler-cli/src/ngtsc/annotations/directive/src/shared.ts +++ b/packages/compiler-cli/src/ngtsc/annotations/directive/src/shared.ts @@ -318,6 +318,8 @@ export function extractDirectiveMetadata( !member.isStatic && member.kind === ClassMemberKind.Method && member.name === 'ngOnChanges', ); + const controlCreate = extractControlDirectiveDefinition(members); + // Parse exportAs. let exportAs: string[] | null = null; if (directive.has('exportAs')) { @@ -442,6 +444,7 @@ export function extractDirectiveMetadata( typeArgumentCount: reflector.getGenericArityOfClass(clazz) || 0, typeSourceSpan: createSourceSpan(clazz.name), usesInheritance, + controlCreate, exportAs, providers, isStandalone, @@ -1576,6 +1579,64 @@ function assertEmittableInputType( })(type); } +/** + * Extracts the `controlCreate` definition for the private control directive contract from the + * directive class. + * + * This looks for a lifecycle method called `ɵngControlCreate`. If present, a control directive + * definition will be extracted, and `ɵɵControlFeature` will be applied to the directive. + * + * A control directive may declare a pass-through input name, by including a generic type on the + * type of the `ControlDirectiveHost` parameter of `ɵngControlCreate`: + * + * ```ts + * class MyControlDirective { + * ɵngControlCreate(host: ControlDirectiveHost<'formField'>): void {} + * } + * ``` + * + * If present, this will be extracted as the `passThroughInput` property of the control directive + * definition. + */ +function extractControlDirectiveDefinition( + members: ClassMember[], +): R3DirectiveMetadata['controlCreate'] { + const controlCreateMember = members.find( + (member) => + !member.isStatic && + member.kind === ClassMemberKind.Method && + member.name === 'ɵngControlCreate', + ); + + if ( + controlCreateMember === undefined || + controlCreateMember.node === null || + !ts.isMethodDeclaration(controlCreateMember.node) + ) { + return null; + } + + const {node} = controlCreateMember; + if ( + node.parameters.length === 0 || + node.parameters[0].type === undefined || + !ts.isTypeReferenceNode(node.parameters[0].type) + ) { + return {passThroughInput: null}; + } + + const type = node.parameters[0].type; + if ( + type.typeArguments?.length !== 1 || + !ts.isLiteralTypeNode(type.typeArguments[0]) || + !ts.isStringLiteral(type.typeArguments[0].literal) + ) { + return {passThroughInput: null}; + } + + return {passThroughInput: type.typeArguments[0].literal.text}; +} + /** * Iterates through all specified class members and attempts to detect * view and content queries defined. diff --git a/packages/compiler-cli/src/ngtsc/typecheck/src/ops/signal_forms.ts b/packages/compiler-cli/src/ngtsc/typecheck/src/ops/signal_forms.ts index 02c9fb86bbb..e316e9372d5 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/src/ops/signal_forms.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/src/ops/signal_forms.ts @@ -299,7 +299,7 @@ export function isFieldDirective(meta: TypeCheckableDirectiveMeta): boolean { ts.isPropertyDeclaration(member) && ts.isComputedPropertyName(member.name) && ts.isIdentifier(member.name.expression) && - member.name.expression.text === 'ɵCONTROL', + member.name.expression.text === 'ɵNgFieldDirective', ) ); } diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_bindings/control_bindings/control_bindings.js b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_bindings/control_bindings/control_bindings.js index 3747a4f6aab..3b432577717 100644 --- a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_bindings/control_bindings/control_bindings.js +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_bindings/control_bindings/control_bindings.js @@ -4,7 +4,7 @@ export class MyComponent { type: MyComponent, selectors: [["ng-component"]], decls: 4, - vars: 3, + vars: 2, consts: [["formField", "Not a form control"], [3, "formField"]], template: function MyComponent_Template(rf, ctx) { if (rf & 1) { @@ -19,10 +19,11 @@ export class MyComponent { i0.ɵɵadvance(); i0.ɵɵattribute("formField", ctx.value); i0.ɵɵadvance(2); - i0.ɵɵcontrol(ctx.value, "formField"); + i0.ɵɵproperty("formField", ctx.value); + i0.ɵɵcontrol(); } }, dependencies: [FormField], encapsulation: 2 }); -} +} \ No newline at end of file diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_bindings/control_bindings/radio_bindings.js b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_bindings/control_bindings/radio_bindings.js index 6d610d17aad..4662c16f84b 100644 --- a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_bindings/control_bindings/radio_bindings.js +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_bindings/control_bindings/radio_bindings.js @@ -4,7 +4,7 @@ export class MyComponent { type: MyComponent, selectors: [["ng-component"]], decls: 2, - vars: 6, + vars: 4, consts: [["type", "radio", "id", "radio", 3, "formField", "value"], ["type", "radio", "id", "radio", 3, "value", "formField"]], template: function MyComponent_Template(rf, ctx) { if (rf & 1) { @@ -14,14 +14,14 @@ export class MyComponent { i0.ɵɵcontrolCreate(); } if (rf & 2) { - i0.ɵɵproperty("value", "foo"); - i0.ɵɵcontrol(ctx.value, "formField"); + i0.ɵɵproperty("formField", ctx.value)("value", "foo"); + i0.ɵɵcontrol(); i0.ɵɵadvance(); - i0.ɵɵproperty("value", "foo"); - i0.ɵɵcontrol(ctx.value, "formField"); + i0.ɵɵproperty("value", "foo")("formField", ctx.value); + i0.ɵɵcontrol(); } }, dependencies: [FormField], encapsulation: 2 }); -} +} \ No newline at end of file diff --git a/packages/compiler/src/compiler_facade_interface.ts b/packages/compiler/src/compiler_facade_interface.ts index dc3277aca20..f0c74e137ce 100644 --- a/packages/compiler/src/compiler_facade_interface.ts +++ b/packages/compiler/src/compiler_facade_interface.ts @@ -198,6 +198,7 @@ export interface R3DirectiveMetadataFacade { inputs: (string | {name: string; alias?: string; required?: boolean})[]; outputs: string[]; usesInheritance: boolean; + controlCreate: {passThroughInput: string | null} | null; exportAs: string[] | null; providers: Provider[] | null; viewQueries: R3QueryMetadataFacade[]; @@ -253,6 +254,7 @@ export interface R3DeclareDirectiveFacade { exportAs?: string[]; usesInheritance?: boolean; usesOnChanges?: boolean; + controlCreate?: {passThroughInput: string | null}; isStandalone?: boolean; isSignal?: boolean; hostDirectives?: R3HostDirectiveMetadataFacade[] | null; diff --git a/packages/compiler/src/jit_compiler_facade.ts b/packages/compiler/src/jit_compiler_facade.ts index 810e82bbcba..ed623ee9cc3 100644 --- a/packages/compiler/src/jit_compiler_facade.ts +++ b/packages/compiler/src/jit_compiler_facade.ts @@ -560,6 +560,7 @@ function convertDeclareDirectiveFacadeToMetadata( declaration.providers !== undefined ? new WrappedNodeExpr(declaration.providers) : null, exportAs: declaration.exportAs ?? null, usesInheritance: declaration.usesInheritance ?? false, + controlCreate: declaration.controlCreate ?? null, lifecycle: {usesOnChanges: declaration.usesOnChanges ?? false}, deps: null, typeArgumentCount: 0, diff --git a/packages/compiler/src/render3/partial/api.ts b/packages/compiler/src/render3/partial/api.ts index 8369c0399e7..1c90823a245 100644 --- a/packages/compiler/src/render3/partial/api.ts +++ b/packages/compiler/src/render3/partial/api.ts @@ -131,6 +131,10 @@ export interface R3DeclareDirectiveMetadata extends R3PartialDeclaration { */ usesOnChanges?: boolean; + controlCreate?: { + passThroughInput: string | null; + }; + /** * Whether the directive is standalone. Defaults to false. */ diff --git a/packages/compiler/src/render3/partial/directive.ts b/packages/compiler/src/render3/partial/directive.ts index c0564947223..087dff97381 100644 --- a/packages/compiler/src/render3/partial/directive.ts +++ b/packages/compiler/src/render3/partial/directive.ts @@ -98,6 +98,19 @@ export function createDirectiveDefinitionMap( definitionMap.set('usesOnChanges', o.literal(true)); } + if (meta.controlCreate) { + definitionMap.set( + 'controlCreate', + o.literalMap([ + { + key: 'passThroughInput', + value: o.literal(meta.controlCreate.passThroughInput), + quoted: false, + }, + ]), + ); + } + if (meta.hostDirectives?.length) { definitionMap.set('hostDirectives', createHostDirectives(meta.hostDirectives)); } diff --git a/packages/compiler/src/render3/r3_identifiers.ts b/packages/compiler/src/render3/r3_identifiers.ts index 3243cc4657d..45c1bf00473 100644 --- a/packages/compiler/src/render3/r3_identifiers.ts +++ b/packages/compiler/src/render3/r3_identifiers.ts @@ -424,6 +424,11 @@ export class Identifiers { static NgOnChangesFeature: o.ExternalReference = {name: 'ɵɵNgOnChangesFeature', moduleName: CORE}; + static ControlFeature: o.ExternalReference = { + name: 'ɵɵControlFeature', + moduleName: CORE, + }; + static InheritDefinitionFeature: o.ExternalReference = { name: 'ɵɵInheritDefinitionFeature', moduleName: CORE, diff --git a/packages/compiler/src/render3/view/api.ts b/packages/compiler/src/render3/view/api.ts index 59724daa792..471c9209caf 100644 --- a/packages/compiler/src/render3/view/api.ts +++ b/packages/compiler/src/render3/view/api.ts @@ -91,6 +91,11 @@ export interface R3DirectiveMetadata { */ usesInheritance: boolean; + /** + * Whether or not the component or directive uses the private `ɵngControlCreate` hook. + */ + controlCreate: {passThroughInput: string | null} | null; + /** * Reference name under which to export the directive's type in a template, * if any. @@ -192,8 +197,9 @@ export const enum DeclarationListEmitMode { /** * Information needed to compile a component for the render3 runtime. */ -export interface R3ComponentMetadata - extends R3DirectiveMetadata { +export interface R3ComponentMetadata< + DeclarationT extends R3TemplateDependency, +> extends R3DirectiveMetadata { /** * Information about the component's template. */ diff --git a/packages/compiler/src/render3/view/compiler.ts b/packages/compiler/src/render3/view/compiler.ts index 01997567035..77ac761b57a 100644 --- a/packages/compiler/src/render3/view/compiler.ts +++ b/packages/compiler/src/render3/view/compiler.ts @@ -148,6 +148,11 @@ function addFeatures( if (meta.lifecycle.usesOnChanges) { features.push(o.importExpr(R3.NgOnChangesFeature)); } + if (meta.controlCreate !== null) { + features.push( + o.importExpr(R3.ControlFeature).callFn([o.literal(meta.controlCreate.passThroughInput)]), + ); + } if ('externalStyles' in meta && meta.externalStyles?.length) { const externalStyleNodes = meta.externalStyles.map((externalStyle) => o.literal(externalStyle)); features.push( diff --git a/packages/compiler/src/template/pipeline/ir/src/expression.ts b/packages/compiler/src/template/pipeline/ir/src/expression.ts index 8b06aacb072..b58ac5f74b4 100644 --- a/packages/compiler/src/template/pipeline/ir/src/expression.ts +++ b/packages/compiler/src/template/pipeline/ir/src/expression.ts @@ -1168,7 +1168,6 @@ export function transformExpressionsInOp( case OpKind.Property: case OpKind.DomProperty: case OpKind.Attribute: - case OpKind.Control: if (op.expression instanceof Interpolation) { transformExpressionsInInterpolation(op.expression, transform, flags); } else { @@ -1299,6 +1298,7 @@ export function transformExpressionsInOp( case OpKind.SourceLocation: case OpKind.ConditionalCreate: case OpKind.ConditionalBranchCreate: + case OpKind.Control: case OpKind.ControlCreate: // These operations contain no expressions. break; diff --git a/packages/compiler/src/template/pipeline/ir/src/ops/create.ts b/packages/compiler/src/template/pipeline/ir/src/ops/create.ts index f850516157f..ac6ab218f8a 100644 --- a/packages/compiler/src/template/pipeline/ir/src/ops/create.ts +++ b/packages/compiler/src/template/pipeline/ir/src/ops/create.ts @@ -1964,7 +1964,11 @@ export interface ControlCreateOp extends Op { /** Creates a {@link ControlCreateOp}. */ export function createControlCreateOp(sourceSpan: ParseSourceSpan): ControlCreateOp { - return {kind: OpKind.ControlCreate, sourceSpan, ...NEW_OP}; + return { + kind: OpKind.ControlCreate, + sourceSpan, + ...NEW_OP, + }; } /** diff --git a/packages/compiler/src/template/pipeline/ir/src/ops/update.ts b/packages/compiler/src/template/pipeline/ir/src/ops/update.ts index 1769b400123..bf4adc2041e 100644 --- a/packages/compiler/src/template/pipeline/ir/src/ops/update.ts +++ b/packages/compiler/src/template/pipeline/ir/src/ops/update.ts @@ -295,9 +295,7 @@ export function createPropertyOp( * A logical operation representing the property binding side of a two-way binding in the update IR. */ export interface TwoWayPropertyOp - extends Op, - ConsumesVarsTrait, - DependsOnSlotContextOpTrait { + extends Op, ConsumesVarsTrait, DependsOnSlotContextOpTrait { kind: OpKind.TwoWayProperty; /** @@ -668,9 +666,7 @@ export function createAdvanceOp(delta: number, sourceSpan: ParseSourceSpan): Adv * Logical operation representing a conditional expression in the update IR. */ export interface ConditionalOp - extends Op, - DependsOnSlotContextOpTrait, - ConsumesVarsTrait { + extends Op, DependsOnSlotContextOpTrait, ConsumesVarsTrait { kind: OpKind.Conditional; /** @@ -880,9 +876,7 @@ export function createDeferWhenOp( * may want to split these into two different op types, deriving from the same base class. */ export interface I18nExpressionOp - extends Op, - ConsumesVarsTrait, - DependsOnSlotContextOpTrait { + extends Op, ConsumesVarsTrait, DependsOnSlotContextOpTrait { kind: OpKind.I18nExpression; /** @@ -1067,28 +1061,18 @@ export function createStoreLetOp( /** * A specialized {@link PropertyOp} that may bind a form field to a control. */ -// TODO: Omit name when we remove the `Field` directive. -export interface ControlOp extends Omit { +export interface ControlOp extends Op, DependsOnSlotContextOpTrait { kind: OpKind.Control; + sourceSpan: ParseSourceSpan; } /** Creates a {@link ControlOp}. */ -export function createControlOp(op: BindingOp): ControlOp { +export function createControlOp(target: XrefId, sourceSpan: ParseSourceSpan): ControlOp { return { kind: OpKind.Control, - target: op.target, - name: op.name, - expression: op.expression, - bindingKind: op.bindingKind, - securityContext: op.securityContext, - sanitizer: null, - isStructuralTemplateAttribute: op.isStructuralTemplateAttribute, - templateKind: op.templateKind, - i18nContext: op.i18nContext, - i18nMessage: op.i18nMessage, - sourceSpan: op.sourceSpan, + sourceSpan, + target, ...TRAIT_DEPENDS_ON_SLOT_CONTEXT, - ...TRAIT_CONSUMES_VARS, ...NEW_OP, }; } diff --git a/packages/compiler/src/template/pipeline/src/emit.ts b/packages/compiler/src/template/pipeline/src/emit.ts index d76046e9278..1437a13b5a6 100644 --- a/packages/compiler/src/template/pipeline/src/emit.ts +++ b/packages/compiler/src/template/pipeline/src/emit.ts @@ -87,6 +87,7 @@ import {countVariables} from './phases/var_counting'; import {optimizeVariables} from './phases/variable_optimization'; import {wrapI18nIcus} from './phases/wrap_icus'; import {generateArrowFunctions} from './phases/generate_arrow_functions'; +import {specializeControlProperties} from './phases/control_directives'; type Phase = | { @@ -112,6 +113,7 @@ const phases: Phase[] = [ {kind: Kind.Both, fn: deduplicateTextBindings}, {kind: Kind.Both, fn: specializeStyleBindings}, {kind: Kind.Both, fn: specializeBindings}, + {kind: Kind.Tmpl, fn: specializeControlProperties}, {kind: Kind.Both, fn: convertAnimations}, {kind: Kind.Both, fn: extractAttributes}, {kind: Kind.Tmpl, fn: createI18nContexts}, diff --git a/packages/compiler/src/template/pipeline/src/ingest.ts b/packages/compiler/src/template/pipeline/src/ingest.ts index 40d02174a12..ee708d2f19e 100644 --- a/packages/compiler/src/template/pipeline/src/ingest.ts +++ b/packages/compiler/src/template/pipeline/src/ingest.ts @@ -316,16 +316,6 @@ function ingestElement(unit: ViewCompilationUnit, element: t.Element): void { const endOp = ir.createElementEndOp(id, element.endSourceSpan ?? element.startSourceSpan); unit.create.push(endOp); - // We want to ensure that the controlCreateOp is after the ops that create the element - const fieldInput = element.inputs.find( - (input) => input.name === 'formField' && input.type === e.BindingType.Property, - ); - if (fieldInput) { - // If the input name is 'formField', this could be a form control binding which requires a - // `ControlCreateOp` to properly initialize. - unit.create.push(ir.createControlCreateOp(fieldInput.sourceSpan)); - } - // If there is an i18n message associated with this element, insert i18n start and end ops. if (i18nBlockId !== null) { ir.OpList.insertBefore( diff --git a/packages/compiler/src/template/pipeline/src/instruction.ts b/packages/compiler/src/template/pipeline/src/instruction.ts index 101c7b23343..0af2ac647d5 100644 --- a/packages/compiler/src/template/pipeline/src/instruction.ts +++ b/packages/compiler/src/template/pipeline/src/instruction.ts @@ -595,26 +595,11 @@ export function property( return propertyBase(Identifiers.property, name, expression, sanitizer, sourceSpan); } -export function control( - name: string, - expression: o.Expression | ir.Interpolation, - sanitizer: o.Expression | null, - sourceSpan: ParseSourceSpan, -): ir.UpdateOp { - const args = []; - if (expression instanceof ir.Interpolation) { - args.push(interpolationToExpression(expression, sourceSpan)); - } else { - args.push(expression); - } - args.push(o.literal(name)); - if (sanitizer !== null) { - args.push(sanitizer); - } - return call(Identifiers.control, args, sourceSpan); +export function control(sourceSpan: ParseSourceSpan | null): ir.UpdateOp { + return call(Identifiers.control, [], sourceSpan); } -export function controlCreate(sourceSpan: ParseSourceSpan): ir.CreateOp { +export function controlCreate(sourceSpan: ParseSourceSpan | null): ir.CreateOp { return call(Identifiers.controlCreate, [], sourceSpan); } diff --git a/packages/compiler/src/template/pipeline/src/phases/attribute_extraction.ts b/packages/compiler/src/template/pipeline/src/phases/attribute_extraction.ts index b6be86ef9e2..671a09becf9 100644 --- a/packages/compiler/src/template/pipeline/src/phases/attribute_extraction.ts +++ b/packages/compiler/src/template/pipeline/src/phases/attribute_extraction.ts @@ -55,22 +55,6 @@ export function extractAttributes(job: CompilationJob): void { ); } break; - case ir.OpKind.Control: - ir.OpList.insertBefore( - // Deliberately null i18nMessage value - ir.createExtractedAttributeOp( - op.target, - ir.BindingKind.Property, - null, - op.name, - /* expression */ null, - /* i18nContext */ null, - /* i18nMessage */ null, - op.securityContext, - ), - lookupElement(elements, op.target), - ); - break; case ir.OpKind.TwoWayProperty: ir.OpList.insertBefore( ir.createExtractedAttributeOp( diff --git a/packages/compiler/src/template/pipeline/src/phases/binding_specialization.ts b/packages/compiler/src/template/pipeline/src/phases/binding_specialization.ts index 0dfc2df1dec..5348e146a74 100644 --- a/packages/compiler/src/template/pipeline/src/phases/binding_specialization.ts +++ b/packages/compiler/src/template/pipeline/src/phases/binding_specialization.ts @@ -127,8 +127,6 @@ export function specializeBindings(job: CompilationJob): void { op.sourceSpan, ), ); - } else if (op.name === 'formField') { - ir.OpList.replace(op, ir.createControlOp(op)); } else { ir.OpList.replace( op, diff --git a/packages/compiler/src/template/pipeline/src/phases/control_directives.ts b/packages/compiler/src/template/pipeline/src/phases/control_directives.ts new file mode 100644 index 00000000000..d83d3d6a731 --- /dev/null +++ b/packages/compiler/src/template/pipeline/src/phases/control_directives.ts @@ -0,0 +1,71 @@ +/** + * @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 + */ + +import * as ir from '../../ir'; +import type {ComponentCompilationJob, ViewCompilationUnit} from '../compilation'; + +const ELIGIBLE_CONTROL_PROPERTIES = new Set(['formField']); + +export function specializeControlProperties(job: ComponentCompilationJob): void { + for (const unit of job.units) { + processView(unit); + } +} + +function processView(view: ViewCompilationUnit): void { + for (const op of view.update) { + if (op.kind !== ir.OpKind.Property) { + continue; + } + + if (ELIGIBLE_CONTROL_PROPERTIES.has(op.name)) { + addControlInstruction(view, op); + } + } +} + +const CONTROL_OP_CREATE_KINDS = new Set([ + ir.OpKind.Container, + ir.OpKind.ContainerStart, + ir.OpKind.ContainerEnd, + ir.OpKind.Element, + ir.OpKind.ElementStart, + ir.OpKind.ElementEnd, + ir.OpKind.Template, +]); + +function isRelevantCreateOp(createOp: ir.CreateOp): createOp is ir.CreateOp & {xref: ir.XrefId} { + return CONTROL_OP_CREATE_KINDS.has(createOp.kind); +} + +function findCreateInstruction(view: ViewCompilationUnit, target: ir.XrefId): ir.CreateOp | null { + let lastFoundOp: ir.CreateOp | null = null; + for (const createOp of view.create) { + if (!isRelevantCreateOp(createOp) || createOp.xref !== target) { + continue; + } + + lastFoundOp = createOp; + } + + return lastFoundOp; +} + +function addControlInstruction(view: ViewCompilationUnit, propertyOp: ir.PropertyOp): void { + const targetCreateOp = findCreateInstruction(view, propertyOp.target); + if (targetCreateOp === null) { + throw new Error(`No create instruction found for control target ${propertyOp.target}`); + } + + const controlCreateOp = ir.createControlCreateOp(propertyOp.sourceSpan); + ir.OpList.insertAfter(controlCreateOp, targetCreateOp); + ir.OpList.insertAfter( + ir.createControlOp(propertyOp.target, propertyOp.sourceSpan), + propertyOp, + ); +} diff --git a/packages/compiler/src/template/pipeline/src/phases/reify.ts b/packages/compiler/src/template/pipeline/src/phases/reify.ts index 6895e60b2d4..ca89a722727 100644 --- a/packages/compiler/src/template/pipeline/src/phases/reify.ts +++ b/packages/compiler/src/template/pipeline/src/phases/reify.ts @@ -752,7 +752,7 @@ function reifyProperty(op: ir.PropertyOp): ir.UpdateOp { } function reifyControl(op: ir.ControlOp): ir.UpdateOp { - return ng.control(op.name, op.expression, op.sanitizer, op.sourceSpan); + return ng.control(op.sourceSpan); } function reifyIrExpression(unit: CompilationUnit, expr: o.Expression): o.Expression { diff --git a/packages/core/src/compiler/compiler_facade_interface.ts b/packages/core/src/compiler/compiler_facade_interface.ts index 6af8b15bb59..5d45064d2ca 100644 --- a/packages/core/src/compiler/compiler_facade_interface.ts +++ b/packages/core/src/compiler/compiler_facade_interface.ts @@ -198,6 +198,7 @@ export interface R3DirectiveMetadataFacade { inputs: (string | {name: string; alias?: string; required?: boolean})[]; outputs: string[]; usesInheritance: boolean; + controlCreate: {passThroughInput: string | null} | null; exportAs: string[] | null; providers: Provider[] | null; viewQueries: R3QueryMetadataFacade[]; @@ -253,6 +254,9 @@ export interface R3DeclareDirectiveFacade { exportAs?: string[]; usesInheritance?: boolean; usesOnChanges?: boolean; + controlCreate?: { + passThroughInput: string | null; + }; isStandalone?: boolean; hostDirectives?: R3HostDirectiveMetadataFacade[] | null; isSignal?: boolean; diff --git a/packages/core/src/core_render3_private_export.ts b/packages/core/src/core_render3_private_export.ts index 3f184ce9127..d1b870cb07c 100644 --- a/packages/core/src/core_render3_private_export.ts +++ b/packages/core/src/core_render3_private_export.ts @@ -55,7 +55,7 @@ export { BaseDirectiveDebugMetadata as ɵBaseDirectiveDebugMetadata, ComponentDef as ɵComponentDef, ComponentType as ɵComponentType, - ɵcontrolUpdate, + ControlDirectiveHost as ɵControlDirectiveHost, CssSelectorList as ɵCssSelectorList, ɵDEFER_BLOCK_CONFIG, ɵDEFER_BLOCK_DEPENDENCY_INTERCEPTOR, @@ -103,6 +103,7 @@ export { ɵɵcontentQuerySignal, ɵɵcontrol, ɵɵcontrolCreate, + ɵɵControlFeature, ɵɵdeclareLet, ɵɵdefer, ɵɵdeferEnableTimerScheduling, @@ -250,13 +251,6 @@ export { } from './render3/index'; export {CONTAINER_HEADER_OFFSET as ɵCONTAINER_HEADER_OFFSET} from './render3/interfaces/container'; export {LContext as ɵLContext} from './render3/interfaces/context'; -export { - ɵCONTROL, - ɵFieldState, - ɵFormFieldBindingOptions, - ɵFormFieldDirective, - ɵInteropControl, -} from './render3/interfaces/control'; export {setDocument as ɵsetDocument} from './render3/interfaces/document'; export { compileComponent as ɵcompileComponent, diff --git a/packages/core/src/errors.ts b/packages/core/src/errors.ts index f44afebf30c..7403d061f01 100644 --- a/packages/core/src/errors.ts +++ b/packages/core/src/errors.ts @@ -64,7 +64,6 @@ export const enum RuntimeErrorCode { NO_BINDING_TARGET = 315, INVALID_BINDING_TARGET = 316, INVALID_SET_INPUT_CALL = 317, - INVALID_FIELD_DIRECTIVE_HOST = 318, // Bootstrap Errors MULTIPLE_PLATFORMS = 400, diff --git a/packages/core/src/render3/definition.ts b/packages/core/src/render3/definition.ts index baadb1f29c2..ab29c44219d 100644 --- a/packages/core/src/render3/definition.ts +++ b/packages/core/src/render3/definition.ts @@ -642,6 +642,7 @@ function getNgDirectiveDef(directiveDefinition: DirectiveDefinition): Dire setInput: null, resolveHostDirectives: null, hostDirectives: null, + controlDef: null, inputs: parseAndConvertInputsForDefinition(directiveDefinition.inputs, declaredInputs), outputs: parseAndConvertOutputsForDefinition(directiveDefinition.outputs), debugInfo: null, diff --git a/packages/core/src/render3/dynamic_bindings.ts b/packages/core/src/render3/dynamic_bindings.ts index 1b2cad946fd..4b17d6170ab 100644 --- a/packages/core/src/render3/dynamic_bindings.ts +++ b/packages/core/src/render3/dynamic_bindings.ts @@ -12,10 +12,9 @@ import {RuntimeError, RuntimeErrorCode} from '../errors'; import {Type, Writable} from '../interface/type'; import {assertNotDefined} from '../util/assert'; import {bindingUpdated} from './bindings'; +import {controlCreateInternal, controlUpdateInternal} from './instructions/control'; import {markViewDirty} from './instructions/mark_view_dirty'; import {setDirectiveInput, storePropertyBindingMetadata} from './instructions/shared'; -import {ɵCONTROL} from './interfaces/control'; -import {TNode} from './interfaces/node'; import {TVIEW} from './interfaces/view'; import {getCurrentTNode, getLView, getSelectedTNode, nextBindingIndex} from './state'; import {stringifyForError} from './util/stringify_utils'; @@ -43,10 +42,10 @@ export interface BindingInternal extends Binding { targetIdx?: number; /** Callback that will be invoked during creation. */ - create?(): void; + create?(slot?: number): void; /** Callback that will be invoked during updates. */ - update?(): void; + update?(slot?: number): void; } /** @@ -61,9 +60,14 @@ export interface DirectiveWithBindings { } // These are constant between all the bindings so we can reuse the objects. -const INPUT_BINDING_METADATA: BindingInternal[typeof BINDING] = {kind: 'input', requiredVars: 1}; -const FIELD_BINDING_METADATA: BindingInternal[typeof BINDING] = {kind: 'field', requiredVars: 2}; -const OUTPUT_BINDING_METADATA: BindingInternal[typeof BINDING] = {kind: 'output', requiredVars: 0}; +const INPUT_BINDING_METADATA: BindingInternal[typeof BINDING] = { + kind: 'input', + requiredVars: 1, +}; +const OUTPUT_BINDING_METADATA: BindingInternal[typeof BINDING] = { + kind: 'output', + requiredVars: 0, +}; // TODO(pk): this is a sketch of an input binding instruction that still needs some cleanups // - take an index of a directive on TNode (as matched), review all the index mappings that we need to do @@ -103,23 +107,6 @@ function inputBindingUpdate(targetDirectiveIdx: number, publicName: string, valu } } -/** - * Instructions for dynamically binding a `Field` to a form control. - */ -interface ControlBinding { - create: () => void; - update: () => void; -} - -/** - * Returns a {@link ControlBinding} for the target directive if it is a 'FormField' directive. - */ -function controlBinding(binding: BindingInternal, tNode: TNode): ControlBinding | undefined { - const lView = getLView(); - const directive = lView[tNode.directiveStart + binding.targetIdx!]; - return directive[ɵCONTROL]; -} - /** * Creates an input binding. * @param publicName Public name of the input to bind to. @@ -142,17 +129,14 @@ function controlBinding(binding: BindingInternal, tNode: TNode): ControlBinding export function inputBinding(publicName: string, value: () => unknown): Binding { if (publicName === 'formField') { const binding: BindingInternal = { - [BINDING]: FIELD_BINDING_METADATA, + [BINDING]: INPUT_BINDING_METADATA, create: () => { - // Set up the form control bindings, if this is a 'FormField' directive bound to a form control. - controlBinding(binding, getCurrentTNode()!)?.create(); + controlCreateInternal(); }, update: () => { // Update the [formField] input binding, regardless of whether this targets a 'FormField' directive. inputBindingUpdate(binding.targetIdx!, publicName, value()); - - // Update the form control bindings, if this is a 'FormField' directive bound to a form control. - controlBinding(binding, getSelectedTNode()!)?.update(); + controlUpdateInternal(); }, }; return binding; diff --git a/packages/core/src/render3/features/control_feature.ts b/packages/core/src/render3/features/control_feature.ts new file mode 100644 index 00000000000..801ac9e6d74 --- /dev/null +++ b/packages/core/src/render3/features/control_feature.ts @@ -0,0 +1,24 @@ +/** + * @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 + */ + +import type {ControlDirectiveHost} from '../interfaces/control'; +import {DirectiveDef} from '../interfaces/definition'; + +export function ɵɵControlFeature(passThroughInput: string | null) { + return (definition: DirectiveDef) => { + definition.controlDef = { + create: (inst: unknown, host: ControlDirectiveHost): void => { + (inst as any)?.ɵngControlCreate(host); + }, + update: (inst: unknown, host: ControlDirectiveHost): void => { + (inst as any)?.ɵngControlUpdate?.(host); + }, + passThroughInput, + }; + }; +} diff --git a/packages/core/src/render3/index.ts b/packages/core/src/render3/index.ts index 928131132ec..25b1ec91864 100644 --- a/packages/core/src/render3/index.ts +++ b/packages/core/src/render3/index.ts @@ -6,11 +6,13 @@ * found in the LICENSE file at https://angular.dev/license */ import {ɵɵdefineComponent, ɵɵdefineDirective, ɵɵdefineNgModule, ɵɵdefinePipe} from './definition'; +import {ɵɵControlFeature} from './features/control_feature'; import {ɵɵHostDirectivesFeature} from './features/host_directives_feature'; import {ɵɵInheritDefinitionFeature} from './features/inherit_definition_feature'; import {ɵɵNgOnChangesFeature} from './features/ng_onchanges_feature'; import {ɵɵProvidersFeature} from './features/providers_feature'; import {ɵɵExternalStylesFeature} from './features/external_styles_feature'; +import {ControlDirectiveHost} from './interfaces/control'; import { ComponentDef, ComponentTemplate, @@ -95,7 +97,6 @@ export { ɵɵproperty, ɵɵcontrol, ɵɵcontrolCreate, - ɵcontrolUpdate, ɵɵcontentQuery, ɵɵcontentQuerySignal, ɵɵloadQuery, @@ -222,6 +223,7 @@ export { AcxDirectiveDebugMetadata, AcxComponentDebugMetadata, WizComponentDebugMetadata, + ControlDirectiveHost, DirectiveDebugMetadata, DirectiveDef, DirectiveType, @@ -238,6 +240,7 @@ export { ɵɵdefinePipe, ɵɵDirectiveDeclaration, ɵɵFactoryDeclaration, + ɵɵControlFeature, ɵɵHostDirectivesFeature, ɵɵInheritDefinitionFeature, ɵɵInjectorDeclaration, diff --git a/packages/core/src/render3/instructions/control.ts b/packages/core/src/render3/instructions/control.ts index c1a2268a03d..4c2e80825f2 100644 --- a/packages/core/src/render3/instructions/control.ts +++ b/packages/core/src/render3/instructions/control.ts @@ -5,40 +5,23 @@ * 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 */ -import {RuntimeError, RuntimeErrorCode} from '../../errors'; import {performanceMarkFeature} from '../../util/performance'; -import {getClosureSafeProperty} from '../../util/property'; import {assertFirstCreatePass} from '../assert'; -import {bindingUpdated} from '../bindings'; -import { - ɵCONTROL, - ɵFieldState, - ɵFormFieldDirective, - type ɵFormFieldBindingOptions, -} from '../interfaces/control'; -import {DirectiveDef} from '../interfaces/definition'; -import {TElementNode, TNode, TNodeFlags, TNodeType} from '../interfaces/node'; -import {Renderer} from '../interfaces/renderer'; -import {SanitizerFn} from '../interfaces/sanitization'; +import type {ControlDirectiveHost} from '../interfaces/control'; +import type {DirectiveDef} from '../interfaces/definition'; +import {type TNode, TNodeFlags} from '../interfaces/node'; import {isComponentHost} from '../interfaces/type_checks'; -import {LView, RENDERER, TView} from '../interfaces/view'; -import {Signal} from '../reactivity/api'; -import {untracked} from '../reactivity/untracked'; +import {type LView, RENDERER, type TView} from '../interfaces/view'; import { - getBindingIndex, getCurrentTNode, getLView, getSelectedTNode, getTView, - nextBindingIndex, + isInCheckNoChangesMode, } from '../state'; -import {NO_CHANGE} from '../tokens'; -import {isNameOnlyAttributeMarker} from '../util/attrs_utils'; import {debugStringifyTypeForError} from '../util/stringify_utils'; -import {getNativeByTNode, storeCleanupWithContext} from '../util/view_utils'; import {listenToOutput} from '../view/directive_outputs'; import {listenToDomEvent, wrapListener} from '../view/listeners'; -import {setPropertyAndInputs, storePropertyBindingMetadata} from './shared'; import {writeToDirectiveInput} from './write_to_directive_input'; /** @@ -51,7 +34,11 @@ import {writeToDirectiveInput} from './write_to_directive_input'; * @codeGenApi */ export function ɵɵcontrolCreate(): void { - const lView = getLView<{} | null>(); + controlCreateInternal(); +} + +export function controlCreateInternal(): void { + const lView = getLView(); const tView = getTView(); const tNode = getCurrentTNode()!; @@ -59,175 +46,174 @@ export function ɵɵcontrolCreate(): void { initializeControlFirstCreatePass(tView, tNode, lView); } - const fieldDirective = getFieldDirective(tNode, lView); - if (!fieldDirective) { + if (tNode.controlDirectiveIndex === -1) { return; } - performanceMarkFeature('NgSignalForms'); - if (tNode.flags & TNodeFlags.isFormValueControl) { - initializeCustomControl(lView, tNode, fieldDirective, 'value'); - } else if (tNode.flags & TNodeFlags.isFormCheckboxControl) { - initializeCustomControl(lView, tNode, fieldDirective, 'checked'); - } else if (tNode.flags & TNodeFlags.isInteropControl) { - initializeInteropControl(fieldDirective); - } else if (tNode.flags & TNodeFlags.isNativeControl) { - initializeNativeControl(lView, tNode, fieldDirective); - } - - fieldDirective.registerAsBinding(getCustomControl(tNode, lView)); + const instance = lView[tNode.controlDirectiveIndex]; + const controlDef = (tView.data[tNode.controlDirectiveIndex] as DirectiveDef).controlDef!; + controlDef.create(instance, new ControlDirectiveHostImpl(lView, tView, tNode)); } /** - * Updates a `field` property, and possibly other form control properties, on the current element. - * - * This is a specialized version of the `ɵɵproperty` instruction that handles updating additional - * form control properties, if set up to do so by {@link ɵɵcontrolCreate} during creation. - * - * @param value New value to write. - * @param sanitizer An optional function used to sanitize the value. + * Calls the update function of the control directive at the given index. * * @codeGenApi */ -export function ɵɵcontrol(value: T, name: string, sanitizer?: SanitizerFn | null): void { - const lView = getLView(); - const tNode = getSelectedTNode(); - const bindingIndex = nextBindingIndex(); +export function ɵɵcontrol(): void { + controlUpdateInternal(); +} - if (bindingUpdated(lView, bindingIndex, value)) { - const tView = getTView(); - setPropertyAndInputs(tNode, lView, name, value, lView[RENDERER], sanitizer); - ngDevMode && storePropertyBindingMetadata(tView.data, tNode, name, bindingIndex); +export function controlUpdateInternal(): void { + if (ngDevMode && isInCheckNoChangesMode()) { + return; } - updateControl(lView, tNode); -} - -/** - * Calls {@link updateControl} with the current `LView` and selected `TNode`. - * - * NOTE: This instruction exists solely to accommodate tree-shakeable, dynamic control bindings. - * It's intended to be referenced exclusively by the Signal Forms `FormField` directive and should not - * be referenced by any other means. - */ -export function ɵcontrolUpdate(): void { const lView = getLView(); + const tView = getTView(); const tNode = getSelectedTNode(); - updateControl(lView, tNode); + + if (tNode.controlDirectiveIndex === -1) { + return; + } + + const controlDef = (tView.data[tNode.controlDirectiveIndex] as DirectiveDef).controlDef!; + const instance = lView[tNode.controlDirectiveIndex]; + controlDef.update(instance, new ControlDirectiveHostImpl(lView, tView, tNode)); } -/** - * Updates the form control properties of a `field` bound form control. - * - * Does nothing if the current node is not a `field` bound form control. - * - * @param lView The `LView` that contains the control. - * @param tNode The `TNode` of the control. - */ -function updateControl(lView: LView, tNode: TNode): void { - const fieldDirective = getFieldDirective(tNode, lView); - if (fieldDirective) { - updateControlClasses(lView, tNode, fieldDirective); +class ControlDirectiveHostImpl implements ControlDirectiveHost { + private readonly lView: LView; + private readonly tView: TView; + private readonly tNode: TNode; - if (tNode.flags & TNodeFlags.isFormValueControl) { - updateCustomControl(tNode, lView, fieldDirective, 'value'); - } else if (tNode.flags & TNodeFlags.isFormCheckboxControl) { - updateCustomControl(tNode, lView, fieldDirective, 'checked'); - } else if (tNode.flags & TNodeFlags.isInteropControl) { - updateInteropControl(tNode, lView, fieldDirective); - } else { - updateNativeControl(tNode, lView, fieldDirective); + readonly hasPassThrough: boolean; + + constructor(lView: LView, tView: TView, tNode: TNode) { + this.lView = lView; + this.tView = tView; + this.tNode = tNode; + this.hasPassThrough = !!(tNode.flags & TNodeFlags.isPassThroughControl); + } + + get customControl(): unknown { + return this.tNode.customControlIndex !== -1 + ? this.lView[this.tNode.customControlIndex] + : undefined; + } + + get descriptor(): string { + if (ngDevMode && isComponentHost(this.tNode)) { + const componentIndex = this.tNode.directiveStart + this.tNode.componentOffset; + const componentDef = this.tView.data[componentIndex] as DirectiveDef; + return `Component ${debugStringifyTypeForError(componentDef.type)}`; + } + return `<${this.tNode.value}>`; + } + + listenToCustomControlOutput(outputName: string, callback: (event: Event) => void): void { + if ( + !hasOutput( + this.tView.data[this.tNode.customControlIndex] as DirectiveDef, + outputName, + ) + ) { + return; + } + + listenToOutput( + this.tNode, + this.lView, + this.tNode.customControlIndex, + outputName, + outputName, + wrapListener(this.tNode, this.lView, callback), + ); + } + + listenToCustomControlModel(listener: (value: unknown) => void): void { + const modelName = + this.tNode.flags & TNodeFlags.isFormValueControl ? 'valueChange' : 'checkedChange'; + listenToOutput( + this.tNode, + this.lView, + this.tNode.customControlIndex, + modelName, + modelName, + wrapListener(this.tNode, this.lView, listener), + ); + } + + listenToDom(eventName: string, listener: (event: Event) => void): void { + listenToDomEvent( + this.tNode, + this.tView, + this.lView as LView<{} | null>, + undefined, + this.lView[RENDERER], + eventName, + listener, + wrapListener(this.tNode, this.lView, listener), + ); + } + + setInputOnDirectives(inputName: string, value: unknown): boolean { + const directiveIndices = this.tNode.inputs?.[inputName]; + if (!directiveIndices) { + return false; + } + for (const index of directiveIndices) { + const directiveDef = this.tView.data[index] as DirectiveDef; + const directive = this.lView[index]; + writeToDirectiveInput(directiveDef, directive, inputName, value); + } + return true; + } + + setCustomControlModelInput(value: unknown): void { + const directive = this.lView[this.tNode.customControlIndex]; + const directiveDef = this.tView.data[this.tNode.customControlIndex] as DirectiveDef<{}>; + const modelName = this.tNode.flags & TNodeFlags.isFormValueControl ? 'value' : 'checked'; + writeToDirectiveInput(directiveDef, directive, modelName, value); + } + + customControlHasInput(inputName: string): boolean { + if (this.tNode.customControlIndex === -1) { + return false; + } + const directiveDef = this.tView.data[this.tNode.customControlIndex] as DirectiveDef; + return directiveDef.inputs[inputName] != undefined; + } +} + +function initializeControlFirstCreatePass(tView: TView, tNode: TNode, lView: LView): void { + ngDevMode && assertFirstCreatePass(tView); + + for (let i = tNode.directiveStart; i < tNode.directiveEnd; i++) { + const directiveDef = tView.data[i] as DirectiveDef; + if (directiveDef.controlDef) { + tNode.controlDirectiveIndex = i; + break; } } - // This instruction requires an additional variable slot to store control property bindings, but - // may not use them if the `control` is undefined, so we increment the index here rather than when - // used to ensure it happens unconditionally. Otherwise, the next instruction could begin with the - // wrong binding index. - nextBindingIndex(); -} - -function initializeControlFirstCreatePass(tView: TView, tNode: TNode, lView: LView): void { - ngDevMode && assertFirstCreatePass(tView); - - const directiveIndices = tNode.inputs?.['formField']; - if (!directiveIndices) { - return; // There are no matching inputs for the `[formField]` property binding. - } - - // If component has a `formField` input, we assume that it will handle binding the field to the - // appropriate native/custom control in its template, so we do not attempt to bind any inputs - // on this component. - if ( - isComponentHost(tNode) && - directiveIndices.includes(tNode.directiveStart + tNode.componentOffset) - ) { + if (tNode.controlDirectiveIndex === -1) { + // No control directive found on this element. return; } - // Check if the `FormField` directive is present. - const controlIndex = directiveIndices.find((index) => ɵCONTROL in lView[index]); - if (controlIndex === undefined) { - return; + const controlDef = (tView.data[tNode.controlDirectiveIndex] as DirectiveDef).controlDef!; + if (controlDef.passThroughInput) { + // This control directive has a `passThroughInput` property binding. + if ((tNode.inputs?.[controlDef.passThroughInput]?.length ?? 0) > 1) { + // Another directive has bound `controlDef.passThroughInput`. + tNode.flags |= TNodeFlags.isPassThroughControl; + return; + } } - // Cache the index of the `FormField` directive. - tNode.fieldIndex = controlIndex; - - // First check if the `FormField` directive is bound to an interop control (e.g. a Reactive Forms - // control using `ControlValueAccessor`). If not, look for a custom control. - const foundControl = - isInteropControlFirstCreatePass(tNode, lView) || isCustomControlFirstCreatePass(tView, tNode); - - // We check for a native control, even if we found a custom or interop one, to determine whether - // we can set native properties as a fallback on the custom or interop control. - if (isNativeControlFirstCreatePass(tNode) || foundControl) { - return; - } - - throw new RuntimeError( - RuntimeErrorCode.INVALID_FIELD_DIRECTIVE_HOST, - ngDevMode && - `${describeElement(tView, tNode)} is an invalid [formField] directive host. The host must be a native form control ` + - `(such as ', '