diff --git a/packages/core/src/render3/instructions.ts b/packages/core/src/render3/instructions.ts index ef68c99250..ebe31d590e 100644 --- a/packages/core/src/render3/instructions.ts +++ b/packages/core/src/render3/instructions.ts @@ -11,7 +11,7 @@ import './ng_dev_mode'; import {assertEqual, assertLessThan, assertNotEqual, assertNotNull, assertNull, assertSame} from './assert'; import {LContainer, TContainer} from './interfaces/container'; import {LInjector} from './interfaces/injector'; -import {CssSelector, LProjection} from './interfaces/projection'; +import {CssSelector, LProjection, NG_PROJECT_AS_ATTR_NAME} from './interfaces/projection'; import {LQueries} from './interfaces/query'; import {LView, LViewFlags, LifecycleStage, RootContext, TData, TView} from './interfaces/view'; @@ -560,8 +560,12 @@ function setUpAttributes(native: RElement, attrs: string[]): void { const isProc = isProceduralRenderer(renderer); for (let i = 0; i < attrs.length; i += 2) { - isProc ? (renderer as ProceduralRenderer3).setAttribute(native, attrs[i], attrs[i | 1]) : - native.setAttribute(attrs[i], attrs[i | 1]); + const attrName = attrs[i]; + if (attrName !== NG_PROJECT_AS_ATTR_NAME) { + const attrVal = attrs[i + 1]; + isProc ? (renderer as ProceduralRenderer3).setAttribute(native, attrName, attrVal) : + native.setAttribute(attrName, attrVal); + } } } @@ -1279,9 +1283,23 @@ export function directiveRefresh(directiveIndex: number, elementIndex: number * each projected node belongs (it re-distributes nodes among "buckets" where each "bucket" is * backed by a selector). * - * @param selectors + * This function requires CSS selectors to be provided in 2 forms: parsed (by a compiler) and text, + * un-parsed form. + * + * The parsed form is needed for efficient matching of a node against a given CSS selector. + * The un-parsed, textual form is needed for support of the ngProjectAs attribute. + * + * Having a CSS selector in 2 different formats is not ideal, but alternatives have even more + * drawbacks: + * - having only a textual form would require runtime parsing of CSS selectors; + * - we can't have only a parsed as we can't re-construct textual form from it (as entered by a + * template author). + * + * @param selectors A collection of parsed CSS selectors + * @param rawSelectors A collection of CSS selectors in the raw, un-parsed form */ -export function projectionDef(index: number, selectors?: CssSelector[]): void { +export function projectionDef( + index: number, selectors?: CssSelector[], textSelectors?: string[]): void { const noOfNodeBuckets = selectors ? selectors.length + 1 : 1; const distributedNodes = new Array(noOfNodeBuckets); for (let i = 0; i < noOfNodeBuckets; i++) { @@ -1296,7 +1314,7 @@ export function projectionDef(index: number, selectors?: CssSelector[]): void { // - there are selectors defined // - a node has a tag name / attributes that can be matched if (selectors && componentChild.tNode) { - const matchedIdx = matchingSelectorIndex(componentChild.tNode, selectors !); + const matchedIdx = matchingSelectorIndex(componentChild.tNode, selectors, textSelectors !); distributedNodes[matchedIdx].push(componentChild); } else { distributedNodes[0].push(componentChild); diff --git a/packages/core/src/render3/interfaces/projection.ts b/packages/core/src/render3/interfaces/projection.ts index 87e44c933d..37d5c3ef96 100644 --- a/packages/core/src/render3/interfaces/projection.ts +++ b/packages/core/src/render3/interfaces/projection.ts @@ -45,6 +45,8 @@ export type CssSelectorWithNegations = [SimpleCssSelector | null, SimpleCssSelec */ export type CssSelector = CssSelectorWithNegations[]; +export const NG_PROJECT_AS_ATTR_NAME = 'ngProjectAs'; + // Note: This hack is necessary so we don't erroneously get a circular dependency // failure based on types. export const unusedValueExportToPlacateAjd = 1; diff --git a/packages/core/src/render3/node_selector_matcher.ts b/packages/core/src/render3/node_selector_matcher.ts index b1a508d85f..51b0a7974f 100644 --- a/packages/core/src/render3/node_selector_matcher.ts +++ b/packages/core/src/render3/node_selector_matcher.ts @@ -10,7 +10,7 @@ import './ng_dev_mode'; import {assertNotNull} from './assert'; import {TNode, unusedValueExportToPlacateAjd as unused1} from './interfaces/node'; -import {CssSelector, CssSelectorWithNegations, SimpleCssSelector, unusedValueExportToPlacateAjd as unused2} from './interfaces/projection'; +import {CssSelector, CssSelectorWithNegations, NG_PROJECT_AS_ATTR_NAME, SimpleCssSelector, unusedValueExportToPlacateAjd as unused2} from './interfaces/projection'; const unusedValueToPlacateAjd = unused1 + unused2; @@ -115,13 +115,34 @@ export function isNodeMatchingSelector(tNode: TNode, selector: CssSelector): boo return false; } +export function getProjectAsAttrValue(tNode: TNode): string|null { + const nodeAttrs = tNode.attrs; + if (nodeAttrs != null) { + const ngProjectAsAttrIdx = nodeAttrs.indexOf(NG_PROJECT_AS_ATTR_NAME); + // only check for ngProjectAs in attribute names, don't accidentally match attribute's value + // (attribute names are stored at even indexes) + if ((ngProjectAsAttrIdx & 1) === 0) { + return nodeAttrs[ngProjectAsAttrIdx + 1]; + } + } + return null; +} + /** * Checks a given node against matching selectors and returns - * selector index (or 0 if none matched); + * selector index (or 0 if none matched). + * + * This function takes into account the ngProjectAs attribute: if present its value will be compared + * to the raw (un-parsed) CSS selector instead of using standard selector matching logic. */ -export function matchingSelectorIndex(tNode: TNode, selectors: CssSelector[]): number { +export function matchingSelectorIndex( + tNode: TNode, selectors: CssSelector[], textSelectors: string[]): number { + const ngProjectAsAttrVal = getProjectAsAttrValue(tNode); for (let i = 0; i < selectors.length; i++) { - if (isNodeMatchingSelector(tNode, selectors[i])) { + // if a node has the ngProjectAs attribute match it against unparsed selector + // match a node against a parsed selector only if ngProjectAs attribute is not present + if (ngProjectAsAttrVal === textSelectors[i] || + ngProjectAsAttrVal === null && isNodeMatchingSelector(tNode, selectors[i])) { return i + 1; // first matching selector "captures" a given node } } diff --git a/packages/core/test/render3/compiler_canonical/content_projection_spec.ts b/packages/core/test/render3/compiler_canonical/content_projection_spec.ts index ed64e0bf20..7f663fc039 100644 --- a/packages/core/test/render3/compiler_canonical/content_projection_spec.ts +++ b/packages/core/test/render3/compiler_canonical/content_projection_spec.ts @@ -39,8 +39,9 @@ describe('content projection', () => { } // NORMATIVE - const $pD_0$: $r3$.ɵCssSelector[] = + const $pD_0P$: $r3$.ɵCssSelector[] = [[[['span', 'title', 'toFirst'], null]], [[['span', 'title', 'toSecond'], null]]]; + const $pD_0R$: string[] = ['span[title=toFirst]', 'span[title=toSecond]']; // /NORMATIVE @Component({ @@ -57,7 +58,7 @@ describe('content projection', () => { factory: () => new ComplexComponent(), template: function(ctx: $ComplexComponent$, cm: $boolean$) { if (cm) { - $r3$.ɵpD(0, $pD_0$); + $r3$.ɵpD(0, $pD_0P$, $pD_0R$); $r3$.ɵE(1, 'div', ['id', 'first']); $r3$.ɵP(2, 0, 1); $r3$.ɵe(); @@ -91,4 +92,4 @@ describe('content projection', () => { } }); -}); \ No newline at end of file +}); diff --git a/packages/core/test/render3/content_spec.ts b/packages/core/test/render3/content_spec.ts index 0cc2e61007..0621af9898 100644 --- a/packages/core/test/render3/content_spec.ts +++ b/packages/core/test/render3/content_spec.ts @@ -538,7 +538,8 @@ describe('content projection', () => { const Child = createComponent('child', function(ctx: any, cm: boolean) { if (cm) { projectionDef( - 0, [[[['span', 'title', 'toFirst'], null]], [[['span', 'title', 'toSecond'], null]]]); + 0, [[[['span', 'title', 'toFirst'], null]], [[['span', 'title', 'toSecond'], null]]], + ['span[title=toFirst]', 'span[title=toSecond]']); elementStart(1, 'div', ['id', 'first']); { projection(2, 0, 1); } elementEnd(); @@ -585,7 +586,8 @@ describe('content projection', () => { const Child = createComponent('child', function(ctx: any, cm: boolean) { if (cm) { projectionDef( - 0, [[[['span', 'class', 'toFirst'], null]], [[['span', 'class', 'toSecond'], null]]]); + 0, [[[['span', 'class', 'toFirst'], null]], [[['span', 'class', 'toSecond'], null]]], + ['span.toFirst', 'span.toSecond']); elementStart(1, 'div', ['id', 'first']); { projection(2, 0, 1); } elementEnd(); @@ -632,7 +634,8 @@ describe('content projection', () => { const Child = createComponent('child', function(ctx: any, cm: boolean) { if (cm) { projectionDef( - 0, [[[['span', 'class', 'toFirst'], null]], [[['span', 'class', 'toSecond'], null]]]); + 0, [[[['span', 'class', 'toFirst'], null]], [[['span', 'class', 'toSecond'], null]]], + ['span.toFirst', 'span.toSecond']); elementStart(1, 'div', ['id', 'first']); { projection(2, 0, 1); } elementEnd(); @@ -678,7 +681,9 @@ describe('content projection', () => { */ const Child = createComponent('child', function(ctx: any, cm: boolean) { if (cm) { - projectionDef(0, [[[['span'], null]], [[['span', 'class', 'toSecond'], null]]]); + projectionDef( + 0, [[[['span'], null]], [[['span', 'class', 'toSecond'], null]]], + ['span', 'span.toSecond']); elementStart(1, 'div', ['id', 'first']); { projection(2, 0, 1); } elementEnd(); @@ -724,7 +729,7 @@ describe('content projection', () => { */ const Child = createComponent('child', function(ctx: any, cm: boolean) { if (cm) { - projectionDef(0, [[[['span', 'class', 'toFirst'], null]]]); + projectionDef(0, [[[['span', 'class', 'toFirst'], null]]], ['span.toFirst']); elementStart(1, 'div', ['id', 'first']); { projection(2, 0, 1); } elementEnd(); @@ -771,7 +776,7 @@ describe('content projection', () => { */ const Child = createComponent('child', function(ctx: any, cm: boolean) { if (cm) { - projectionDef(0, [[[['span', 'class', 'toSecond'], null]]]); + projectionDef(0, [[[['span', 'class', 'toSecond'], null]]], ['span.toSecond']); elementStart(1, 'div', ['id', 'first']); { projection(2, 0); } elementEnd(); @@ -825,7 +830,7 @@ describe('content projection', () => { */ const GrandChild = createComponent('grand-child', function(ctx: any, cm: boolean) { if (cm) { - projectionDef(0, [[[['span'], null]]]); + projectionDef(0, [[[['span'], null]]], ['span']); projection(1, 0, 1); elementStart(2, 'hr'); elementEnd(); @@ -891,7 +896,9 @@ describe('content projection', () => { */ const Card = createComponent('card', function(ctx: any, cm: boolean) { if (cm) { - projectionDef(0, [[[['', 'card-title', ''], null]], [[['', 'card-content', ''], null]]]); + projectionDef( + 0, [[[['', 'card-title', ''], null]], [[['', 'card-content', ''], null]]], + ['[card-title]', '[card-content]']); projection(1, 0, 1); elementStart(2, 'hr'); elementEnd(); @@ -942,6 +949,108 @@ describe('content projection', () => { '

Title


content
'); }); + + it('should support ngProjectAs on elements (including )', () => { + + /** + * + *
+ * + */ + const Card = createComponent('card', function(ctx: any, cm: boolean) { + if (cm) { + projectionDef( + 0, [[[['', 'card-title', ''], null]], [[['', 'card-content', ''], null]]], + ['[card-title]', '[card-content]']); + projection(1, 0, 1); + elementStart(2, 'hr'); + elementEnd(); + projection(3, 0, 2); + } + }); + + /** + * + *

+ * + */ + const CardWithTitle = createComponent('card-with-title', function(ctx: any, cm: boolean) { + if (cm) { + projectionDef(0); + elementStart(1, Card); + { + elementStart(3, 'h1', ['ngProjectAs', '[card-title]']); + { text(4, 'Title'); } + elementEnd(); + projection(5, 0, 0, ['ngProjectAs', '[card-content]']); + } + elementEnd(); + Card.ngComponentDef.h(2, 1); + directiveRefresh(2, 1); + } + }); + + /** + * + * content + * + */ + const App = createComponent('app', function(ctx: any, cm: boolean) { + if (cm) { + elementStart(0, CardWithTitle); + { text(2, 'content'); } + elementEnd(); + } + CardWithTitle.ngComponentDef.h(1, 0); + directiveRefresh(1, 0); + }); + + const app = renderComponent(App); + expect(toHtml(app)) + .toEqual('

Title


content
'); + + }); + + it('should not match selectors against node having ngProjectAs attribute', function() { + + /** + * + */ + const Child = createComponent('child', function(ctx: any, cm: boolean) { + if (cm) { + projectionDef(0, [[[['div'], null]]], ['div']); + projection(1, 0, 1); + } + }); + + /** + * + *
should not project
+ *
should project
+ *
+ */ + const Parent = createComponent('parent', function(ctx: any, cm: boolean) { + if (cm) { + elementStart(0, Child); + { + elementStart(2, 'div', ['ngProjectAs', 'span']); + { text(3, 'should not project'); } + elementEnd(); + elementStart(4, 'div'); + { text(5, 'should project'); } + elementEnd(); + } + elementEnd(); + } + Child.ngComponentDef.h(1, 0); + directiveRefresh(1, 0); + }); + + const parent = renderComponent(Parent); + expect(toHtml(parent)).toEqual('
should project
'); + }); + it('should match selectors against projected containers', () => { /** @@ -951,7 +1060,7 @@ describe('content projection', () => { */ const Child = createComponent('child', function(ctx: any, cm: boolean) { if (cm) { - projectionDef(0, [[[['div'], null]]]); + projectionDef(0, [[[['div'], null]]], ['div']); elementStart(1, 'span'); { projection(2, 0, 1); } elementEnd(); diff --git a/packages/core/test/render3/node_selector_matcher_spec.ts b/packages/core/test/render3/node_selector_matcher_spec.ts index de4570774b..f26d19b4e2 100644 --- a/packages/core/test/render3/node_selector_matcher_spec.ts +++ b/packages/core/test/render3/node_selector_matcher_spec.ts @@ -7,8 +7,8 @@ */ import {TNode} from '../../src/render3/interfaces/node'; -import {CssSelector, CssSelectorWithNegations, SimpleCssSelector} from '../../src/render3/interfaces/projection'; -import {isNodeMatchingSelector, isNodeMatchingSelectorWithNegations, isNodeMatchingSimpleSelector} from '../../src/render3/node_selector_matcher'; +import {CssSelector, CssSelectorWithNegations, NG_PROJECT_AS_ATTR_NAME, SimpleCssSelector} from '../../src/render3/interfaces/projection'; +import {getProjectAsAttrValue, isNodeMatchingSelector, isNodeMatchingSelectorWithNegations, isNodeMatchingSimpleSelector} from '../../src/render3/node_selector_matcher'; function testLStaticData(tagName: string, attrs: string[] | null): TNode { return { @@ -183,4 +183,26 @@ describe('css selector matching', () => { }); }); + describe('reading the ngProjectAs attribute value', function() { + + function testTNode(attrs: string[] | null) { return testLStaticData('tag', attrs); } + + it('should get ngProjectAs value if present', function() { + expect(getProjectAsAttrValue(testTNode([NG_PROJECT_AS_ATTR_NAME, 'tag[foo=bar]']))) + .toBe('tag[foo=bar]'); + }); + + it('should return null if there are no attributes', + function() { expect(getProjectAsAttrValue(testTNode(null))).toBe(null); }); + + it('should return if ngProjectAs is not present', function() { + expect(getProjectAsAttrValue(testTNode(['foo', 'bar']))).toBe(null); + }); + + it('should not accidentally identify ngProjectAs in attribute values', function() { + expect(getProjectAsAttrValue(testTNode(['foo', NG_PROJECT_AS_ATTR_NAME]))).toBe(null); + }); + + }); + });