fix(ivy): ensure component/directive `class` selectors are properly understood (#27849)

Angular allows for `<ng-content>` elements to include a selector which
filters which content-projected entries are inserted into the container
depending on whether or not the selector is matched.

With Ivy this feature has not fully worked due to the massive changes
that took place inside of Ivy's styling algorithm code (which is
responsible for assigning classes and styles to an element). This
fix ensures that content-projection can correctly identify which slot
an element should be placed into when class-based selectors are used.

PR Close #27849
This commit is contained in:
Matias Niemelä 2019-01-11 08:37:51 -08:00 committed by Andrew Kushnir
parent 06e5bf1661
commit e62eeed7d4
4 changed files with 179 additions and 113 deletions

View File

@ -11,6 +11,7 @@ import '../util/ng_dev_mode';
import {assertDefined, assertNotEqual} from '../util/assert'; import {assertDefined, assertNotEqual} from '../util/assert';
import {AttributeMarker, TAttributes, TNode, TNodeType, unusedValueExportToPlacateAjd as unused1} from './interfaces/node'; import {AttributeMarker, TAttributes, TNode, TNodeType, unusedValueExportToPlacateAjd as unused1} from './interfaces/node';
import {CssSelector, CssSelectorList, NG_PROJECT_AS_ATTR_NAME, SelectorFlags, unusedValueExportToPlacateAjd as unused2} from './interfaces/projection'; import {CssSelector, CssSelectorList, NG_PROJECT_AS_ATTR_NAME, SelectorFlags, unusedValueExportToPlacateAjd as unused2} from './interfaces/projection';
import {getInitialClassNameValue} from './styling/class_and_style_bindings';
const unusedValueToPlacateAjd = unused1 + unused2; const unusedValueToPlacateAjd = unused1 + unused2;
@ -58,7 +59,6 @@ function hasTagAndTypeMatch(
export function isNodeMatchingSelector( export function isNodeMatchingSelector(
tNode: TNode, selector: CssSelector, isProjectionMode: boolean): boolean { tNode: TNode, selector: CssSelector, isProjectionMode: boolean): boolean {
ngDevMode && assertDefined(selector[0], 'Selector should have a tag name'); ngDevMode && assertDefined(selector[0], 'Selector should have a tag name');
let mode: SelectorFlags = SelectorFlags.ELEMENT; let mode: SelectorFlags = SelectorFlags.ELEMENT;
const nodeAttrs = tNode.attrs !; const nodeAttrs = tNode.attrs !;
const selectOnlyMarkerIdx = nodeAttrs ? nodeAttrs.indexOf(AttributeMarker.SelectOnly) : -1; const selectOnlyMarkerIdx = nodeAttrs ? nodeAttrs.indexOf(AttributeMarker.SelectOnly) : -1;
@ -92,7 +92,19 @@ export function isNodeMatchingSelector(
skipToNextSelector = true; skipToNextSelector = true;
} }
} else { } else {
const attrName = mode & SelectorFlags.CLASS ? 'class' : current; const selectorAttrValue = mode & SelectorFlags.CLASS ? current : selector[++i];
// special case for matching against classes when a tNode has been instantiated with
// class and style values as separate attribute values (e.g. ['title', CLASS, 'foo'])
if ((mode & SelectorFlags.CLASS) && tNode.stylingTemplate) {
if (!isCssClassMatching(readClassValueFromTNode(tNode), selectorAttrValue as string)) {
if (isPositive(mode)) return false;
skipToNextSelector = true;
}
continue;
}
const attrName = (mode & SelectorFlags.CLASS) ? 'class' : current;
const attrIndexInNode = findAttrIndexInNode(attrName, nodeAttrs); const attrIndexInNode = findAttrIndexInNode(attrName, nodeAttrs);
if (attrIndexInNode === -1) { if (attrIndexInNode === -1) {
@ -101,7 +113,6 @@ export function isNodeMatchingSelector(
continue; continue;
} }
const selectorAttrValue = mode & SelectorFlags.CLASS ? current : selector[++i];
if (selectorAttrValue !== '') { if (selectorAttrValue !== '') {
let nodeAttrValue: string; let nodeAttrValue: string;
const maybeAttrName = nodeAttrs[attrIndexInNode]; const maybeAttrName = nodeAttrs[attrIndexInNode];
@ -113,8 +124,10 @@ export function isNodeMatchingSelector(
'We do not match directives on namespaced attributes'); 'We do not match directives on namespaced attributes');
nodeAttrValue = nodeAttrs[attrIndexInNode + 1] as string; nodeAttrValue = nodeAttrs[attrIndexInNode + 1] as string;
} }
if (mode & SelectorFlags.CLASS &&
!isCssClassMatching(nodeAttrValue as string, selectorAttrValue as string) || const compareAgainstClassName = mode & SelectorFlags.CLASS ? nodeAttrValue : null;
if (compareAgainstClassName &&
!isCssClassMatching(compareAgainstClassName, selectorAttrValue as string) ||
mode & SelectorFlags.ATTRIBUTE && selectorAttrValue !== nodeAttrValue) { mode & SelectorFlags.ATTRIBUTE && selectorAttrValue !== nodeAttrValue) {
if (isPositive(mode)) return false; if (isPositive(mode)) return false;
skipToNextSelector = true; skipToNextSelector = true;
@ -130,6 +143,16 @@ function isPositive(mode: SelectorFlags): boolean {
return (mode & SelectorFlags.NOT) === 0; return (mode & SelectorFlags.NOT) === 0;
} }
function readClassValueFromTNode(tNode: TNode): string {
// comparing against CSS class values is complex because the compiler doesn't place them as
// regular attributes when an element is created. Instead, the classes (and styles for
// that matter) are placed in a special styling context that is used for resolving all
// class/style values across static attributes, [style]/[class] and [style.prop]/[class.name]
// bindings. Therefore if and when the styling context exists then the class values are to be
// extracted by the context helper code below...
return tNode.stylingTemplate ? getInitialClassNameValue(tNode.stylingTemplate) : '';
}
/** /**
* Examines an attributes definition array from a node to find the index of the * Examines an attributes definition array from a node to find the index of the
* attribute with the specified name. * attribute with the specified name.

View File

@ -1022,6 +1022,9 @@
{ {
"name": "queueComponentIndexForCheck" "name": "queueComponentIndexForCheck"
}, },
{
"name": "readClassValueFromTNode"
},
{ {
"name": "readElementValue" "name": "readElementValue"
}, },

View File

@ -81,8 +81,21 @@ describe('projection', () => {
expect(main.nativeElement).toHaveText(''); expect(main.nativeElement).toHaveText('');
}); });
fixmeIvy('FW-833: Directive / projected node matching against class name') it('should project a single class-based tag', () => {
.it('should support multiple content tags', () => { TestBed.configureTestingModule({declarations: [SingleContentTagComponent]});
TestBed.overrideComponent(MainComp, {
set: {
template: '<single-content-tag>' +
'<div class="target">I AM PROJECTED</div>' +
'</single-content-tag>'
}
});
const main = TestBed.createComponent(MainComp);
expect(main.nativeElement).toHaveText('I AM PROJECTED');
});
fixmeIvy('unknown').it('should support multiple content tags', () => {
TestBed.configureTestingModule({declarations: [MultipleContentTagsComponent]}); TestBed.configureTestingModule({declarations: [MultipleContentTagsComponent]});
TestBed.overrideComponent(MainComp, { TestBed.overrideComponent(MainComp, {
set: { set: {
@ -182,8 +195,7 @@ describe('projection', () => {
expect(main.nativeElement).toHaveText('OUTER(INNER(INNERINNER(A,BC)))'); expect(main.nativeElement).toHaveText('OUTER(INNER(INNERINNER(A,BC)))');
}); });
fixmeIvy('FW-833: Directive / projected node matching against class name') fixmeIvy('unknown').it('should redistribute when the shadow dom changes', () => {
.it('should redistribute when the shadow dom changes', () => {
TestBed.configureTestingModule( TestBed.configureTestingModule(
{declarations: [ConditionalContentComponent, ManualViewportDirective]}); {declarations: [ConditionalContentComponent, ManualViewportDirective]});
TestBed.overrideComponent(MainComp, { TestBed.overrideComponent(MainComp, {
@ -290,11 +302,9 @@ describe('projection', () => {
expect(main.nativeElement).toHaveText('SIMPLE()START(A)END'); expect(main.nativeElement).toHaveText('SIMPLE()START(A)END');
}); });
fixmeIvy('FW-833: Directive / projected node matching against class name') fixmeIvy('unknown').it('should support moving ng-content around', () => {
.it('should support moving ng-content around', () => { TestBed.configureTestingModule(
TestBed.configureTestingModule({ {declarations: [ConditionalContentComponent, ProjectDirective, ManualViewportDirective]});
declarations: [ConditionalContentComponent, ProjectDirective, ManualViewportDirective]
});
TestBed.overrideComponent(MainComp, { TestBed.overrideComponent(MainComp, {
set: { set: {
template: '<conditional-content>' + template: '<conditional-content>' +
@ -533,8 +543,7 @@ describe('projection', () => {
expect(main.nativeElement).toHaveText('B(A)'); expect(main.nativeElement).toHaveText('B(A)');
}); });
fixmeIvy('FW-833: Directive / projected node matching against class name') fixmeIvy('unknown').it('should project filled view containers into a view container', () => {
.it('should project filled view containers into a view container', () => {
TestBed.configureTestingModule( TestBed.configureTestingModule(
{declarations: [ConditionalContentComponent, ManualViewportDirective]}); {declarations: [ConditionalContentComponent, ManualViewportDirective]});
TestBed.overrideComponent(MainComp, { TestBed.overrideComponent(MainComp, {
@ -626,6 +635,13 @@ class Empty {
class MultipleContentTagsComponent { class MultipleContentTagsComponent {
} }
@Component({
selector: 'single-content-tag',
template: '<ng-content SELECT=".target"></ng-content>',
})
class SingleContentTagComponent {
}
@Directive({selector: '[manual]'}) @Directive({selector: '[manual]'})
class ManualViewportDirective { class ManualViewportDirective {
constructor(public vc: ViewContainerRef, public templateRef: TemplateRef<Object>) {} constructor(public vc: ViewContainerRef, public templateRef: TemplateRef<Object>) {}

View File

@ -10,6 +10,7 @@ import {AttributeMarker, TAttributes, TNode, TNodeType} from '../../src/render3/
import {CssSelector, CssSelectorList, NG_PROJECT_AS_ATTR_NAME, SelectorFlags,} from '../../src/render3/interfaces/projection'; import {CssSelector, CssSelectorList, NG_PROJECT_AS_ATTR_NAME, SelectorFlags,} from '../../src/render3/interfaces/projection';
import {getProjectAsAttrValue, isNodeMatchingSelectorList, isNodeMatchingSelector} from '../../src/render3/node_selector_matcher'; import {getProjectAsAttrValue, isNodeMatchingSelectorList, isNodeMatchingSelector} from '../../src/render3/node_selector_matcher';
import {initializeStaticContext} from '../../src/render3/styling/class_and_style_bindings';
import {createTNode} from '@angular/core/src/render3/instructions'; import {createTNode} from '@angular/core/src/render3/instructions';
import {getLView} from '@angular/core/src/render3/state'; import {getLView} from '@angular/core/src/render3/state';
@ -18,9 +19,12 @@ function testLStaticData(tagName: string, attrs: TAttributes | null): TNode {
} }
describe('css selector matching', () => { describe('css selector matching', () => {
function isMatching(tagName: string, attrs: TAttributes | null, selector: CssSelector): boolean { function isMatching(
return isNodeMatchingSelector( tagName: string, attrsOrTNode: TAttributes | TNode | null, selector: CssSelector): boolean {
createTNode(getLView(), TNodeType.Element, 0, tagName, attrs, null), selector, false); const tNode = (!attrsOrTNode || Array.isArray(attrsOrTNode)) ?
createTNode(getLView(), TNodeType.Element, 0, tagName, attrsOrTNode as TAttributes, null) :
(attrsOrTNode as TNode);
return isNodeMatchingSelector(tNode, selector, false);
} }
describe('isNodeMatchingSimpleSelector', () => { describe('isNodeMatchingSimpleSelector', () => {
@ -298,6 +302,26 @@ describe('css selector matching', () => {
// <div class="foo"> // <div class="foo">
expect(isMatching('div', ['class', 'foo'], selector)).toBeFalsy(); expect(isMatching('div', ['class', 'foo'], selector)).toBeFalsy();
}); });
it('should match against a class value before and after the styling context is created',
() => {
// selector: 'div.abc'
const selector = ['div', SelectorFlags.CLASS, 'abc'];
const tNode = createTNode(getLView(), TNodeType.Element, 0, 'div', [], null);
// <div> (without attrs or styling context)
expect(isMatching('div', tNode, selector)).toBeFalsy();
// <div class="abc"> (with attrs but without styling context)
tNode.attrs = ['class', 'abc'];
tNode.stylingTemplate = null;
expect(isMatching('div', tNode, selector)).toBeTruthy();
// <div class="abc"> (with styling context but without attrs)
tNode.stylingTemplate = initializeStaticContext([AttributeMarker.Classes, 'abc']);
tNode.attrs = null;
expect(isMatching('div', tNode, selector)).toBeTruthy();
});
}); });
}); });