Skip to content

Commit 243ccce

Browse files
JoostKatscott
authored andcommitted
fix(core): exclude class attribute intended for projection matching from directive matching (#54800)
This commit resolves a regression that was introduced when the compiler switched from `TemplateDefinitionBuilder` (TDB) to the template pipeline (TP) compiler. The TP compiler has changed the output of ```html if (false) { <div class="test"></div> } ``` from ```ts defineComponent({ consts: [['class', 'test'], [AttributeMarker.Classes, 'test']], template: function(rf) { if (rf & 1) { ɵɵtemplate(0, App_Conditional_0_Template, 2, 0, "div", 0) } } }); ``` to ```ts defineComponent({ consts: [[AttributeMarker.Classes, 'test']], template: function(rf) { if (rf & 1) { ɵɵtemplate(0, App_Conditional_0_Template, 2, 0, "div", 0) } } }); ``` The last argument to the `ɵɵtemplate` instruction (0 in both compilation outputs) corresponds with the index in `consts` of the element's attribute's, and we observe how TP has allocated only a single attribute array for the `div`, where there used to be two `consts` entries with TDB. Consequently, the `ɵɵtemplate` instruction is now effectively referencing a different attributes array, where the distinction between the `"class"` attribute vs. the `AttributeMarker.Classes` distinction affects the behavior: TP's emit causes the runtime to incorrectly match a directive with `selector: '.foo'` to be instantiated on the `ɵɵtemplate` instruction as if it corresponds with a structural directive! Instead of changing TP to align with TDB's emit, this commit updates the runtime instead. This uncovered an inconsistency in selector matching for class names, where there used to be two paths dealing with class matching: 1. The first check was commented to be a special-case for class matching, implemented in `isCssClassMatching`. 2. The second path was part of the main selector matching algorithm, where `findAttrIndexInNode` was being used to find the start position in `tNode.attrs` to match the selector's value against. The second path only considers `AttributeMarker.Classes` values if matching for content projection, OR of the `TNode` is not an inline template. The special-case in path 1 however does not make that distinction, so it would consider the `AttributeMarker.Classes` binding as a selector match, incorrectly causing a directive to match on the `ɵɵtemplate` itself. The second path was also buggy for class bindings, as the return value of `classIndexOf` was incorrectly negated: it considered a matching class attribute as non-matching and vice-versa. This bug was not observable because of another issue, where the class-handling in part 2 was never relevant because of the special-case in part 1. This commit separates path 1 entirely from path 2 and removes the buggy class-matching logic in part 2, as that is entirely handled by path 1 anyway. `isCssClassMatching` is updated to exclude class bindings from being matched for inline templates. Fixes #54798 PR Close #54800
1 parent e8badec commit 243ccce

3 files changed

Lines changed: 113 additions & 30 deletions

File tree

packages/core/src/render3/node_selector_matcher.ts

Lines changed: 23 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -10,28 +10,25 @@ import '../util/ng_dev_mode';
1010

1111
import {assertDefined, assertEqual, assertNotEqual} from '../util/assert';
1212

13+
import {AttributeMarker} from './interfaces/attribute_marker';
1314
import {TAttributes, TNode, TNodeType} from './interfaces/node';
1415
import {CssSelector, CssSelectorList, SelectorFlags} from './interfaces/projection';
1516
import {classIndexOf} from './styling/class_differ';
1617
import {isNameOnlyAttributeMarker} from './util/attrs_utils';
17-
import { AttributeMarker } from './interfaces/attribute_marker';
1818

1919
const NG_TEMPLATE_SELECTOR = 'ng-template';
2020

2121
/**
2222
* Search the `TAttributes` to see if it contains `cssClassToMatch` (case insensitive)
2323
*
24+
* @param tNode static data of the node to match
2425
* @param attrs `TAttributes` to search through.
2526
* @param cssClassToMatch class to match (lowercase)
2627
* @param isProjectionMode Whether or not class matching should look into the attribute `class` in
2728
* addition to the `AttributeMarker.Classes`.
2829
*/
2930
function isCssClassMatching(
30-
attrs: TAttributes, cssClassToMatch: string, isProjectionMode: boolean): boolean {
31-
// TODO(misko): The fact that this function needs to know about `isProjectionMode` seems suspect.
32-
// It is strange to me that sometimes the class information comes in form of `class` attribute
33-
// and sometimes in form of `AttributeMarker.Classes`. Some investigation is needed to determine
34-
// if that is the right behavior.
31+
tNode: TNode, attrs: TAttributes, cssClassToMatch: string, isProjectionMode: boolean): boolean {
3532
ngDevMode &&
3633
assertEqual(
3734
cssClassToMatch, cssClassToMatch.toLowerCase(), 'Class name expected to be lowercase.');
@@ -51,14 +48,20 @@ function isCssClassMatching(
5148
}
5249
}
5350
} else if (item === AttributeMarker.Classes) {
51+
if (!isProjectionMode && isInlineTemplate(tNode)) {
52+
// Matching directives (i.e. when not matching for projection mode) should not consider the
53+
// class bindings that are present on inline templates, as those class bindings only target
54+
// the root node of the template, not the template itself.
55+
return false;
56+
}
5457
// We found the classes section. Start searching for the class.
5558
while (i < attrs.length && typeof (item = attrs[i++]) == 'string') {
5659
// while we have strings
5760
if (item.toLowerCase() === cssClassToMatch) return true;
5861
}
5962
return false;
6063
} else if (typeof item === 'number') {
61-
// We've came across a first marker, which indicates
64+
// We've come across a first marker, which indicates
6265
// that the implicit attribute section is over.
6366
isImplicitAttrsSection = false;
6467
}
@@ -96,7 +99,7 @@ function hasTagAndTypeMatch(
9699
/**
97100
* A utility function to match an Ivy node static data against a simple CSS selector
98101
*
99-
* @param node static data of the node to match
102+
* @param tNode static data of the node to match
100103
* @param selector The selector to try matching against the node.
101104
* @param isProjectionMode if `true` we are matching for content projection, otherwise we are doing
102105
* directive matching.
@@ -106,10 +109,10 @@ export function isNodeMatchingSelector(
106109
tNode: TNode, selector: CssSelector, isProjectionMode: boolean): boolean {
107110
ngDevMode && assertDefined(selector[0], 'Selector should have a tag name');
108111
let mode: SelectorFlags = SelectorFlags.ELEMENT;
109-
const nodeAttrs = tNode.attrs || [];
112+
const nodeAttrs = tNode.attrs;
110113

111114
// Find the index of first attribute that has no value, only a name.
112-
const nameOnlyMarkerIdx = getNameOnlyMarkerIndex(nodeAttrs);
115+
const nameOnlyMarkerIdx = nodeAttrs !== null ? getNameOnlyMarkerIndex(nodeAttrs) : 0;
113116

114117
// When processing ":not" selectors, we skip to the next ":not" if the
115118
// current one doesn't match
@@ -139,22 +142,15 @@ export function isNodeMatchingSelector(
139142
if (isPositive(mode)) return false;
140143
skipToNextSelector = true;
141144
}
142-
} else {
143-
const selectorAttrValue = mode & SelectorFlags.CLASS ? current : selector[++i];
144-
145-
// special case for matching against classes when a tNode has been instantiated with
146-
// class and style values as separate attribute values (e.g. ['title', CLASS, 'foo'])
147-
if ((mode & SelectorFlags.CLASS) && tNode.attrs !== null) {
148-
if (!isCssClassMatching(tNode.attrs, selectorAttrValue as string, isProjectionMode)) {
149-
if (isPositive(mode)) return false;
150-
skipToNextSelector = true;
151-
}
152-
continue;
145+
} else if (mode & SelectorFlags.CLASS) {
146+
if (nodeAttrs === null || !isCssClassMatching(tNode, nodeAttrs, current, isProjectionMode)) {
147+
if (isPositive(mode)) return false;
148+
skipToNextSelector = true;
153149
}
154-
155-
const attrName = (mode & SelectorFlags.CLASS) ? 'class' : current;
150+
} else {
151+
const selectorAttrValue = selector[++i];
156152
const attrIndexInNode =
157-
findAttrIndexInNode(attrName, nodeAttrs, isInlineTemplate(tNode), isProjectionMode);
153+
findAttrIndexInNode(current, nodeAttrs, isInlineTemplate(tNode), isProjectionMode);
158154

159155
if (attrIndexInNode === -1) {
160156
if (isPositive(mode)) return false;
@@ -169,18 +165,15 @@ export function isNodeMatchingSelector(
169165
} else {
170166
ngDevMode &&
171167
assertNotEqual(
172-
nodeAttrs[attrIndexInNode], AttributeMarker.NamespaceURI,
168+
nodeAttrs![attrIndexInNode], AttributeMarker.NamespaceURI,
173169
'We do not match directives on namespaced attributes');
174170
// we lowercase the attribute value to be able to match
175171
// selectors without case-sensitivity
176172
// (selectors are already in lowercase when generated)
177-
nodeAttrValue = (nodeAttrs[attrIndexInNode + 1] as string).toLowerCase();
173+
nodeAttrValue = (nodeAttrs![attrIndexInNode + 1] as string).toLowerCase();
178174
}
179175

180-
const compareAgainstClassName = mode & SelectorFlags.CLASS ? nodeAttrValue : null;
181-
if (compareAgainstClassName &&
182-
classIndexOf(compareAgainstClassName, selectorAttrValue as string, 0) !== -1 ||
183-
mode & SelectorFlags.ATTRIBUTE && selectorAttrValue !== nodeAttrValue) {
176+
if (mode & SelectorFlags.ATTRIBUTE && selectorAttrValue !== nodeAttrValue) {
184177
if (isPositive(mode)) return false;
185178
skipToNextSelector = true;
186179
}

packages/core/test/acceptance/control_flow_if_spec.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -659,5 +659,52 @@ describe('control flow - if', () => {
659659
expect(directiveCount).toBe(1);
660660
expect(fixture.nativeElement.textContent).toBe('Main: Before After Slot: foo');
661661
});
662+
663+
it('should not match a directive with a class-based selector only meant for content projection',
664+
() => {
665+
let directiveCount = 0;
666+
667+
@Component({
668+
standalone: true,
669+
selector: 'test',
670+
template: 'Main: <ng-content/> Slot: <ng-content select=".foo"/>',
671+
})
672+
class TestComponent {
673+
}
674+
675+
@Directive({
676+
selector: '.foo',
677+
standalone: true,
678+
})
679+
class TemplateDirective {
680+
constructor() {
681+
directiveCount++;
682+
}
683+
}
684+
685+
@Component({
686+
standalone: true,
687+
imports: [TestComponent, TemplateDirective],
688+
template: `<test>Before @if (condition) {
689+
<div class="foo">foo</div>
690+
} After</test>
691+
`
692+
})
693+
class App {
694+
condition = false;
695+
}
696+
697+
const fixture = TestBed.createComponent(App);
698+
fixture.detectChanges();
699+
700+
expect(directiveCount).toBe(0);
701+
expect(fixture.nativeElement.textContent).toBe('Main: Before After Slot: ');
702+
703+
fixture.componentInstance.condition = true;
704+
fixture.detectChanges();
705+
706+
expect(directiveCount).toBe(1);
707+
expect(fixture.nativeElement.textContent).toBe('Main: Before After Slot: foo');
708+
});
662709
});
663710
});

packages/core/test/acceptance/directive_spec.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,49 @@ describe('directives', () => {
186186
expect(nodesWithDirective.length).toBe(1);
187187
});
188188

189+
it('should match class selectors on ng-template', () => {
190+
@Directive({selector: '.titleDir'})
191+
class TitleClassDirective {
192+
}
193+
194+
TestBed.configureTestingModule({declarations: [TestComponent, TitleClassDirective]});
195+
TestBed.overrideTemplate(TestComponent, `
196+
<ng-template class="titleDir"></ng-template>
197+
`);
198+
199+
const fixture = TestBed.createComponent(TestComponent);
200+
const nodesWithDirective =
201+
fixture.debugElement.queryAllNodes(By.directive(TitleClassDirective));
202+
203+
expect(nodesWithDirective.length).toBe(1);
204+
});
205+
206+
it('should NOT match class selectors on ng-template created by * syntax', () => {
207+
@Directive({selector: '.titleDir'})
208+
class TitleClassDirective {
209+
}
210+
211+
@Component({selector: 'test-cmp', template: `<div *ngIf="condition" class="titleDir"></div>`})
212+
class TestCmp {
213+
condition = false;
214+
}
215+
216+
TestBed.configureTestingModule({declarations: [TestCmp, TitleClassDirective]});
217+
218+
const fixture = TestBed.createComponent(TestCmp);
219+
220+
const initialNodesWithDirective =
221+
fixture.debugElement.queryAllNodes(By.directive(TitleClassDirective));
222+
expect(initialNodesWithDirective.length).toBe(0);
223+
224+
fixture.componentInstance.condition = true;
225+
fixture.detectChanges();
226+
227+
const changedNodesWithDirective =
228+
fixture.debugElement.queryAllNodes(By.directive(TitleClassDirective));
229+
expect(changedNodesWithDirective.length).toBe(1);
230+
});
231+
189232
it('should NOT match classes to directive selectors', () => {
190233
TestBed.configureTestingModule({declarations: [TestComponent, TitleDirective]});
191234
TestBed.overrideTemplate(TestComponent, `

0 commit comments

Comments
 (0)