Skip to content

Commit 32f86d3

Browse files
leonsenftAndrewKushnir
authored andcommitted
perf(forms): optimize [field] binding instructions (#64351)
Caches information about the kind of form control that a `TNode` represents in `TNodeFlags`. This avoids redundant computations on subsequent template create and update passes. Renames the `INVALID_CONTROL_HOST` error code to `INVALID_FIELD_DIRECTIVE_HOST` for clarity and adds a test for it. PR Close #64351
1 parent 6a96844 commit 32f86d3

File tree

5 files changed

+149
-98
lines changed

5 files changed

+149
-98
lines changed

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,14 +76,14 @@ export const enum RuntimeErrorCode {
7676
// (undocumented)
7777
INVALID_BINDING_TARGET = 316,
7878
// (undocumented)
79-
INVALID_CONTROL_HOST = 318,
80-
// (undocumented)
8179
INVALID_DIFFER_INPUT = 900,
8280
// (undocumented)
8381
INVALID_EVENT_BINDING = 306,
8482
// (undocumented)
8583
INVALID_FACTORY_DEPENDENCY = 202,
8684
// (undocumented)
85+
INVALID_FIELD_DIRECTIVE_HOST = 318,
86+
// (undocumented)
8787
INVALID_I18N_STRUCTURE = 700,
8888
// (undocumented)
8989
INVALID_INHERITANCE = 903,

packages/core/src/errors.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ export const enum RuntimeErrorCode {
6464
NO_BINDING_TARGET = 315,
6565
INVALID_BINDING_TARGET = 316,
6666
INVALID_SET_INPUT_CALL = 317,
67-
INVALID_CONTROL_HOST = 318,
67+
INVALID_FIELD_DIRECTIVE_HOST = 318,
6868

6969
// Bootstrap Errors
7070
MULTIPLE_PLATFORMS = 400,

packages/core/src/render3/instructions/control.ts

Lines changed: 96 additions & 95 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,11 @@ import {bindingUpdated} from '../bindings';
1010
import {ɵCONTROL, ɵControl} from '../interfaces/control';
1111
import {ComponentDef} from '../interfaces/definition';
1212
import {InputFlags} from '../interfaces/input_flags';
13-
import {TNode} from '../interfaces/node';
13+
import {TNode, TNodeFlags} from '../interfaces/node';
1414
import {Renderer} from '../interfaces/renderer';
1515
import {SanitizerFn} from '../interfaces/sanitization';
1616
import {isComponentHost} from '../interfaces/type_checks';
17-
import {LView, RENDERER} from '../interfaces/view';
17+
import {LView, RENDERER, TView} from '../interfaces/view';
1818
import {getCurrentTNode, getLView, getSelectedTNode, getTView, nextBindingIndex} from '../state';
1919
import {getNativeByTNode} from '../util/view_utils';
2020
import {listenToOutput} from '../view/directive_outputs';
@@ -33,34 +33,22 @@ import {writeToDirectiveInput} from './write_to_directive_input';
3333
*/
3434
export function ɵɵcontrolCreate(): void {
3535
const lView = getLView<{} | null>();
36+
const tView = getTView();
3637
const tNode = getCurrentTNode()!;
38+
const control = tView.firstCreatePass
39+
? getControlDirectiveFirstCreatePass(tView, tNode, lView)
40+
: getControlDirective(tNode, lView);
3741

38-
// TODO(https://github.com/orgs/angular/projects/60/views/1?pane=issue&itemId=131712274)
39-
// * cache the field directive index or instance for reuse.
40-
const control = getControlDirective(tNode, lView);
4142
if (!control) {
4243
return;
4344
}
4445

45-
// TODO(https://github.com/orgs/angular/projects/60/views/1?pane=issue&itemId=131712274):
46-
// * cache the custom control component index or instance for reuse.
47-
// * cache the control model name for reuse.
48-
const customControl = getCustomControlComponent(tNode);
49-
if (customControl) {
50-
const [componentIndex, modelName] = customControl;
51-
listenToCustomControl(lView, tNode, control, componentIndex, modelName);
52-
} else if (isNativeControl(lView, tNode)) {
53-
listenToNativeControl(lView, tNode, control);
46+
if (tNode.flags & TNodeFlags.isFormValueControl) {
47+
listenToCustomControl(lView, tNode, control, 'value');
48+
} else if (tNode.flags & TNodeFlags.isFormCheckboxControl) {
49+
listenToCustomControl(lView, tNode, control, 'checked');
5450
} else {
55-
// For example, user wrote <div [control]="f">.
56-
// TODO: https://github.com/orgs/angular/projects/60/views/1?pane=issue&itemId=131860276
57-
const tagName = tNode.value;
58-
throw new RuntimeError(
59-
RuntimeErrorCode.INVALID_CONTROL_HOST,
60-
`'<${tagName}>' is an invalid control host. The host must be a native form control (such ` +
61-
`as <input>', '<select>', or '<textarea>') or a custom form control component with a ` +
62-
`'value' or 'checked' model.`,
63-
);
51+
listenToNativeControl(lView, tNode, control);
6452
}
6553

6654
control.register();
@@ -79,49 +67,41 @@ export function ɵɵcontrolCreate(): void {
7967
*/
8068
export function ɵɵcontrol<T>(value: T, sanitizer?: SanitizerFn | null): void {
8169
const lView = getLView();
82-
const bindingIndex = nextBindingIndex();
8370
const tNode = getSelectedTNode();
71+
const bindingIndex = nextBindingIndex();
8472

8573
if (bindingUpdated(lView, bindingIndex, value)) {
8674
const tView = getTView();
8775
setPropertyAndInputs(tNode, lView, 'field', value, lView[RENDERER], sanitizer);
8876
ngDevMode && storePropertyBindingMetadata(tView.data, tNode, 'field', bindingIndex);
8977
}
9078

91-
// TODO: https://github.com/orgs/angular/projects/60/views/1?pane=issue&itemId=131711472
92-
// * only run if this is really a control binding determine in the create pass.
9379
const control = getControlDirective(tNode, lView);
94-
if (!control) {
95-
return;
96-
}
97-
98-
const customControl = getCustomControlComponent(tNode);
99-
if (customControl) {
100-
const [componentIndex, modelName] = customControl;
101-
updateCustomControl(lView, componentIndex, modelName, control);
102-
} else {
103-
updateNativeControl(tNode, lView, control);
80+
if (control) {
81+
if (tNode.flags & TNodeFlags.isFormValueControl) {
82+
updateCustomControl(tNode, lView, control, 'value');
83+
} else if (tNode.flags & TNodeFlags.isFormCheckboxControl) {
84+
updateCustomControl(tNode, lView, control, 'checked');
85+
} else {
86+
updateNativeControl(tNode, lView, control);
87+
}
10488
}
10589
}
10690

107-
/**
108-
* Returns the {@link ɵControl} directive on the specified node, if one is present and a `field`
109-
* input is bound to it, but not to a component. If a `field` input is bound to a component, we
110-
* assume the component will manage the control in its own template and return nothing to indicate
111-
* that the directive should not be set up.
112-
*
113-
* @param tNode The `TNode` of the element to check.
114-
* @param lView The `LView` that contains the element.
115-
*/
116-
function getControlDirective<T>(tNode: TNode, lView: LView): ɵControl<T> | undefined {
91+
function getControlDirectiveFirstCreatePass<T>(
92+
tView: TView,
93+
tNode: TNode,
94+
lView: LView,
95+
): ɵControl<T> | undefined {
11796
const directiveIndices = tNode.inputs?.['field'];
11897
if (!directiveIndices) {
119-
// There are no matching inputs for the `[control]` property binding.
98+
// There are no matching inputs for the `[field]` property binding.
12099
return;
121100
}
122101

102+
let componentIndex!: number;
123103
if (isComponentHost(tNode)) {
124-
const componentIndex = tNode.directiveStart + tNode.componentOffset;
104+
componentIndex = tNode.directiveStart + tNode.componentOffset;
125105
if (directiveIndices.includes(componentIndex)) {
126106
// If component has a `field` input, we assume that it will handle binding the field to the
127107
// appropriate native/custom control in its template, so we do not attempt to bind any inputs
@@ -130,49 +110,74 @@ function getControlDirective<T>(tNode: TNode, lView: LView): ɵControl<T> | unde
130110
}
131111
}
132112

133-
// Search for the `Field` directive.
134-
for (let index of directiveIndices) {
135-
const directive = lView[index];
136-
if (ɵCONTROL in directive) {
137-
return directive;
113+
// Search for the `ɵControl` directive.
114+
const control = findControlDirective<T>(lView, directiveIndices);
115+
if (!control) {
116+
// The `ɵControl` directive was not imported by this component.
117+
return;
118+
}
119+
120+
tNode.flags |= TNodeFlags.isFormControl;
121+
122+
if (isComponentHost(tNode)) {
123+
const componentDef = tView.data[componentIndex] as ComponentDef<unknown>;
124+
// TODO: should we check that any additional field state inputs are signal based?
125+
if (hasModelInput(componentDef, 'value')) {
126+
tNode.flags |= TNodeFlags.isFormValueControl;
127+
return control;
128+
} else if (hasModelInput(componentDef, 'checked')) {
129+
tNode.flags |= TNodeFlags.isFormCheckboxControl;
130+
return control;
138131
}
139132
}
140133

141-
// The `Field` directive was not imported by this component.
142-
return;
143-
}
134+
const nativeElement = lView[tNode.index];
135+
if (isNativeControl(nativeElement)) {
136+
if (isNumericInput(nativeElement)) {
137+
tNode.flags |= TNodeFlags.isNativeNumericControl;
138+
}
139+
if (isTextControl(nativeElement)) {
140+
tNode.flags |= TNodeFlags.isNativeTextControl;
141+
}
142+
return control;
143+
}
144144

145-
/**
146-
* The name of the property that represents a control's value.
147-
*/
148-
type ControlModelName = 'value' | 'checked';
145+
const tagName = tNode.value;
146+
throw new RuntimeError(
147+
RuntimeErrorCode.INVALID_FIELD_DIRECTIVE_HOST,
148+
`'<${tagName}>' is an invalid [field] directive host. The host must be a native form control ` +
149+
`(such as <input>', '<select>', or '<textarea>') or a custom form control component with a ` +
150+
`'value' or 'checked' model.`,
151+
);
152+
}
149153

150154
/**
151-
* Returns information about the component on the specified node, if it appears to be a custom form
152-
* control.
153-
*
154-
* A component is considered a custom form control if it has a model input named `value` or
155-
* `checked`.
155+
* Returns the {@link ɵControl} directive on the specified node, if one is present and a `field`
156+
* input is bound to it, but not to a component. If a `field` input is bound to a component, we
157+
* assume the component will manage the control in its own template and return nothing to indicate
158+
* that the directive should not be set up.
156159
*
157160
* @param tNode The `TNode` of the element to check.
158-
* @returns an array containing the component index and model input name if it's a custom form
159-
* control, or undefined.
161+
* @param lView The `LView` that contains the element.
160162
*/
161-
function getCustomControlComponent(tNode: TNode): [number, ControlModelName] | undefined {
162-
if (!isComponentHost(tNode)) {
163-
return;
164-
}
165-
const tView = getTView();
166-
const componentIndex = tNode.directiveStart + tNode.componentOffset;
167-
const componentDef = tView.data[componentIndex] as ComponentDef<unknown>;
168-
if (hasModelInput(componentDef, 'value')) {
169-
return [componentIndex, 'value'];
170-
}
171-
if (hasModelInput(componentDef, 'checked')) {
172-
return [componentIndex, 'checked'];
163+
function getControlDirective<T>(tNode: TNode, lView: LView): ɵControl<T> | undefined {
164+
return tNode.flags & TNodeFlags.isFormControl
165+
? findControlDirective(lView, tNode.inputs!['field'])
166+
: undefined;
167+
}
168+
169+
function findControlDirective<T>(
170+
lView: LView,
171+
directiveIndices: number[],
172+
): ɵControl<T> | undefined {
173+
for (let index of directiveIndices) {
174+
const directive = lView[index];
175+
if (ɵCONTROL in directive) {
176+
return directive;
177+
}
173178
}
174-
// TODO: https://github.com/orgs/angular/projects/60/views/1?pane=issue&itemId=131861022
175-
// * should we check that any additional field state inputs are signal based?
179+
180+
// The `Field` directive was not imported by this component.
176181
return;
177182
}
178183

@@ -205,9 +210,9 @@ function listenToCustomControl(
205210
lView: LView<{} | null>,
206211
tNode: TNode,
207212
control: ɵControl<unknown>,
208-
componentIndex: number,
209-
modelName: ControlModelName,
213+
modelName: string,
210214
) {
215+
const componentIndex = tNode.directiveStart + tNode.componentOffset;
211216
const outputName = modelName + 'Change';
212217
listenToOutput(
213218
tNode,
@@ -254,8 +259,7 @@ interface HTMLTextAreaElementNarrowed extends HTMLTextAreaElement {
254259
*/
255260
type NativeControlElement = HTMLInputElement | HTMLSelectElement | HTMLTextAreaElementNarrowed;
256261

257-
function isNativeControl(lView: LView<unknown>, tNode: TNode): boolean {
258-
const element = lView[tNode.index];
262+
function isNativeControl(element: unknown): element is NativeControlElement {
259263
return (
260264
element instanceof HTMLInputElement ||
261265
element instanceof HTMLSelectElement ||
@@ -314,12 +318,13 @@ function listenToNativeControl(lView: LView<{} | null>, tNode: TNode, control:
314318
* @param control The `ɵControl` directive instance.
315319
*/
316320
function updateCustomControl(
321+
tNode: TNode,
317322
lView: LView,
318-
componentIndex: number,
319-
modelName: ControlModelName,
320323
control: ɵControl<unknown>,
324+
modelName: string,
321325
) {
322326
const tView = getTView();
327+
const componentIndex = tNode.directiveStart + tNode.componentOffset;
323328
const component = lView[componentIndex];
324329
const componentDef = tView.data[componentIndex] as ComponentDef<{}>;
325330
const state = control.state();
@@ -380,16 +385,12 @@ function updateNativeControl(tNode: TNode, lView: LView, control: ɵControl<unkn
380385
setBooleanAttribute(renderer, input, 'readonly', state.readonly());
381386
setBooleanAttribute(renderer, input, 'required', state.required());
382387

383-
// TODO: https://github.com/orgs/angular/projects/60/views/1?pane=issue&itemId=131711472
384-
// * cache this in `tNode.flags`.
385-
if (isNumericInput(input)) {
388+
if (tNode.flags & TNodeFlags.isNativeNumericControl) {
386389
setOptionalAttribute(renderer, input, 'max', state.max());
387390
setOptionalAttribute(renderer, input, 'min', state.min());
388391
}
389392

390-
// TODO: https://github.com/orgs/angular/projects/60/views/1?pane=issue&itemId=131711472
391-
// * cache this in `tNode.flags`.
392-
if (isTextInput(input)) {
393+
if (tNode.flags & TNodeFlags.isNativeTextControl) {
393394
setOptionalAttribute(renderer, input, 'maxLength', state.maxLength());
394395
setOptionalAttribute(renderer, input, 'minLength', state.minLength());
395396
}
@@ -421,9 +422,9 @@ function isNumericInput(control: NativeControlElement) {
421422
* This is not the same as an input with `type="text"`, but rather any input that accepts
422423
* text-based input which includes numeric types.
423424
*/
424-
function isTextInput(
425+
function isTextControl(
425426
control: NativeControlElement,
426-
): control is HTMLInputElement | HTMLTextAreaElementNarrowed {
427+
): control is Exclude<NativeControlElement, HTMLSelectElement> {
427428
return !(control instanceof HTMLSelectElement);
428429
}
429430

packages/core/src/render3/interfaces/node.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,42 @@ export const enum TNodeFlags {
170170
* Bit #10 - This bit is set if the node is within a set of control flow blocks.
171171
*/
172172
isInControlFlow = 0x200,
173+
174+
/**
175+
* Bit #11 - This bit is set if the node represents a form control.
176+
*
177+
* True when the node has an input binding to a `ɵControl` directive (but not also to a custom
178+
* component).
179+
*/
180+
isFormControl = 0x400,
181+
182+
/**
183+
* Bit #12 - This bit is set if the node hosts a custom control component.
184+
*
185+
* A custom control component's model property is named `value`.
186+
*/
187+
isFormValueControl = 0x800,
188+
189+
/**
190+
* Bit #13 - This bit is set if the node hosts a custom checkbox component.
191+
*
192+
* A custom checkbox component's model property is named `checked`.
193+
*/
194+
isFormCheckboxControl = 0x1000,
195+
196+
/**
197+
* Bit #14 - This bit is set if the node is a native control with a numeric type.
198+
*
199+
* This is used to determine whether the control supports the `min` and `max` properties.
200+
*/
201+
isNativeNumericControl = 0x2000,
202+
203+
/**
204+
* Bit #15 - This bit is set if the node is a native text control.
205+
*
206+
* This is used to determine whether control supports the `minLength` and `maxLength` properties.
207+
*/
208+
isNativeTextControl = 0x4000,
173209
}
174210

175211
/**

packages/forms/signals/test/web/field_directive.spec.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1278,6 +1278,20 @@ describe('field directive', () => {
12781278
expect(cmp.f().value()).toBe('#0000ff');
12791279
});
12801280
});
1281+
1282+
it('should throw for invalid field directive host', () => {
1283+
@Component({
1284+
imports: [Field],
1285+
template: `<div [field]="f"></div>`,
1286+
})
1287+
class TestCmp {
1288+
f = form(signal(''));
1289+
}
1290+
1291+
expect(() => act(() => TestBed.createComponent(TestCmp))).toThrowError(
1292+
/'<div>' is an invalid \[field\] directive host\./,
1293+
);
1294+
});
12811295
});
12821296

12831297
function setupRadioGroup() {

0 commit comments

Comments
 (0)