fix(ivy): match attribute selectors for content projection with inline-templates (#29041)

The content projection mechanism is static, in that it only looks at the static
template nodes before directives are matched and change detection is run.
When you have a selector-based content projection the selection is based
on nodes that are available in the template.

For example:

```
<ng-content selector="[some-attr]"></ng-content>
```

would match

```
<div some-attr="..."></div>
```

If you have an inline-template in your projected nodes. For example:

```
<div *ngIf="..." some-attr="..."></div>
```

This gets pre-parsed and converted to a canonical form.

For example:

```
<ng-template [ngIf]="...">
  <div some-attr=".."></div>
</ng-template>
```

Note that only structural attributes (e.g. `*ngIf`) stay with the `<ng-template>`
node. The other attributes move to the contained element inside the template.

When this happens in ivy, the ng-template content is removed
from the component template function and is compiled into its own
template function. But this means that the information about the
attributes that were on the content are lost and the projection
selection mechanism is unable to match the original
`<div *ngIf="..." some-attr>`.

This commit adds support for this in ivy. Attributes are separated into three
groups (Bindings, Templates and "other"). For inline-templates the Bindings
and "other" types are hoisted back from the contained node to the `template()`
instruction, so that they can be used in content projection matching.

PR Close #29041
This commit is contained in:
Pete Bacon Darwin
2019-03-07 08:31:31 +00:00
committed by Kara Erickson
parent e3a401d20c
commit f535f31d78
28 changed files with 357 additions and 194 deletions

View File

@ -81,10 +81,10 @@ export class Element implements Node {
export class Template implements Node {
constructor(
public tagName: string, public attributes: TextAttribute[], public inputs: BoundAttribute[],
public outputs: BoundEvent[], public children: Node[], public references: Reference[],
public variables: Variable[], public sourceSpan: ParseSourceSpan,
public startSourceSpan: ParseSourceSpan|null, public endSourceSpan: ParseSourceSpan|null,
public i18n?: I18nAST) {}
public outputs: BoundEvent[], public templateAttrs: (BoundAttribute|TextAttribute)[],
public children: Node[], public references: Reference[], public variables: Variable[],
public sourceSpan: ParseSourceSpan, public startSourceSpan: ParseSourceSpan|null,
public endSourceSpan: ParseSourceSpan|null, public i18n?: I18nAST) {}
visit<Result>(visitor: Visitor<Result>): Result { return visitor.visitTemplate(this); }
}
@ -189,15 +189,18 @@ export class TransformVisitor implements Visitor<Node> {
const newAttributes = transformAll(this, template.attributes);
const newInputs = transformAll(this, template.inputs);
const newOutputs = transformAll(this, template.outputs);
const newTemplateAttrs = transformAll(this, template.templateAttrs);
const newChildren = transformAll(this, template.children);
const newReferences = transformAll(this, template.references);
const newVariables = transformAll(this, template.variables);
if (newAttributes != template.attributes || newInputs != template.inputs ||
newOutputs != template.outputs || newChildren != template.children ||
newReferences != template.references || newVariables != template.variables) {
newOutputs != template.outputs || newTemplateAttrs != template.templateAttrs ||
newChildren != template.children || newReferences != template.references ||
newVariables != template.variables) {
return new Template(
template.tagName, newAttributes, newInputs, newOutputs, newChildren, newReferences,
newVariables, template.sourceSpan, template.startSourceSpan, template.endSourceSpan);
template.tagName, newAttributes, newInputs, newOutputs, newTemplateAttrs, newChildren,
newReferences, newVariables, template.sourceSpan, template.startSourceSpan,
template.endSourceSpan);
}
return template;
}

View File

@ -177,8 +177,9 @@ class HtmlAstToIvyAst implements html.Visitor {
const attrs = this.extractAttributes(element.name, parsedProperties, i18nAttrsMeta);
parsedElement = new t.Template(
element.name, attributes, attrs.bound, boundEvents, children, references, variables,
element.sourceSpan, element.startSourceSpan, element.endSourceSpan, element.i18n);
element.name, attributes, attrs.bound, boundEvents, [/* no template attributes */],
children, references, variables, element.sourceSpan, element.startSourceSpan,
element.endSourceSpan, element.i18n);
} else {
const attrs = this.extractAttributes(element.name, parsedProperties, i18nAttrsMeta);
parsedElement = new t.Element(
@ -187,10 +188,25 @@ class HtmlAstToIvyAst implements html.Visitor {
}
if (elementHasInlineTemplate) {
// If this node is an inline-template (e.g. has *ngFor) then we need to create a template
// node that contains this node.
// Moreover, if the node is an element, then we need to hoist its attributes to the template
// node for matching against content projection selectors.
const attrs = this.extractAttributes('ng-template', templateParsedProperties, i18nAttrsMeta);
const templateAttrs: (t.TextAttribute | t.BoundAttribute)[] = [];
attrs.literal.forEach(attr => templateAttrs.push(attr));
attrs.bound.forEach(attr => templateAttrs.push(attr));
const hoistedAttrs = parsedElement instanceof t.Element ?
{
attributes: parsedElement.attributes,
inputs: parsedElement.inputs,
outputs: parsedElement.outputs,
} :
{attributes: [], inputs: [], outputs: []};
// TODO(pk): test for this case
parsedElement = new t.Template(
(parsedElement as t.Element).name, attrs.literal, attrs.bound, [], [parsedElement], [],
(parsedElement as t.Element).name, hoistedAttrs.attributes, hoistedAttrs.inputs,
hoistedAttrs.outputs, templateAttrs, [parsedElement], [/* no references */],
templateVariables, element.sourceSpan, element.startSourceSpan, element.endSourceSpan,
element.i18n);
}

View File

@ -576,9 +576,8 @@ export class TemplateDefinitionBuilder implements t.Visitor<void>, LocalResolver
attributes.push(...getAttributeNameLiterals(attr.name), o.literal(attr.value));
});
// this will build the instructions so that they fall into the following syntax
// add attributes for directive matching purposes
attributes.push(...this.prepareBindingsAttrs(allOtherInputs, element.outputs, stylingBuilder));
// add attributes for directive and projection matching purposes
attributes.push(...this.prepareNonRenderAttrs(allOtherInputs, element.outputs, stylingBuilder));
parameters.push(this.toAttrsParam(attributes));
// local refs (ex.: <div #foo #bar="baz">)
@ -774,6 +773,7 @@ export class TemplateDefinitionBuilder implements t.Visitor<void>, LocalResolver
}
visitTemplate(template: t.Template) {
const NG_TEMPLATE_TAG_NAME = 'ng-template';
const templateIndex = this.allocateDataSlot();
if (this.i18n) {
@ -794,13 +794,14 @@ export class TemplateDefinitionBuilder implements t.Visitor<void>, LocalResolver
];
// find directives matching on a given <ng-template> node
this.matchDirectives('ng-template', template);
this.matchDirectives(NG_TEMPLATE_TAG_NAME, template);
// prepare attributes parameter (including attributes used for directive matching)
const attrsExprs: o.Expression[] = [];
template.attributes.forEach(
(a: t.TextAttribute) => { attrsExprs.push(asLiteral(a.name), asLiteral(a.value)); });
attrsExprs.push(...this.prepareBindingsAttrs(template.inputs, template.outputs));
attrsExprs.push(...this.prepareNonRenderAttrs(
template.inputs, template.outputs, undefined, template.templateAttrs));
parameters.push(this.toAttrsParam(attrsExprs));
// local refs (ex.: <ng-template #foo>)
@ -840,23 +841,19 @@ export class TemplateDefinitionBuilder implements t.Visitor<void>, LocalResolver
// handle property bindings e.g. ɵelementProperty(1, 'ngForOf', ɵbind(ctx.items));
const context = o.variable(CONTEXT_NAME);
template.inputs.forEach(input => {
const value = input.value.visit(this._valueConverter);
this.allocateBindingSlots(value);
this.updateInstruction(templateIndex, template.sourceSpan, R3.elementProperty, () => {
return [
o.literal(templateIndex), o.literal(input.name),
this.convertPropertyBinding(context, value)
];
});
});
this.templatePropertyBindings(template, templateIndex, context, template.templateAttrs);
// Generate listeners for directive output
template.outputs.forEach((outputAst: t.BoundEvent) => {
this.creationInstruction(
outputAst.sourceSpan, R3.listener,
this.prepareListenerParameter('ng_template', outputAst, templateIndex));
});
// Only add normal input/output binding instructions on explicit ng-template elements.
if (template.tagName === NG_TEMPLATE_TAG_NAME) {
// Add the input bindings
this.templatePropertyBindings(template, templateIndex, context, template.inputs);
// Generate listeners for directive output
template.outputs.forEach((outputAst: t.BoundEvent) => {
this.creationInstruction(
outputAst.sourceSpan, R3.listener,
this.prepareListenerParameter('ng_template', outputAst, templateIndex));
});
}
}
// These should be handled in the template or element directly.
@ -949,6 +946,23 @@ export class TemplateDefinitionBuilder implements t.Visitor<void>, LocalResolver
private bindingContext() { return `${this._bindingContext++}`; }
private templatePropertyBindings(
template: t.Template, templateIndex: number, context: o.ReadVarExpr,
attrs: (t.BoundAttribute|t.TextAttribute)[]) {
attrs.forEach(input => {
if (input instanceof t.BoundAttribute) {
const value = input.value.visit(this._valueConverter);
this.allocateBindingSlots(value);
this.updateInstruction(templateIndex, template.sourceSpan, R3.elementProperty, () => {
return [
o.literal(templateIndex), o.literal(input.name),
this.convertPropertyBinding(context, value)
];
});
}
});
}
// Bindings must only be resolved after all local refs have been visited, so all
// instructions are queued in callbacks that execute once the initial pass has completed.
// Otherwise, we wouldn't be able to support local refs that are defined after their
@ -1051,9 +1065,9 @@ export class TemplateDefinitionBuilder implements t.Visitor<void>, LocalResolver
* Note that this function will fully ignore all synthetic (@foo) attribute values
* because those values are intended to always be generated as property instructions.
*/
private prepareBindingsAttrs(
inputs: t.BoundAttribute[], outputs: t.BoundEvent[],
styles?: StylingBuilder): o.Expression[] {
private prepareNonRenderAttrs(
inputs: t.BoundAttribute[], outputs: t.BoundEvent[], styles?: StylingBuilder,
templateAttrs: (t.BoundAttribute|t.TextAttribute)[] = []): o.Expression[] {
const alreadySeen = new Set<string>();
const attrExprs: o.Expression[] = [];
@ -1102,6 +1116,11 @@ export class TemplateDefinitionBuilder implements t.Visitor<void>, LocalResolver
}
}
if (templateAttrs.length) {
attrExprs.push(o.literal(core.AttributeMarker.Template));
templateAttrs.forEach(attr => addAttrExpr(attr.name));
}
return attrExprs;
}

View File

@ -173,5 +173,9 @@ export function getAttrsForDirectiveMatching(elOrTpl: t.Element | t.Template):
elOrTpl.inputs.forEach(i => { attributesMap[i.name] = ''; });
elOrTpl.outputs.forEach(o => { attributesMap[o.name] = ''; });
if (elOrTpl instanceof t.Template) {
elOrTpl.templateAttrs.forEach(a => attributesMap[a.name] = '');
}
return attributesMap;
}

View File

@ -32,6 +32,8 @@ class R3AstHumanizer implements t.Visitor<void> {
this.visitAll([
template.attributes,
template.inputs,
template.outputs,
template.templateAttrs,
template.references,
template.variables,
template.children,