Skip to content

Commit 16adbbf

Browse files
alxhubleonsenft
authored andcommitted
fix(core): ensure custom controls resolve transitive host directives
Custom controls can be modeled using a set of host directives to alias and expose value and valueChange (or checked/checkedChange) bindings, as well as native attributes like disabled. This commit updates initializeCustomControlStatus to correctly identify host components using mapped inputs/outputs, even when those inputs are exposed via transitive host directives. It also updates customControlHasInput so that the custom control presence check correctly evaluates the exposed inputs across all applied host directives, caching the result to optimize performance on hot code paths.
1 parent 3382e8a commit 16adbbf

8 files changed

Lines changed: 243 additions & 20 deletions

File tree

packages/core/src/render3/definition.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -639,6 +639,7 @@ function getNgDirectiveDef<T>(directiveDefinition: DirectiveDefinition<T>): Dire
639639
resolveHostDirectives: null,
640640
hostDirectives: null,
641641
controlDef: null,
642+
signalFormsInputPresence: null,
642643
inputs: parseAndConvertInputsForDefinition(directiveDefinition.inputs, declaredInputs),
643644
outputs: parseAndConvertOutputsForDefinition(directiveDefinition.outputs),
644645
debugInfo: null,

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

Lines changed: 117 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,9 @@ import {
2121
} from '../state';
2222
import {getNativeByTNode} from '../util/view_utils';
2323
import {debugStringifyTypeForError} from '../util/stringify_utils';
24-
import {listenToOutput} from '../view/directive_outputs';
24+
import {listenToDirectiveOutput} from '../view/directive_outputs';
2525
import {listenToDomEvent, wrapListener} from '../view/listeners';
26+
import {setDirectiveInput} from './shared';
2627
import {writeToDirectiveInput} from './write_to_directive_input';
2728

2829
/**
@@ -118,20 +119,11 @@ class ControlDirectiveHostImpl implements ControlDirectiveHost {
118119
}
119120

120121
listenToCustomControlOutput(outputName: string, callback: (event: Event) => void): void {
121-
if (
122-
!hasOutput(
123-
this.tView.data[this.tNode.customControlIndex] as DirectiveDef<unknown>,
124-
outputName,
125-
)
126-
) {
127-
return;
128-
}
129-
130-
listenToOutput(
122+
const directiveDef = this.tView.data[this.tNode.customControlIndex] as DirectiveDef<unknown>;
123+
listenToDirectiveOutput(
131124
this.tNode,
132125
this.lView,
133-
this.tNode.customControlIndex,
134-
outputName,
126+
directiveDef,
135127
outputName,
136128
wrapListener(this.tNode, this.lView, callback),
137129
);
@@ -140,11 +132,11 @@ class ControlDirectiveHostImpl implements ControlDirectiveHost {
140132
listenToCustomControlModel(listener: (value: unknown) => void): void {
141133
const modelName =
142134
this.tNode.flags & TNodeFlags.isFormValueControl ? 'valueChange' : 'checkedChange';
143-
listenToOutput(
135+
const directiveDef = this.tView.data[this.tNode.customControlIndex] as DirectiveDef<unknown>;
136+
listenToDirectiveOutput(
144137
this.tNode,
145138
this.lView,
146-
this.tNode.customControlIndex,
147-
modelName,
139+
directiveDef,
148140
modelName,
149141
wrapListener(this.tNode, this.lView, listener),
150142
);
@@ -203,21 +195,78 @@ class ControlDirectiveHostImpl implements ControlDirectiveHost {
203195
}
204196

205197
setCustomControlModelInput(value: unknown): void {
206-
const directive = this.lView[this.tNode.customControlIndex];
207198
const directiveDef = this.tView.data[this.tNode.customControlIndex] as DirectiveDef<{}>;
208199
const modelName = this.tNode.flags & TNodeFlags.isFormValueControl ? 'value' : 'checked';
209-
writeToDirectiveInput(directiveDef, directive, modelName, value);
200+
setDirectiveInput(this.tNode, this.tView, this.lView, directiveDef, modelName, value);
210201
}
211202

212203
customControlHasInput(inputName: string): boolean {
213204
if (this.tNode.customControlIndex === -1) {
214205
return false;
215206
}
216207
const directiveDef = this.tView.data[this.tNode.customControlIndex] as DirectiveDef<unknown>;
217-
return directiveDef.inputs[inputName] != undefined;
208+
const presence = (directiveDef.signalFormsInputPresence ??=
209+
this._buildCustomControlInputCache(directiveDef));
210+
return presence[inputName] === true;
211+
}
212+
213+
private _buildCustomControlInputCache(directiveDef: DirectiveDef<unknown>): {
214+
[key: string]: boolean;
215+
} {
216+
const cache: {[key: string]: boolean} = {};
217+
218+
// First, add all inputs defined directly on the custom control directive.
219+
for (const key in directiveDef.inputs) {
220+
cache[key] = true;
221+
}
222+
223+
// Next, gather inputs exposed by host directives recursively.
224+
if (directiveDef.hostDirectives !== null) {
225+
const queue = [...directiveDef.hostDirectives];
226+
while (queue.length > 0) {
227+
const hostDir = queue.shift()!;
228+
if (typeof hostDir !== 'function') {
229+
// HostDirectiveDef object
230+
for (const key in hostDir.inputs) {
231+
cache[hostDir.inputs[key]] = true;
232+
}
233+
const hostDirectives = getHostDirectives(hostDir.directive);
234+
if (hostDirectives !== null) {
235+
queue.push(...hostDirectives);
236+
}
237+
continue;
238+
}
239+
240+
// Factory function returning HostDirectiveConfig[]
241+
for (const config of hostDir()) {
242+
if (typeof config === 'function') {
243+
continue;
244+
}
245+
if (config.inputs) {
246+
for (let i = 0; i < config.inputs.length; i += 2) {
247+
const exposedName = config.inputs[i + 1] || config.inputs[i];
248+
cache[exposedName] = true;
249+
}
250+
}
251+
const hostDirectives = getHostDirectives(config.directive);
252+
if (hostDirectives !== null) {
253+
queue.push(...hostDirectives);
254+
}
255+
}
256+
}
257+
}
258+
259+
return cache;
218260
}
219261
}
220262

263+
function getHostDirectives(directiveType: any): readonly any[] | null {
264+
if (typeof directiveType === 'function' && 'ɵdir' in directiveType) {
265+
return (directiveType as any).ɵdir.hostDirectives ?? null;
266+
}
267+
return null;
268+
}
269+
221270
function initializeControlFirstCreatePass(tView: TView, tNode: TNode, lView: LView): void {
222271
ngDevMode && assertFirstCreatePass(tView);
223272

@@ -261,6 +310,11 @@ function initializeControlFirstCreatePass(tView: TView, tNode: TNode, lView: LVi
261310
function initializeCustomControlStatus(tView: TView, tNode: TNode): void {
262311
for (let i = tNode.directiveStart; i < tNode.directiveEnd; i++) {
263312
const directiveDef = tView.data[i] as DirectiveDef<unknown>;
313+
// Host directives shouldn't be matched directly since their types are not in
314+
// `directiveToIndex`. We match them through their host component's `hostDirectiveInputs` instead.
315+
if (tNode.directiveToIndex && !tNode.directiveToIndex.has(directiveDef.type)) {
316+
continue;
317+
}
264318
if (hasModelInput(directiveDef, 'value')) {
265319
tNode.flags |= TNodeFlags.isFormValueControl;
266320
tNode.customControlIndex = i;
@@ -272,6 +326,50 @@ function initializeCustomControlStatus(tView: TView, tNode: TNode): void {
272326
return;
273327
}
274328
}
329+
330+
if (
331+
tNode.hostDirectiveInputs !== null &&
332+
tNode.hostDirectiveOutputs !== null &&
333+
tNode.directiveToIndex !== null
334+
) {
335+
const checkModel = (modelName: string, flag: TNodeFlags) => {
336+
const inputs = tNode.hostDirectiveInputs![modelName];
337+
const outputs = tNode.hostDirectiveOutputs![modelName + 'Change'];
338+
if (!inputs || !outputs) {
339+
return false;
340+
}
341+
342+
for (let i = 0; i < inputs.length; i += 2) {
343+
const inputIndex = inputs[i] as number;
344+
for (let j = 0; j < outputs.length; j += 2) {
345+
const outputIndex = outputs[j] as number;
346+
// TODO: invert control flow logic here.
347+
if (inputIndex !== outputIndex) {
348+
continue;
349+
}
350+
for (const data of tNode.directiveToIndex!.values()) {
351+
if (!Array.isArray(data)) {
352+
continue;
353+
}
354+
const [hostIndex, start, end] = data;
355+
if (inputIndex >= start && inputIndex <= end) {
356+
tNode.flags |= flag;
357+
tNode.customControlIndex = hostIndex;
358+
return true;
359+
}
360+
}
361+
}
362+
}
363+
return false;
364+
};
365+
366+
if (checkModel('value', TNodeFlags.isFormValueControl)) {
367+
return;
368+
}
369+
if (checkModel('checked', TNodeFlags.isFormCheckboxControl)) {
370+
return;
371+
}
372+
}
275373
}
276374

277375
/**

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,12 @@ export interface DirectiveDef<T> {
264264

265265
controlDef: ControlDirectiveDef | null;
266266

267+
/**
268+
* Cache of inputs that this custom control directive covers,
269+
* used by the signal forms system.
270+
*/
271+
signalFormsInputPresence: Record<string, boolean> | null;
272+
267273
setInput:
268274
| (<U extends T>(
269275
this: DirectiveDef<U>,

packages/core/src/render3/view/directive_outputs.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ export function createOutputListener(
4242
}
4343

4444
/** Listens to an output on a specific directive. */
45-
function listenToDirectiveOutput(
45+
export function listenToDirectiveOutput(
4646
tNode: TNode,
4747
lView: LView,
4848
target: DirectiveDef<unknown>,

packages/core/test/bundling/create_component/bundle.golden_symbols.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -411,6 +411,7 @@
411411
"getFirstLContainer",
412412
"getFirstNativeNode",
413413
"getGlobalLocale",
414+
"getHostDirectives",
414415
"getInheritedInjectableDef",
415416
"getInitialLViewFlagsFromDef",
416417
"getInjectFlag",
@@ -553,6 +554,7 @@
553554
"leaveViewLight",
554555
"leavingNodes",
555556
"linkTNodeInTView",
557+
"listenToDirectiveOutput",
556558
"listenToDomEvent",
557559
"listenToOutput",
558560
"locateDirectiveOrProvider",

packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -599,6 +599,7 @@
599599
"getFirstLContainer",
600600
"getFirstNativeNode",
601601
"getGlobalLocale",
602+
"getHostDirectives",
602603
"getInheritedInjectableDef",
603604
"getInitialLViewFlagsFromDef",
604605
"getInjectFlag",
@@ -803,6 +804,7 @@
803804
"leavingNodes",
804805
"lengthOrSize",
805806
"linkTNodeInTView",
807+
"listenToDirectiveOutput",
806808
"listenToDomEvent",
807809
"listenToOutput",
808810
"listenerInternal",
@@ -970,6 +972,7 @@
970972
"setCurrentQueryIndex",
971973
"setCurrentTNode",
972974
"setCurrentTNodeAsNotParent",
975+
"setDirectiveInput",
973976
"setDirectiveInputsWhichShadowsStyling",
974977
"setDisabledStateDefault",
975978
"setDocument",

packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -595,6 +595,7 @@
595595
"getFirstLContainer",
596596
"getFirstNativeNode",
597597
"getGlobalLocale",
598+
"getHostDirectives",
598599
"getInheritedInjectableDef",
599600
"getInitialLViewFlagsFromDef",
600601
"getInjectFlag",
@@ -798,6 +799,7 @@
798799
"leavingNodes",
799800
"lengthOrSize",
800801
"linkTNodeInTView",
802+
"listenToDirectiveOutput",
801803
"listenToDomEvent",
802804
"listenToOutput",
803805
"listenerInternal",
@@ -968,6 +970,7 @@
968970
"setCurrentQueryIndex",
969971
"setCurrentTNode",
970972
"setCurrentTNodeAsNotParent",
973+
"setDirectiveInput",
971974
"setDirectiveInputsWhichShadowsStyling",
972975
"setDisabledStateDefault",
973976
"setDocument",

0 commit comments

Comments
 (0)