fix(compiler): treat i18n attributes with no bindings as static attributes (#39408)

Currently `i18n` attributes are treated the same no matter if they have data bindings or not. This
both generates more code since they have to go through the `ɵɵi18nAttributes` instruction and
prevents the translated attributes from being injected using the `@Attribute` decorator.

These changes makes it so that static translated attributes are treated in the same way as regular
static attributes and all other `i18n` attributes go through the old code path.

Fixes #38231.

PR Close #39408
This commit is contained in:
Kristiyan Kostadinov 2020-10-24 13:16:11 +02:00 committed by Alex Rickabaugh
parent 421efbf69b
commit 58408d6a60
9 changed files with 191 additions and 270 deletions

View File

@ -153,7 +153,14 @@ ${output}
`); `);
}; };
const verify = (input: string, output: string, extra: any = {}): void => { const verify = (input: string, output: string, extra: {
inputArgs?: any,
compilerOptions?: any,
skipPathBasedCheck?: boolean,
skipIdBasedCheck?: boolean,
verbose?: boolean,
exceptions?: {}
} = {}): void => {
const files = getAppFilesWithTemplate(input, extra.inputArgs); const files = getAppFilesWithTemplate(input, extra.inputArgs);
const opts = (i18nUseExternalIds: boolean) => const opts = (i18nUseExternalIds: boolean) =>
({i18nUseExternalIds, ...(extra.compilerOptions || {})}); ({i18nUseExternalIds, ...(extra.compilerOptions || {})});
@ -161,7 +168,7 @@ const verify = (input: string, output: string, extra: any = {}): void => {
// invoke with file-based prefix translation names // invoke with file-based prefix translation names
if (!extra.skipPathBasedCheck) { if (!extra.skipPathBasedCheck) {
const result = compile(files, angularFiles, opts(false)); const result = compile(files, angularFiles, opts(false));
maybePrint(result.source, extra.verbose); maybePrint(result.source, !!extra.verbose);
expect(verifyPlaceholdersIntegrity(result.source)).toBe(true); expect(verifyPlaceholdersIntegrity(result.source)).toBe(true);
expect(verifyUniqueConsts(result.source)).toBe(true); expect(verifyUniqueConsts(result.source)).toBe(true);
expectEmit(result.source, output, 'Incorrect template'); expectEmit(result.source, output, 'Incorrect template');
@ -170,7 +177,7 @@ const verify = (input: string, output: string, extra: any = {}): void => {
// invoke with translation names based on external ids // invoke with translation names based on external ids
if (!extra.skipIdBasedCheck) { if (!extra.skipIdBasedCheck) {
const result = compile(files, angularFiles, opts(true)); const result = compile(files, angularFiles, opts(true));
maybePrint(result.source, extra.verbose); maybePrint(result.source, !!extra.verbose);
const interpolationConfig = extra.inputArgs && extra.inputArgs.interpolation ? const interpolationConfig = extra.inputArgs && extra.inputArgs.interpolation ?
InterpolationConfig.fromArray(extra.inputArgs.interpolation) : InterpolationConfig.fromArray(extra.inputArgs.interpolation) :
undefined; undefined;
@ -346,7 +353,6 @@ describe('i18n support in the template compiler', () => {
${i18n_7} ${i18n_7}
return [ return [
$i18n_0$, $i18n_0$,
[${AttributeMarker.I18n}, "title"],
["title", $i18n_1$], ["title", $i18n_1$],
["title", $i18n_2$], ["title", $i18n_2$],
["title", $i18n_3$], ["title", $i18n_3$],
@ -362,31 +368,25 @@ describe('i18n support in the template compiler', () => {
$r3$.ɵɵi18n(1, 0); $r3$.ɵɵi18n(1, 0);
$r3$.ɵɵelementEnd(); $r3$.ɵɵelementEnd();
$r3$.ɵɵelementStart(2, "div", 1); $r3$.ɵɵelementStart(2, "div", 1);
$r3$.ɵɵi18nAttributes(3, 2); $r3$.ɵɵtext(3, "Content B");
$r3$.ɵɵtext(4, "Content B");
$r3$.ɵɵelementEnd(); $r3$.ɵɵelementEnd();
$r3$.ɵɵelementStart(5, "div", 1); $r3$.ɵɵelementStart(4, "div", 2);
$r3$.ɵɵi18nAttributes(6, 3); $r3$.ɵɵtext(5, "Content C");
$r3$.ɵɵtext(7, "Content C");
$r3$.ɵɵelementEnd(); $r3$.ɵɵelementEnd();
$r3$.ɵɵelementStart(8, "div", 1); $r3$.ɵɵelementStart(6, "div", 3);
$r3$.ɵɵi18nAttributes(9, 4); $r3$.ɵɵtext(7, "Content D");
$r3$.ɵɵtext(10, "Content D");
$r3$.ɵɵelementEnd(); $r3$.ɵɵelementEnd();
$r3$.ɵɵelementStart(11, "div", 1); $r3$.ɵɵelementStart(8, "div", 4);
$r3$.ɵɵi18nAttributes(12, 5); $r3$.ɵɵtext(9, "Content E");
$r3$.ɵɵtext(13, "Content E");
$r3$.ɵɵelementEnd(); $r3$.ɵɵelementEnd();
$r3$.ɵɵelementStart(14, "div", 1); $r3$.ɵɵelementStart(10, "div", 5);
$r3$.ɵɵi18nAttributes(15, 6); $r3$.ɵɵtext(11, "Content F");
$r3$.ɵɵtext(16, "Content F");
$r3$.ɵɵelementEnd(); $r3$.ɵɵelementEnd();
$r3$.ɵɵelementStart(17, "div", 1); $r3$.ɵɵelementStart(12, "div", 6);
$r3$.ɵɵi18nAttributes(18, 7); $r3$.ɵɵtext(13, "Content G");
$r3$.ɵɵtext(19, "Content G");
$r3$.ɵɵelementEnd(); $r3$.ɵɵelementEnd();
$r3$.ɵɵelementStart(20, "div"); $r3$.ɵɵelementStart(14, "div");
$r3$.ɵɵi18n(21, 8); $r3$.ɵɵi18n(15, 7);
$r3$.ɵɵelementEnd(); $r3$.ɵɵelementEnd();
} }
} }
@ -405,14 +405,12 @@ describe('i18n support in the template compiler', () => {
consts: function () { consts: function () {
${i18n_0} ${i18n_0}
return [ return [
[${AttributeMarker.I18n}, "title"],
["title", $i18n_0$] ["title", $i18n_0$]
]; ];
}, },
template: function MyComponent_Template(rf, ctx) { template: function MyComponent_Template(rf, ctx) {
if (rf & 1) { if (rf & 1) {
$r3$.ɵɵtemplate(0, MyComponent_ng_template_0_Template, 0, 0, "ng-template", 0); $r3$.ɵɵtemplate(0, MyComponent_ng_template_0_Template, 0, 0, "ng-template", 0);
$r3$.ɵɵi18nAttributes(1, 1);
} }
} }
`; `;
@ -436,7 +434,6 @@ describe('i18n support in the template compiler', () => {
function MyComponent_0_Template(rf, ctx) { function MyComponent_0_Template(rf, ctx) {
if (rf & 1) { if (rf & 1) {
$r3$.ɵɵtemplate(0, MyComponent_0_ng_template_0_Template, 1, 0, "ng-template", 1); $r3$.ɵɵtemplate(0, MyComponent_0_ng_template_0_Template, 1, 0, "ng-template", 1);
$r3$.ɵɵi18nAttributes(1, 2);
} }
} }
@ -444,13 +441,12 @@ describe('i18n support in the template compiler', () => {
${i18n_0} ${i18n_0}
return [ return [
[${AttributeMarker.Template}, "ngIf"], [${AttributeMarker.Template}, "ngIf"],
[${AttributeMarker.I18n}, "title"],
["title", $i18n_0$] ["title", $i18n_0$]
]; ];
}, },
template: function MyComponent_Template(rf, ctx) { template: function MyComponent_Template(rf, ctx) {
if (rf & 1) { if (rf & 1) {
$r3$.ɵɵtemplate(0, MyComponent_0_Template, 2, 0, undefined, 0); $r3$.ɵɵtemplate(0, MyComponent_0_Template, 1, 0, undefined, 0);
} }
if (rf & 2) { if (rf & 2) {
$r3$.ɵɵproperty("ngIf", ctx.visible); $r3$.ɵɵproperty("ngIf", ctx.visible);
@ -584,15 +580,12 @@ describe('i18n support in the template compiler', () => {
consts: function() { consts: function() {
${i18n_0} ${i18n_0}
return [ return [
["id", "static", ${AttributeMarker.I18n}, "title"], ["id", "static", "title", $i18n_0$]
["title", $i18n_0$]
]; ];
}, },
template: function MyComponent_Template(rf, ctx) { template: function MyComponent_Template(rf, ctx) {
if (rf & 1) { if (rf & 1) {
$r3$.ɵɵelementStart(0, "div", 0); $r3$.ɵɵelement(0, "div", 0);
$r3$.ɵɵi18nAttributes(1, 1);
$r3$.ɵɵelementEnd();
} }
} }
`; `;
@ -640,9 +633,9 @@ describe('i18n support in the template compiler', () => {
${i18n_3} ${i18n_3}
${i18n_4} ${i18n_4}
return [ return [
["id", "dynamic-1", ${AttributeMarker.I18n}, "aria-roledescription", ["id", "dynamic-1", "aria-roledescription", $i18n_0$, ${AttributeMarker.I18n},
"title", "aria-label"], "title", "aria-label"],
["aria-roledescription", $i18n_0$, "title", $i18n_1$, "aria-label", $i18n_2$], ["title", $i18n_1$, "aria-label", $i18n_2$],
["id", "dynamic-2", ${AttributeMarker.I18n}, "title", "aria-roledescription"], ["id", "dynamic-2", ${AttributeMarker.I18n}, "title", "aria-roledescription"],
["title", $i18n_3$, "aria-roledescription", $i18n_4$] ["title", $i18n_3$, "aria-roledescription", $i18n_4$]
]; ];
@ -790,76 +783,6 @@ describe('i18n support in the template compiler', () => {
verify(input, output); verify(input, output);
}); });
it('should support interpolation', () => {
const input = `
<div id="dynamic-1"
i18n-title="m|d" title="intro {{ valueA | uppercase }}"
i18n-aria-label="m1|d1" aria-label="{{ valueB }}"
i18n-aria-roledescription aria-roledescription="static text"
></div>
<div id="dynamic-2"
i18n-title="m2|d2" title="{{ valueA }} and {{ valueB }} and again {{ valueA + valueB }}"
i18n-aria-roledescription aria-roledescription="{{ valueC }}"
></div>
`;
const i18n_0 = i18nMsg('static text');
const i18n_1 = i18nMsg(
'intro {$interpolation}', [['interpolation', String.raw`\uFFFD0\uFFFD`]],
{meaning: 'm', desc: 'd'});
const i18n_2 = i18nMsg(
'{$interpolation}', [['interpolation', String.raw`\uFFFD0\uFFFD`]],
{meaning: 'm1', desc: 'd1'});
const i18n_3 = i18nMsg(
'{$interpolation} and {$interpolation_1} and again {$interpolation_2}',
[
['interpolation', String.raw`\uFFFD0\uFFFD`],
['interpolation_1', String.raw`\uFFFD1\uFFFD`],
['interpolation_2', String.raw`\uFFFD2\uFFFD`]
],
{meaning: 'm2', desc: 'd2'});
const i18n_4 = i18nMsg('{$interpolation}', [['interpolation', String.raw`\uFFFD0\uFFFD`]]);
const output = String.raw`
decls: 5,
vars: 8,
consts: function() {
${i18n_0}
${i18n_1}
${i18n_2}
${i18n_3}
${i18n_4}
return [
["id", "dynamic-1", ${AttributeMarker.I18n}, "aria-roledescription",
"title", "aria-label"],
["aria-roledescription", $i18n_0$, "title", $i18n_1$, "aria-label", $i18n_2$],
["id", "dynamic-2", ${AttributeMarker.I18n}, "title", "aria-roledescription"],
["title", $i18n_3$, "aria-roledescription", $i18n_4$]
];
},
template: function MyComponent_Template(rf, ctx) {
if (rf & 1) {
$r3$.ɵɵelementStart(0, "div", 0);
$r3$.ɵɵpipe(1, "uppercase");
$r3$.ɵɵi18nAttributes(2, 1);
$r3$.ɵɵelementEnd();
$r3$.ɵɵelementStart(3, "div", 2);
$r3$.ɵɵi18nAttributes(4, 3);
$r3$.ɵɵelementEnd();
}
if (rf & 2) {
$r3$.ɵɵi18nExp($r3$.ɵɵpipeBind1(1, 6, ctx.valueA))(ctx.valueB);
$r3$.ɵɵi18nApply(2);
$r3$.ɵɵadvance(3);
$r3$.ɵɵi18nExp(ctx.valueA)(ctx.valueB)(ctx.valueA + ctx.valueB)(ctx.valueC);
$r3$.ɵɵi18nApply(4);
}
}
`;
verify(input, output);
});
it('should correctly bind to context in nested template', () => { it('should correctly bind to context in nested template', () => {
const input = ` const input = `
<div *ngFor="let outer of items"> <div *ngFor="let outer of items">
@ -925,7 +848,6 @@ describe('i18n support in the template compiler', () => {
${i18n_0} ${i18n_0}
${i18n_1} ${i18n_1}
return [ return [
[${AttributeMarker.I18n}, "title"],
["title", $i18n_0$], ["title", $i18n_0$],
$i18n_1$ $i18n_1$
]; ];
@ -933,8 +855,7 @@ describe('i18n support in the template compiler', () => {
template: function MyComponent_Template(rf, ctx) { template: function MyComponent_Template(rf, ctx) {
if (rf & 1) { if (rf & 1) {
$r3$.ɵɵelementStart(0, "div", 0); $r3$.ɵɵelementStart(0, "div", 0);
$r3$.ɵɵi18nAttributes(1, 1); $r3$.ɵɵi18n(1, 1);
$r3$.ɵɵi18n(2, 2);
$r3$.ɵɵelementEnd(); $r3$.ɵɵelementEnd();
} }
} }

View File

@ -50,6 +50,11 @@ export function hasI18nMeta(node: t.Node&{i18n?: i18n.I18nMeta}): boolean {
return !!node.i18n; return !!node.i18n;
} }
export function isBoundI18nAttribute(node: t.TextAttribute|
t.BoundAttribute): node is t.BoundAttribute {
return node.i18n !== undefined && node instanceof t.BoundAttribute;
}
export function hasI18nAttrs(element: html.Element): boolean { export function hasI18nAttrs(element: html.Element): boolean {
return element.attrs.some((attr: html.Attribute) => isI18nAttribute(attr.name)); return element.attrs.some((attr: html.Attribute) => isI18nAttribute(attr.name));
} }

View File

@ -36,7 +36,7 @@ import {I18nContext} from './i18n/context';
import {createGoogleGetMsgStatements} from './i18n/get_msg_utils'; import {createGoogleGetMsgStatements} from './i18n/get_msg_utils';
import {createLocalizeStatements} from './i18n/localize_utils'; import {createLocalizeStatements} from './i18n/localize_utils';
import {I18nMetaVisitor} from './i18n/meta'; import {I18nMetaVisitor} from './i18n/meta';
import {assembleBoundTextPlaceholders, assembleI18nBoundString, declareI18nVariable, getTranslationConstPrefix, hasI18nMeta, I18N_ICU_MAPPING_PREFIX, i18nFormatPlaceholderNames, icuFromI18nMessage, isI18nRootNode, isSingleI18nIcu, placeholdersToParams, TRANSLATION_VAR_PREFIX, wrapI18nPlaceholder} from './i18n/util'; import {assembleBoundTextPlaceholders, assembleI18nBoundString, declareI18nVariable, getTranslationConstPrefix, hasI18nMeta, I18N_ICU_MAPPING_PREFIX, i18nFormatPlaceholderNames, icuFromI18nMessage, isBoundI18nAttribute, isI18nRootNode, isSingleI18nIcu, placeholdersToParams, TRANSLATION_VAR_PREFIX, wrapI18nPlaceholder} from './i18n/util';
import {StylingBuilder, StylingInstruction} from './styling_builder'; import {StylingBuilder, StylingInstruction} from './styling_builder';
import {asLiteral, chainedInstruction, CONTEXT_NAME, getAttrsForDirectiveMatching, getInterpolationArgsLength, IMPLICIT_REFERENCE, invalid, NON_BINDABLE_ATTR, REFERENCE_PREFIX, RENDER_FLAGS, trimTrailingNulls, unsupported} from './util'; import {asLiteral, chainedInstruction, CONTEXT_NAME, getAttrsForDirectiveMatching, getInterpolationArgsLength, IMPLICIT_REFERENCE, invalid, NON_BINDABLE_ATTR, REFERENCE_PREFIX, RENDER_FLAGS, trimTrailingNulls, unsupported} from './util';
@ -492,30 +492,25 @@ export class TemplateDefinitionBuilder implements t.Visitor<void>, LocalResolver
} }
private i18nAttributesInstruction( private i18nAttributesInstruction(
nodeIndex: number, attrs: (t.TextAttribute|t.BoundAttribute)[], nodeIndex: number, attrs: t.BoundAttribute[], sourceSpan: ParseSourceSpan): void {
sourceSpan: ParseSourceSpan): void {
let hasBindings: boolean = false; let hasBindings: boolean = false;
const i18nAttrArgs: o.Expression[] = []; const i18nAttrArgs: o.Expression[] = [];
const bindings: ChainableBindingInstruction[] = []; const bindings: ChainableBindingInstruction[] = [];
attrs.forEach(attr => { attrs.forEach(attr => {
const message = attr.i18n! as i18n.Message; const message = attr.i18n! as i18n.Message;
if (attr instanceof t.TextAttribute) { const converted = attr.value.visit(this._valueConverter);
i18nAttrArgs.push(o.literal(attr.name), this.i18nTranslate(message)); this.allocateBindingSlots(converted);
} else { if (converted instanceof Interpolation) {
const converted = attr.value.visit(this._valueConverter); const placeholders = assembleBoundTextPlaceholders(message);
this.allocateBindingSlots(converted); const params = placeholdersToParams(placeholders);
if (converted instanceof Interpolation) { i18nAttrArgs.push(o.literal(attr.name), this.i18nTranslate(message, params));
const placeholders = assembleBoundTextPlaceholders(message); converted.expressions.forEach(expression => {
const params = placeholdersToParams(placeholders); hasBindings = true;
i18nAttrArgs.push(o.literal(attr.name), this.i18nTranslate(message, params)); bindings.push({
converted.expressions.forEach(expression => { sourceSpan,
hasBindings = true; value: () => this.convertPropertyBinding(expression),
bindings.push({
sourceSpan,
value: () => this.convertPropertyBinding(expression),
});
}); });
} });
} }
}); });
if (bindings.length > 0) { if (bindings.length > 0) {
@ -591,7 +586,7 @@ export class TemplateDefinitionBuilder implements t.Visitor<void>, LocalResolver
const isI18nRootElement: boolean = const isI18nRootElement: boolean =
isI18nRootNode(element.i18n) && !isSingleI18nIcu(element.i18n); isI18nRootNode(element.i18n) && !isSingleI18nIcu(element.i18n);
const i18nAttrs: (t.TextAttribute|t.BoundAttribute)[] = []; const boundI18nAttrs: t.BoundAttribute[] = [];
const outputAttrs: t.TextAttribute[] = []; const outputAttrs: t.TextAttribute[] = [];
const [namespaceKey, elementName] = splitNsName(element.name); const [namespaceKey, elementName] = splitNsName(element.name);
@ -606,8 +601,12 @@ export class TemplateDefinitionBuilder implements t.Visitor<void>, LocalResolver
stylingBuilder.registerStyleAttr(value); stylingBuilder.registerStyleAttr(value);
} else if (name === 'class') { } else if (name === 'class') {
stylingBuilder.registerClassAttr(value); stylingBuilder.registerClassAttr(value);
} else if (isBoundI18nAttribute(attr)) {
// Note that we don't collect static i18n attributes here, because
// they can be treated in the same way as regular attributes.
boundI18nAttrs.push(attr);
} else { } else {
(attr.i18n ? i18nAttrs : outputAttrs).push(attr); outputAttrs.push(attr);
} }
} }
@ -627,7 +626,7 @@ export class TemplateDefinitionBuilder implements t.Visitor<void>, LocalResolver
const stylingInputWasSet = stylingBuilder.registerBoundInput(input); const stylingInputWasSet = stylingBuilder.registerBoundInput(input);
if (!stylingInputWasSet) { if (!stylingInputWasSet) {
if (input.type === BindingType.Property && input.i18n) { if (input.type === BindingType.Property && input.i18n) {
i18nAttrs.push(input); boundI18nAttrs.push(input);
} else { } else {
allOtherInputs.push(input); allOtherInputs.push(input);
} }
@ -636,7 +635,8 @@ export class TemplateDefinitionBuilder implements t.Visitor<void>, LocalResolver
// add attributes for directive and projection matching purposes // add attributes for directive and projection matching purposes
const attributes: o.Expression[] = this.getAttributeExpressions( const attributes: o.Expression[] = this.getAttributeExpressions(
element.name, outputAttrs, allOtherInputs, element.outputs, stylingBuilder, [], i18nAttrs); element.name, outputAttrs, allOtherInputs, element.outputs, stylingBuilder, [],
boundI18nAttrs);
parameters.push(this.addAttrsToConsts(attributes)); parameters.push(this.addAttrsToConsts(attributes));
// local refs (ex.: <div #foo #bar="baz">) // local refs (ex.: <div #foo #bar="baz">)
@ -662,7 +662,7 @@ export class TemplateDefinitionBuilder implements t.Visitor<void>, LocalResolver
element.children.length > 0; element.children.length > 0;
const createSelfClosingInstruction = !stylingBuilder.hasBindingsWithPipes && const createSelfClosingInstruction = !stylingBuilder.hasBindingsWithPipes &&
element.outputs.length === 0 && i18nAttrs.length === 0 && !hasChildren; element.outputs.length === 0 && boundI18nAttrs.length === 0 && !hasChildren;
const createSelfClosingI18nInstruction = const createSelfClosingI18nInstruction =
!createSelfClosingInstruction && hasTextChildrenOnly(element.children); !createSelfClosingInstruction && hasTextChildrenOnly(element.children);
@ -679,9 +679,9 @@ export class TemplateDefinitionBuilder implements t.Visitor<void>, LocalResolver
this.creationInstruction(element.startSourceSpan, R3.disableBindings); this.creationInstruction(element.startSourceSpan, R3.disableBindings);
} }
if (i18nAttrs.length > 0) { if (boundI18nAttrs.length > 0) {
this.i18nAttributesInstruction( this.i18nAttributesInstruction(
elementIndex, i18nAttrs, element.startSourceSpan ?? element.sourceSpan); elementIndex, boundI18nAttrs, element.startSourceSpan ?? element.sourceSpan);
} }
// Generate Listeners (outputs) // Generate Listeners (outputs)
@ -866,10 +866,11 @@ export class TemplateDefinitionBuilder implements t.Visitor<void>, LocalResolver
this.matchDirectives(NG_TEMPLATE_TAG_NAME, template); this.matchDirectives(NG_TEMPLATE_TAG_NAME, template);
// prepare attributes parameter (including attributes used for directive matching) // prepare attributes parameter (including attributes used for directive matching)
const [i18nStaticAttrs, staticAttrs] = partitionArray(template.attributes, hasI18nMeta); const [boundI18nAttrs, attrs] = partitionArray<t.BoundAttribute, t.TextAttribute>(
template.attributes, isBoundI18nAttribute);
const attrsExprs: o.Expression[] = this.getAttributeExpressions( const attrsExprs: o.Expression[] = this.getAttributeExpressions(
NG_TEMPLATE_TAG_NAME, staticAttrs, template.inputs, template.outputs, NG_TEMPLATE_TAG_NAME, attrs, template.inputs, template.outputs, undefined /* styles */,
undefined /* styles */, template.templateAttrs, i18nStaticAttrs); template.templateAttrs, boundI18nAttrs);
parameters.push(this.addAttrsToConsts(attrsExprs)); parameters.push(this.addAttrsToConsts(attrsExprs));
// local refs (ex.: <ng-template #foo>) // local refs (ex.: <ng-template #foo>)
@ -913,8 +914,9 @@ export class TemplateDefinitionBuilder implements t.Visitor<void>, LocalResolver
// Only add normal input/output binding instructions on explicit <ng-template> elements. // Only add normal input/output binding instructions on explicit <ng-template> elements.
if (template.tagName === NG_TEMPLATE_TAG_NAME) { if (template.tagName === NG_TEMPLATE_TAG_NAME) {
const [i18nInputs, inputs] = partitionArray(template.inputs, hasI18nMeta); const [i18nInputs, inputs] =
const i18nAttrs = [...i18nStaticAttrs, ...i18nInputs]; partitionArray<t.BoundAttribute, t.BoundAttribute>(template.inputs, hasI18nMeta);
const i18nAttrs = [...boundI18nAttrs, ...i18nInputs];
// Add i18n attributes that may act as inputs to directives. If such attributes are present, // Add i18n attributes that may act as inputs to directives. If such attributes are present,
// generate `i18nAttributes` instruction. Note: we generate it only for explicit <ng-template> // generate `i18nAttributes` instruction. Note: we generate it only for explicit <ng-template>
@ -1289,18 +1291,25 @@ export class TemplateDefinitionBuilder implements t.Visitor<void>, LocalResolver
elementName: string, renderAttributes: t.TextAttribute[], inputs: t.BoundAttribute[], elementName: string, renderAttributes: t.TextAttribute[], inputs: t.BoundAttribute[],
outputs: t.BoundEvent[], styles?: StylingBuilder, outputs: t.BoundEvent[], styles?: StylingBuilder,
templateAttrs: (t.BoundAttribute|t.TextAttribute)[] = [], templateAttrs: (t.BoundAttribute|t.TextAttribute)[] = [],
i18nAttrs: (t.BoundAttribute|t.TextAttribute)[] = []): o.Expression[] { boundI18nAttrs: t.BoundAttribute[] = []): o.Expression[] {
const alreadySeen = new Set<string>(); const alreadySeen = new Set<string>();
const attrExprs: o.Expression[] = []; const attrExprs: o.Expression[] = [];
let ngProjectAsAttr: t.TextAttribute|undefined; let ngProjectAsAttr: t.TextAttribute|undefined;
renderAttributes.forEach((attr: t.TextAttribute) => { for (const attr of renderAttributes) {
if (attr.name === NG_PROJECT_AS_ATTR_NAME) { if (attr.name === NG_PROJECT_AS_ATTR_NAME) {
ngProjectAsAttr = attr; ngProjectAsAttr = attr;
} }
attrExprs.push(
...getAttributeNameLiterals(attr.name), trustedConstAttribute(elementName, attr)); // Note that static i18n attributes aren't in the i18n array,
}); // because they're treated in the same way as regular attributes.
if (attr.i18n) {
attrExprs.push(o.literal(attr.name), this.i18nTranslate(attr.i18n as i18n.Message));
} else {
attrExprs.push(
...getAttributeNameLiterals(attr.name), trustedConstAttribute(elementName, attr));
}
}
// Keep ngProjectAs next to the other name, value pairs so we can verify that we match // Keep ngProjectAs next to the other name, value pairs so we can verify that we match
// ngProjectAs marker in the attribute name slot. // ngProjectAs marker in the attribute name slot.
@ -1360,9 +1369,9 @@ export class TemplateDefinitionBuilder implements t.Visitor<void>, LocalResolver
templateAttrs.forEach(attr => addAttrExpr(attr.name)); templateAttrs.forEach(attr => addAttrExpr(attr.name));
} }
if (i18nAttrs.length) { if (boundI18nAttrs.length) {
attrExprs.push(o.literal(core.AttributeMarker.I18n)); attrExprs.push(o.literal(core.AttributeMarker.I18n));
i18nAttrs.forEach(attr => addAttrExpr(attr.name)); boundI18nAttrs.forEach(attr => addAttrExpr(attr.name));
} }
return attrExprs; return attrExprs;

View File

@ -280,12 +280,12 @@ export function newArray<T>(size: number, value?: T): T[] {
* @param conditionFn Condition function that is called for each item in a given array and returns a * @param conditionFn Condition function that is called for each item in a given array and returns a
* boolean value. * boolean value.
*/ */
export function partitionArray<T>( export function partitionArray<T, F = T>(
arr: T[], conditionFn: <K extends T>(value: K) => boolean): [T[], T[]] { arr: (T|F)[], conditionFn: (value: T|F) => boolean): [T[], F[]] {
const truthy: T[] = []; const truthy: T[] = [];
const falsy: T[] = []; const falsy: F[] = [];
arr.forEach(item => { for (const item of arr) {
(conditionFn(item) ? truthy : falsy).push(item); (conditionFn(item) ? truthy : falsy).push(item as any);
}); }
return [truthy, falsy]; return [truthy, falsy];
} }

View File

@ -14,16 +14,14 @@ import {_sanitizeUrl, sanitizeSrcset} from '../../sanitization/url_sanitizer';
import {assertDefined, assertEqual, assertGreaterThanOrEqual, assertOneOf, assertString} from '../../util/assert'; import {assertDefined, assertEqual, assertGreaterThanOrEqual, assertOneOf, assertString} from '../../util/assert';
import {CharCode} from '../../util/char_code'; import {CharCode} from '../../util/char_code';
import {loadIcuContainerVisitor} from '../instructions/i18n_icu_container_visitor'; import {loadIcuContainerVisitor} from '../instructions/i18n_icu_container_visitor';
import {allocExpando, createTNodeAtIndex, elementAttributeInternal, setInputsForProperty, setNgReflectProperties} from '../instructions/shared'; import {allocExpando, createTNodeAtIndex} from '../instructions/shared';
import {getDocument} from '../interfaces/document'; import {getDocument} from '../interfaces/document';
import {ELEMENT_MARKER, I18nCreateOpCode, I18nCreateOpCodes, I18nRemoveOpCodes, I18nUpdateOpCode, I18nUpdateOpCodes, ICU_MARKER, IcuCreateOpCode, IcuCreateOpCodes, IcuExpression, IcuType, TI18n, TIcu} from '../interfaces/i18n'; import {ELEMENT_MARKER, I18nCreateOpCode, I18nCreateOpCodes, I18nRemoveOpCodes, I18nUpdateOpCode, I18nUpdateOpCodes, ICU_MARKER, IcuCreateOpCode, IcuCreateOpCodes, IcuExpression, IcuType, TI18n, TIcu} from '../interfaces/i18n';
import {TNode, TNodeType} from '../interfaces/node'; import {TNode, TNodeType} from '../interfaces/node';
import {RComment, RElement} from '../interfaces/renderer';
import {SanitizerFn} from '../interfaces/sanitization'; import {SanitizerFn} from '../interfaces/sanitization';
import {HEADER_OFFSET, LView, TView} from '../interfaces/view'; import {HEADER_OFFSET, LView, TView} from '../interfaces/view';
import {getCurrentParentTNode, getCurrentTNode, setCurrentTNode} from '../state'; import {getCurrentParentTNode, getCurrentTNode, setCurrentTNode} from '../state';
import {attachDebugGetter} from '../util/debug_utils'; import {attachDebugGetter} from '../util/debug_utils';
import {getNativeByIndex, getTNode} from '../util/view_utils';
import {i18nCreateOpCodesToString, i18nRemoveOpCodesToString, i18nUpdateOpCodesToString, icuCreateOpCodesToString} from './i18n_debug'; import {i18nCreateOpCodesToString, i18nRemoveOpCodesToString, i18nUpdateOpCodesToString, icuCreateOpCodesToString} from './i18n_debug';
import {addTNodeAndUpdateInsertBeforeIndex} from './i18n_insert_before_index'; import {addTNodeAndUpdateInsertBeforeIndex} from './i18n_insert_before_index';
@ -102,7 +100,10 @@ export function i18nStartFirstCreatePass(
// Verify that ICU expression has the right shape. Translations might contain invalid // Verify that ICU expression has the right shape. Translations might contain invalid
// constructions (while original messages were correct), so ICU parsing at runtime may // constructions (while original messages were correct), so ICU parsing at runtime may
// not succeed (thus `icuExpression` remains a string). // not succeed (thus `icuExpression` remains a string).
if (ngDevMode && typeof icuExpression !== 'object') { // Note: we intentionally retain the error here by not using `ngDevMode`, because
// the value can change based on the locale and users aren't guaranteed to hit
// an invalid string while they're developing.
if (typeof icuExpression !== 'object') {
throw new Error(`Unable to parse ICU expression in "${message}" message.`); throw new Error(`Unable to parse ICU expression in "${message}" message.`);
} }
const icuContainerTNode = createTNodeAndAddOpCode( const icuContainerTNode = createTNodeAndAddOpCode(
@ -225,54 +226,34 @@ function i18nStartFirstCreatePassProcessTextNode(
/** /**
* See `i18nAttributes` above. * See `i18nAttributes` above.
*/ */
export function i18nAttributesFirstPass( export function i18nAttributesFirstPass(tView: TView, index: number, values: string[]) {
lView: LView, tView: TView, index: number, values: string[]) {
const previousElement = getCurrentTNode()!; const previousElement = getCurrentTNode()!;
const previousElementIndex = previousElement.index; const previousElementIndex = previousElement.index;
const updateOpCodes: I18nUpdateOpCodes = [] as any; const updateOpCodes: I18nUpdateOpCodes = [] as any;
if (ngDevMode) { if (ngDevMode) {
attachDebugGetter(updateOpCodes, i18nUpdateOpCodesToString); attachDebugGetter(updateOpCodes, i18nUpdateOpCodesToString);
} }
for (let i = 0; i < values.length; i += 2) { if (tView.firstCreatePass && tView.data[index] === null) {
const attrName = values[i]; for (let i = 0; i < values.length; i += 2) {
const message = values[i + 1]; const attrName = values[i];
const parts = message.split(ICU_REGEXP); const message = values[i + 1];
for (let j = 0; j < parts.length; j++) {
const value = parts[j];
if (j & 1) { if (message !== '') {
// Odd indexes are ICU expressions // Check if attribute value contains an ICU and throw an error if that's the case.
// TODO(ocombe): support ICU expressions in attributes // ICUs in element attributes are not supported.
throw new Error('ICU expressions are not yet supported in attributes'); // Note: we intentionally retain the error here by not using `ngDevMode`, because
} else if (value !== '') { // the `value` can change based on the locale and users aren't guaranteed to hit
// Even indexes are text (including bindings) // an invalid string while they're developing.
const hasBinding = !!value.match(BINDING_REGEXP); if (ICU_REGEXP.test(message)) {
if (hasBinding) { throw new Error(
if (tView.firstCreatePass && tView.data[index] === null) { `ICU expressions are not supported in attributes. Message: "${message}".`);
generateBindingUpdateOpCodes(updateOpCodes, value, previousElementIndex, attrName);
}
} else {
const tNode = getTNode(tView, previousElementIndex);
// Set attributes for Elements only, for other types (like ElementContainer),
// only set inputs below
if (tNode.type & TNodeType.AnyRNode) {
elementAttributeInternal(tNode, lView, attrName, value, null, null);
}
// Check if that attribute is a directive input
const dataValue = tNode.inputs !== null && tNode.inputs[attrName];
if (dataValue) {
setInputsForProperty(tView, lView, dataValue, attrName, value);
if (ngDevMode) {
const element = getNativeByIndex(previousElementIndex, lView) as RElement | RComment;
setNgReflectProperties(lView, element, tNode.type, dataValue, value);
}
}
} }
// i18n attributes that hit this code path are guaranteed to have bindings, because
// the compiler treats static i18n attributes as regular attribute bindings.
generateBindingUpdateOpCodes(updateOpCodes, message, previousElementIndex, attrName);
} }
} }
}
if (tView.firstCreatePass && tView.data[index] === null) {
tView.data[index] = updateOpCodes; tView.data[index] = updateOpCodes;
} }
} }
@ -622,10 +603,10 @@ function walkIcuTree(
generateBindingUpdateOpCodes(update, attr.value, newIndex, attr.name); generateBindingUpdateOpCodes(update, attr.value, newIndex, attr.name);
} }
} else { } else {
ngDevMode && console.warn(` WARNING: ngDevMode &&
ignoring unsafe attribute value ${lowerAttrName} on element $ { console.warn(
tagName `WARNING: ignoring unsafe attribute value ` +
} (see http://g.co/ng/security#xss)`); `${lowerAttrName} on element ${tagName} (see http://g.co/ng/security#xss)`);
} }
} else { } else {
addCreateAttribute(create, newIndex, attr); addCreateAttribute(create, newIndex, attr);
@ -705,4 +686,4 @@ function addCreateNodeAndAppend(
function addCreateAttribute(create: IcuCreateOpCodes, newIndex: number, attr: Attr) { function addCreateAttribute(create: IcuCreateOpCodes, newIndex: number, attr: Attr) {
create.push(newIndex << IcuCreateOpCode.SHIFT_REF | IcuCreateOpCode.Attr, attr.name, attr.value); create.push(newIndex << IcuCreateOpCode.SHIFT_REF | IcuCreateOpCode.Attr, attr.name, attr.value);
} }

View File

@ -122,11 +122,10 @@ export function ɵɵi18n(index: number, messageIndex: number, subTemplateIndex?:
* @codeGenApi * @codeGenApi
*/ */
export function ɵɵi18nAttributes(index: number, attrsIndex: number): void { export function ɵɵi18nAttributes(index: number, attrsIndex: number): void {
const lView = getLView();
const tView = getTView(); const tView = getTView();
ngDevMode && assertDefined(tView, `tView should be defined`); ngDevMode && assertDefined(tView, `tView should be defined`);
const attrs = getConstant<string[]>(tView.consts, attrsIndex)!; const attrs = getConstant<string[]>(tView.consts, attrsIndex)!;
i18nAttributesFirstPass(lView, tView, index + HEADER_OFFSET, attrs); i18nAttributesFirstPass(tView, index + HEADER_OFFSET, attrs);
} }
@ -181,4 +180,4 @@ export function ɵɵi18nApply(index: number) {
export function ɵɵi18nPostprocess( export function ɵɵi18nPostprocess(
message: string, replacements: {[key: string]: (string|string[])} = {}): string { message: string, replacements: {[key: string]: (string|string[])} = {}): string {
return i18nPostprocess(message, replacements); return i18nPostprocess(message, replacements);
} }

View File

@ -2054,6 +2054,26 @@ describe('di', () => {
expect(directive.outputAttr).toBeNull(); expect(directive.outputAttr).toBeNull();
expect(directive.other).toBe('otherValue'); expect(directive.other).toBe('otherValue');
}); });
it('should inject `null` for attributes with data bindings', () => {
@Directive({selector: '[dir]'})
class MyDir {
constructor(@Attribute('title') public attrValue: string) {}
}
@Component({template: '<div dir title="title {{ value }}"></div>'})
class MyComp {
@ViewChild(MyDir) directiveInstance!: MyDir;
value = 'value';
}
TestBed.configureTestingModule({declarations: [MyDir, MyComp]});
const fixture = TestBed.createComponent(MyComp);
fixture.detectChanges();
expect(fixture.componentInstance.directiveInstance.attrValue).toBeNull();
expect(fixture.nativeElement.querySelector('div').getAttribute('title')).toBe('title value');
});
}); });
it('should support dependencies in Pipes used inside ICUs', () => { it('should support dependencies in Pipes used inside ICUs', () => {

View File

@ -13,7 +13,7 @@ import {CommonModule, DOCUMENT, registerLocaleData} from '@angular/common';
import localeEs from '@angular/common/locales/es'; import localeEs from '@angular/common/locales/es';
import localeRo from '@angular/common/locales/ro'; import localeRo from '@angular/common/locales/ro';
import {computeMsgId} from '@angular/compiler'; import {computeMsgId} from '@angular/compiler';
import {Component, ContentChild, ContentChildren, Directive, ElementRef, HostBinding, Input, LOCALE_ID, NO_ERRORS_SCHEMA, Pipe, PipeTransform, QueryList, RendererFactory2, TemplateRef, Type, ViewChild, ViewContainerRef, ɵsetDocument} from '@angular/core'; import {Attribute, Component, ContentChild, ContentChildren, Directive, ElementRef, HostBinding, Input, LOCALE_ID, NO_ERRORS_SCHEMA, Pipe, PipeTransform, QueryList, RendererFactory2, TemplateRef, Type, ViewChild, ViewContainerRef, ɵsetDocument} from '@angular/core';
import {DebugNode, HEADER_OFFSET, TVIEW} from '@angular/core/src/render3/interfaces/view'; import {DebugNode, HEADER_OFFSET, TVIEW} from '@angular/core/src/render3/interfaces/view';
import {getComponentLView} from '@angular/core/src/render3/util/discovery_utils'; import {getComponentLView} from '@angular/core/src/render3/util/discovery_utils';
import {TestBed} from '@angular/core/testing'; import {TestBed} from '@angular/core/testing';
@ -642,9 +642,9 @@ onlyInIvy('Ivy i18n logic').describe('runtime i18n', () => {
// such a case a single `TIcuContainerNode` should be generated only. // such a case a single `TIcuContainerNode` should be generated only.
it('should create a single dynamic TNode for ICU', () => { it('should create a single dynamic TNode for ICU', () => {
const fixture = initWithTemplate(AppComp, ` const fixture = initWithTemplate(AppComp, `
{count, plural, {count, plural,
=0 {just now} =0 {just now}
=1 {one minute ago} =1 {one minute ago}
other {{{count}} minutes ago} other {{{count}} minutes ago}
} }
`.trim()); `.trim());
@ -662,13 +662,13 @@ onlyInIvy('Ivy i18n logic').describe('runtime i18n', () => {
it('should support multiple ICUs', () => { it('should support multiple ICUs', () => {
const fixture = initWithTemplate(AppComp, ` const fixture = initWithTemplate(AppComp, `
{count, plural, {count, plural,
=0 {just now} =0 {just now}
=1 {one minute ago} =1 {one minute ago}
other {{{count}} minutes ago} other {{{count}} minutes ago}
} }
{name, select, {name, select,
Angular {Mr. Angular} Angular {Mr. Angular}
other {Sir} other {Sir}
} }
`); `);
@ -1843,40 +1843,6 @@ onlyInIvy('Ivy i18n logic').describe('runtime i18n', () => {
expect(fixture.nativeElement.firstChild.title).toEqual(`ANGULAR - value 1 - value 2 (fr)`); expect(fixture.nativeElement.firstChild.title).toEqual(`ANGULAR - value 1 - value 2 (fr)`);
}); });
it('should create corresponding ng-reflect properties', () => {
@Component({
selector: 'welcome',
template: '{{ messageText }}',
})
class WelcomeComp {
@Input() messageText!: string;
}
@Component({
template: `
<welcome
messageText="Hello"
i18n-messageText="Welcome message description">
</welcome>
`
})
class App {
}
TestBed.configureTestingModule({
declarations: [App, WelcomeComp],
});
loadTranslations({
[computeMsgId('Hello')]: 'Bonjour',
});
const fixture = TestBed.createComponent(App);
fixture.detectChanges();
const comp = fixture.debugElement.query(By.css('welcome'));
expect(comp.attributes['messagetext']).toBe('Bonjour');
expect(comp.attributes['ng-reflect-message-text']).toBe('Bonjour');
});
it('should support i18n attributes on <ng-container> elements', () => { it('should support i18n attributes on <ng-container> elements', () => {
loadTranslations({[computeMsgId('Hello', 'meaning')]: 'Bonjour'}); loadTranslations({[computeMsgId('Hello', 'meaning')]: 'Bonjour'});
@ -3024,6 +2990,50 @@ onlyInIvy('Ivy i18n logic').describe('runtime i18n', () => {
fixture.detectChanges(); fixture.detectChanges();
expect(fixture.nativeElement.textContent).toEqual(`two,one,`); expect(fixture.nativeElement.textContent).toEqual(`two,one,`);
}); });
it('should be able to inject a static i18n attribute', () => {
loadTranslations({[computeMsgId('text')]: 'translatedText'});
@Directive({selector: '[injectTitle]'})
class InjectTitleDir {
constructor(@Attribute('title') public title: string) {}
}
@Component({template: `<div i18n-title title="text" injectTitle></div>`})
class App {
@ViewChild(InjectTitleDir) dir!: InjectTitleDir;
}
TestBed.configureTestingModule({declarations: [App, InjectTitleDir]});
const fixture = TestBed.createComponent(App);
fixture.detectChanges();
expect(fixture.componentInstance.dir.title).toBe('translatedText');
expect(fixture.nativeElement.querySelector('div').getAttribute('title')).toBe('translatedText');
});
it('should inject `null` for an i18n attribute with an interpolation', () => {
loadTranslations({[computeMsgId('text {$INTERPOLATION}')]: 'translatedText {$INTERPOLATION}'});
@Directive({selector: '[injectTitle]'})
class InjectTitleDir {
constructor(@Attribute('title') public title: string) {}
}
@Component({template: `<div i18n-title title="text {{ value }}" injectTitle></div>`})
class App {
@ViewChild(InjectTitleDir) dir!: InjectTitleDir;
value = 'value';
}
TestBed.configureTestingModule({declarations: [App, InjectTitleDir]});
const fixture = TestBed.createComponent(App);
fixture.detectChanges();
expect(fixture.componentInstance.dir.title).toBeNull();
expect(fixture.nativeElement.querySelector('div').getAttribute('title'))
.toBe('translatedText value');
});
}); });
function initWithTemplate(compType: Type<any>, template: string) { function initWithTemplate(compType: Type<any>, template: string) {

View File

@ -416,30 +416,6 @@ describe('Runtime i18n', () => {
}); });
describe(`i18nAttribute`, () => { describe(`i18nAttribute`, () => {
it('for text', () => {
const message = `Hello world!`;
const attrs = ['title', message];
const nbDecls = 2;
const index = 1;
const fixture = new TemplateFixture({
create: () => {
ɵɵelementStart(0, 'div');
ɵɵi18nAttributes(index, 0);
ɵɵelementEnd();
},
decls: nbDecls,
vars: index,
consts: [attrs],
});
const tView = fixture.hostView[TVIEW];
const opCodes = tView.data[HEADER_OFFSET + index] as I18nUpdateOpCodes;
expect(opCodes).toEqual([]);
expect((getNativeByIndex(HEADER_OFFSET, fixture.hostView as LView) as any as Element)
.getAttribute('title'))
.toEqual(message);
});
it('for simple bindings', () => { it('for simple bindings', () => {
const message = `Hello <20>0<EFBFBD>!`; const message = `Hello <20>0<EFBFBD>!`;
const attrs = ['title', message]; const attrs = ['title', message];