Skip to content

Commit f95b8ce

Browse files
AndrewKushniralxhub
authored andcommitted
fix(ivy): add attributes and classes to host elements based on selector (#34481)
In View Engine, host element of dynamically created component received attributes and classes extracted from component's selector. For example, if component selector is `[attr] .class`, the `attr` attribute and `.class` class will be add to host element. This commit adds similar logic to Ivy, to make sure this behavior is aligned with View Engine. PR Close #34481
1 parent 3f4e02b commit f95b8ce

5 files changed

Lines changed: 206 additions & 16 deletions

File tree

packages/core/src/render3/component.ts

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import {CLEAN_PROMISE, addHostBindingsToExpandoInstructions, addToViewTree, crea
2020
import {ComponentDef, ComponentType, RenderFlags} from './interfaces/definition';
2121
import {TElementNode, TNode, TNodeType} from './interfaces/node';
2222
import {PlayerHandler} from './interfaces/player';
23-
import {RElement, Renderer3, RendererFactory3, domRendererFactory3, isProceduralRenderer} from './interfaces/renderer';
23+
import {RElement, Renderer3, RendererFactory3, domRendererFactory3} from './interfaces/renderer';
2424
import {CONTEXT, HEADER_OFFSET, LView, LViewFlags, RootContext, RootContextFlags, TVIEW, TViewType} from './interfaces/view';
2525
import {writeDirectClass, writeDirectStyle} from './node_manipulation';
2626
import {enterView, getPreviousOrParentTNode, leaveView, setSelectedIndex} from './state';
@@ -139,7 +139,7 @@ export function renderComponent<T>(
139139
try {
140140
if (rendererFactory.begin) rendererFactory.begin();
141141
const componentView = createRootComponentView(
142-
hostRNode, componentDef, rootView, rendererFactory, renderer, null, sanitizer);
142+
hostRNode, componentDef, rootView, rendererFactory, renderer, sanitizer);
143143
component = createRootComponent(
144144
componentView, componentDef, rootView, rootContext, opts.hostFeatures || null);
145145

@@ -169,8 +169,8 @@ export function renderComponent<T>(
169169
*/
170170
export function createRootComponentView(
171171
rNode: RElement | null, def: ComponentDef<any>, rootView: LView,
172-
rendererFactory: RendererFactory3, hostRenderer: Renderer3, addVersion: string | null,
173-
sanitizer: Sanitizer | null): LView {
172+
rendererFactory: RendererFactory3, hostRenderer: Renderer3,
173+
sanitizer?: Sanitizer | null): LView {
174174
const tView = rootView[TVIEW];
175175
ngDevMode && assertDataInRange(rootView, 0 + HEADER_OFFSET);
176176
rootView[0 + HEADER_OFFSET] = rNode;
@@ -188,14 +188,8 @@ export function createRootComponentView(
188188
}
189189
}
190190
}
191-
const viewRenderer = rendererFactory.createRenderer(rNode, def);
192-
if (rNode !== null && addVersion) {
193-
ngDevMode && ngDevMode.rendererSetAttribute++;
194-
isProceduralRenderer(hostRenderer) ?
195-
hostRenderer.setAttribute(rNode, 'ng-version', addVersion) :
196-
rNode.setAttribute('ng-version', addVersion);
197-
}
198191

192+
const viewRenderer = rendererFactory.createRenderer(rNode, def);
199193
const componentView = createLView(
200194
rootView, getOrCreateTComponentView(def), null,
201195
def.onPush ? LViewFlags.Dirty : LViewFlags.CheckAlways, rootView[HEADER_OFFSET], tNode,

packages/core/src/render3/component_ref.ts

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,10 @@ import {TContainerNode, TElementContainerNode, TElementNode} from './interfaces/
3030
import {RNode, RendererFactory3, domRendererFactory3} from './interfaces/renderer';
3131
import {LView, LViewFlags, TVIEW, TViewType} from './interfaces/view';
3232
import {MATH_ML_NAMESPACE, SVG_NAMESPACE} from './namespaces';
33-
import {stringifyCSSSelectorList} from './node_selector_matcher';
33+
import {writeDirectClass} from './node_manipulation';
34+
import {extractAttrsAndClassesFromSelector, stringifyCSSSelectorList} from './node_selector_matcher';
3435
import {enterView, leaveView} from './state';
36+
import {setUpAttributes} from './util/attrs_utils';
3537
import {defaultScheduler} from './util/misc_utils';
3638
import {getTNode} from './util/view_utils';
3739
import {createElementRef} from './view_engine_compatibility';
@@ -165,7 +167,6 @@ export class ComponentFactory<T> extends viewEngine_ComponentFactory<T> {
165167
const rootLView = createLView(
166168
null, rootTView, rootContext, rootFlags, null, null, rendererFactory, hostRenderer,
167169
sanitizer, rootViewInjector);
168-
const addVersion = rootSelectorOrNode && hostRNode ? VERSION.full : null;
169170

170171
// rootView is the parent when bootstrapping
171172
// TODO(misko): it looks like we are entering view here but we don't really need to as
@@ -179,7 +180,24 @@ export class ComponentFactory<T> extends viewEngine_ComponentFactory<T> {
179180

180181
try {
181182
const componentView = createRootComponentView(
182-
hostRNode, this.componentDef, rootLView, rendererFactory, hostRenderer, addVersion, null);
183+
hostRNode, this.componentDef, rootLView, rendererFactory, hostRenderer);
184+
if (hostRNode) {
185+
if (rootSelectorOrNode) {
186+
setUpAttributes(hostRenderer, hostRNode, ['ng-version', VERSION.full]);
187+
} else {
188+
// If host element is created as a part of this function call (i.e. `rootSelectorOrNode`
189+
// is not defined), also apply attributes and classes extracted from component selector.
190+
// Extract attributes and classes from the first selector only to match VE behavior.
191+
const {attrs, classes} =
192+
extractAttrsAndClassesFromSelector(this.componentDef.selectors[0]);
193+
if (attrs) {
194+
setUpAttributes(hostRenderer, hostRNode, attrs);
195+
}
196+
if (classes && classes.length > 0) {
197+
writeDirectClass(hostRenderer, hostRNode, classes.join(' '));
198+
}
199+
}
200+
}
183201

184202
tElementNode = getTNode(rootLView[TVIEW], 0) as TElementNode;
185203

packages/core/src/render3/node_selector_matcher.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -388,4 +388,42 @@ function stringifyCSSSelector(selector: CssSelector): string {
388388
*/
389389
export function stringifyCSSSelectorList(selectorList: CssSelectorList): string {
390390
return selectorList.map(stringifyCSSSelector).join(',');
391+
}
392+
393+
/**
394+
* Extracts attributes and classes information from a given CSS selector.
395+
*
396+
* This function is used while creating a component dynamically. In this case, the host element
397+
* (that is created dynamically) should contain attributes and classes specified in component's CSS
398+
* selector.
399+
*
400+
* @param selector CSS selector in parsed form (in a form of array)
401+
* @returns object with `attrs` and `classes` fields that contain extracted information
402+
*/
403+
export function extractAttrsAndClassesFromSelector(selector: CssSelector):
404+
{attrs: string[], classes: string[]} {
405+
const attrs: string[] = [];
406+
const classes: string[] = [];
407+
let i = 1;
408+
let mode = SelectorFlags.ATTRIBUTE;
409+
while (i < selector.length) {
410+
let valueOrMarker = selector[i];
411+
if (typeof valueOrMarker === 'string') {
412+
if (mode === SelectorFlags.ATTRIBUTE) {
413+
if (valueOrMarker !== '') {
414+
attrs.push(valueOrMarker, selector[++i] as string);
415+
}
416+
} else if (mode === SelectorFlags.CLASS) {
417+
classes.push(valueOrMarker);
418+
}
419+
} else {
420+
// According to CssSelector spec, once we come across `SelectorFlags.NOT` flag, the negative
421+
// mode is maintained for remaining chunks of a selector. Since attributes and classes are
422+
// extracted only for "positive" part of the selector, we can stop here.
423+
if (!isPositive(mode)) break;
424+
mode = valueOrMarker;
425+
}
426+
i++;
427+
}
428+
return {attrs, classes};
391429
}

packages/core/test/acceptance/view_container_ref_spec.ts

Lines changed: 93 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
import {CommonModule, DOCUMENT} from '@angular/common';
1010
import {computeMsgId} from '@angular/compiler';
11-
import {Compiler, Component, ComponentFactoryResolver, Directive, DoCheck, ElementRef, EmbeddedViewRef, ErrorHandler, NO_ERRORS_SCHEMA, NgModule, OnInit, Pipe, PipeTransform, QueryList, Renderer2, RendererFactory2, RendererType2, Sanitizer, TemplateRef, ViewChild, ViewChildren, ViewContainerRef, ɵsetDocument} from '@angular/core';
11+
import {Compiler, Component, ComponentFactoryResolver, Directive, DoCheck, ElementRef, EmbeddedViewRef, ErrorHandler, Injector, NO_ERRORS_SCHEMA, NgModule, OnInit, Pipe, PipeTransform, QueryList, RendererFactory2, RendererType2, Sanitizer, TemplateRef, ViewChild, ViewChildren, ViewContainerRef, ɵsetDocument} from '@angular/core';
1212
import {Input} from '@angular/core/src/metadata';
1313
import {ngDevModeResetPerfCounters} from '@angular/core/src/util/ng_dev_mode';
1414
import {TestBed, TestComponentRenderer} from '@angular/core/testing';
@@ -251,6 +251,98 @@ describe('ViewContainerRef', () => {
251251
// Also test with selector that has element name in uppercase
252252
runTestWithSelectors('SVG[some-attr]', 'MATH[some-attr]');
253253
});
254+
255+
it('should apply attributes and classes to host element based on selector', () => {
256+
@Component({
257+
selector: '[attr-a=a].class-a:not(.class-b):not([attr-b=b]).class-c[attr-c]',
258+
template: 'Hello'
259+
})
260+
class HelloComp {
261+
}
262+
263+
@NgModule({entryComponents: [HelloComp], declarations: [HelloComp]})
264+
class HelloCompModule {
265+
}
266+
267+
@Component({
268+
template: `
269+
<div id="factory" attr-a="a-original" class="class-original"></div>
270+
<div id="vcr">
271+
<ng-container #container></ng-container>
272+
</div>
273+
`
274+
})
275+
class TestComp {
276+
@ViewChild('container', {read: ViewContainerRef}) vcRef !: ViewContainerRef;
277+
278+
private helloCompFactory = this.cfr.resolveComponentFactory(HelloComp);
279+
280+
constructor(public cfr: ComponentFactoryResolver, public injector: Injector) {}
281+
282+
createComponentViaVCRef() {
283+
this.vcRef.createComponent(this.helloCompFactory); //
284+
}
285+
286+
createComponentViaFactory() {
287+
this.helloCompFactory.create(this.injector, undefined, '#factory');
288+
}
289+
}
290+
291+
TestBed.configureTestingModule({declarations: [TestComp], imports: [HelloCompModule]});
292+
const fixture = TestBed.createComponent(TestComp);
293+
fixture.detectChanges();
294+
fixture.componentInstance.createComponentViaVCRef();
295+
fixture.componentInstance.createComponentViaFactory();
296+
fixture.detectChanges();
297+
298+
// Verify host element for a component created via `vcRef.createComponent` method
299+
const vcrHostElement = fixture.nativeElement.querySelector('#vcr > div');
300+
301+
expect(vcrHostElement.classList.contains('class-a')).toBe(true);
302+
// `class-b` should not be present, since it's wrapped in `:not()` selector
303+
expect(vcrHostElement.classList.contains('class-b')).toBe(false);
304+
expect(vcrHostElement.classList.contains('class-c')).toBe(true);
305+
306+
expect(vcrHostElement.getAttribute('attr-a')).toBe('a');
307+
// `attr-b` should not be present, since it's wrapped in `:not()` selector
308+
expect(vcrHostElement.getAttribute('attr-b')).toBe(null);
309+
expect(vcrHostElement.getAttribute('attr-c')).toBe('');
310+
311+
// Verify host element for a component created using `factory.createComponent` method when
312+
// also passing element selector as an argument
313+
const factoryHostElement = fixture.nativeElement.querySelector('#factory');
314+
315+
if (ivyEnabled) {
316+
// In Ivy, if selector is passed when component is created, matched host node (found using
317+
// this selector) retains all attrs/classes and selector-based attrs/classes should *not* be
318+
// added
319+
320+
// Verify original attrs and classes are still present
321+
expect(factoryHostElement.classList.contains('class-original')).toBe(true);
322+
expect(factoryHostElement.getAttribute('attr-a')).toBe('a-original');
323+
324+
// Make sure selector-based attrs and classes were not added to the host element
325+
expect(factoryHostElement.classList.contains('class-a')).toBe(false);
326+
expect(factoryHostElement.getAttribute('attr-c')).toBe(null);
327+
328+
} else {
329+
// In View Engine, selector-based attrs/classes are *always* added to the host element
330+
331+
expect(factoryHostElement.classList.contains('class-a')).toBe(true);
332+
// `class-b` should not be present, since it's wrapped in `:not()` selector
333+
expect(factoryHostElement.classList.contains('class-b')).toBe(false);
334+
expect(factoryHostElement.classList.contains('class-c')).toBe(true);
335+
// Make sure classes are overridden with ones used in component selector
336+
expect(factoryHostElement.classList.contains('class-original')).toBe(false);
337+
338+
// Note: `attr-a` attr is also present on host element, but we update the value with the
339+
// value from component selector (i.e. using `[attr-a=a]`)
340+
expect(factoryHostElement.getAttribute('attr-a')).toBe('a');
341+
// `attr-b` should not be present, since it's wrapped in `:not()` selector
342+
expect(factoryHostElement.getAttribute('attr-b')).toBe(null);
343+
expect(factoryHostElement.getAttribute('attr-c')).toBe('');
344+
}
345+
});
254346
});
255347

256348
describe('insert', () => {

packages/core/test/render3/node_selector_matcher_spec.ts

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {createTNode} from '@angular/core/src/render3/instructions/shared';
1010

1111
import {AttributeMarker, TAttributes, TNode, TNodeType} from '../../src/render3/interfaces/node';
1212
import {CssSelector, CssSelectorList, SelectorFlags} from '../../src/render3/interfaces/projection';
13-
import {getProjectAsAttrValue, isNodeMatchingSelector, isNodeMatchingSelectorList, stringifyCSSSelectorList} from '../../src/render3/node_selector_matcher';
13+
import {extractAttrsAndClassesFromSelector, getProjectAsAttrValue, isNodeMatchingSelector, isNodeMatchingSelectorList, stringifyCSSSelectorList} from '../../src/render3/node_selector_matcher';
1414

1515
function testLStaticData(tagName: string, attrs: TAttributes | null): TNode {
1616
return createTNode(null !, null, TNodeType.Element, 0, tagName, attrs);
@@ -569,3 +569,51 @@ describe('stringifyCSSSelectorList', () => {
569569
])).toBe('[id],button[id="value"],div:not([foo]),div:not(p.bar):not(.baz)');
570570
});
571571
});
572+
573+
describe('extractAttrsAndClassesFromSelector', () => {
574+
const cases = [
575+
[
576+
['div', '', ''],
577+
[],
578+
[],
579+
],
580+
[
581+
['div', 'attr-a', 'a', 'attr-b', 'b', 'attr-c', ''],
582+
['attr-a', 'a', 'attr-b', 'b', 'attr-c', ''],
583+
[],
584+
],
585+
[
586+
['div', 'attr-a', 'a', SelectorFlags.CLASS, 'class-a', 'class-b', 'class-c'],
587+
['attr-a', 'a'],
588+
['class-a', 'class-b', 'class-c'],
589+
],
590+
[
591+
['', 'attr-a', 'a', SelectorFlags.CLASS, 'class-a', SelectorFlags.ATTRIBUTE, 'attr-b', 'b'],
592+
['attr-a', 'a', 'attr-b', 'b'],
593+
['class-a'],
594+
],
595+
[
596+
[
597+
'', '', '', SelectorFlags.ATTRIBUTE, 'attr-a', 'a',
598+
(SelectorFlags.CLASS | SelectorFlags.NOT), 'class-b'
599+
],
600+
['attr-a', 'a'],
601+
[],
602+
],
603+
[
604+
[
605+
'', '', '', (SelectorFlags.CLASS | SelectorFlags.NOT), 'class-a',
606+
(SelectorFlags.ATTRIBUTE | SelectorFlags.NOT), 'attr-b', 'b'
607+
],
608+
[],
609+
[],
610+
],
611+
];
612+
cases.forEach(([selector, attrs, classes]) => {
613+
it(`should process ${JSON.stringify(selector)} selector`, () => {
614+
const extracted = extractAttrsAndClassesFromSelector(selector);
615+
expect(extracted.attrs).toEqual(attrs as string[]);
616+
expect(extracted.classes).toEqual(classes as string[]);
617+
});
618+
});
619+
});

0 commit comments

Comments
 (0)