fix(ivy): incorrectly remapping certain properties that refer inputs (#28765)

During build time we remap particular property bindings, because their names don't match their attribute equivalents (e.g. the property for the `for` attribute is called `htmlFor`). This breaks down if the particular element has an input that has the same name, because the property gets mapped to something invalid.

The following changes address the issue by mapping the name during runtime, because that's when directives are resolved and we know all of the inputs that are associated with a particular element.

PR Close #28765
This commit is contained in:
Kristiyan Kostadinov 2019-02-15 21:55:07 +01:00 committed by Igor Minar
parent 9defc00b14
commit 93a7836f7a
6 changed files with 95 additions and 14 deletions

View File

@ -242,11 +242,11 @@ class HtmlAstToIvyAst implements html.Visitor {
literal.push(new t.TextAttribute(
prop.name, prop.expression.source || '', prop.sourceSpan, undefined, i18n));
} else {
// we skip validation here, since we do this check at runtime due to the fact that we need
// to make sure a given prop is not an input of some Directive (thus should not be a subject
// of this check) and Directive matching happens at runtime
// Note that validation is skipped and property mapping is disabled
// due to the fact that we need to make sure a given prop is not an
// input of a directive and directive matching happens at runtime.
const bep = this.bindingParser.createBoundElementProperty(
elementName, prop, /* skipValidation */ true);
elementName, prop, /* skipValidation */ true, /* mapPropertyName */ false);
bound.push(t.BoundAttribute.fromBoundElementProperty(bep, i18n));
}
});

View File

@ -240,8 +240,8 @@ export class BindingParser {
}
createBoundElementProperty(
elementSelector: string, boundProp: ParsedProperty,
skipValidation: boolean = false): BoundElementProperty {
elementSelector: string, boundProp: ParsedProperty, skipValidation: boolean = false,
mapPropertyName: boolean = true): BoundElementProperty {
if (boundProp.isAnimation) {
return new BoundElementProperty(
boundProp.name, BindingType.Animation, SecurityContext.NONE, boundProp.expression, null,
@ -286,12 +286,13 @@ export class BindingParser {
// If not a special case, use the full property name
if (boundPropertyName === null) {
boundPropertyName = this._schemaRegistry.getMappedPropName(boundProp.name);
const mappedPropName = this._schemaRegistry.getMappedPropName(boundProp.name);
boundPropertyName = mapPropertyName ? mappedPropName : boundProp.name;
securityContexts = calcPossibleSecurityContexts(
this._schemaRegistry, elementSelector, boundPropertyName, false);
this._schemaRegistry, elementSelector, mappedPropName, false);
bindingType = BindingType.Property;
if (!skipValidation) {
this._validatePropertyOrAttributeName(boundPropertyName, boundProp.sourceSpan, false);
this._validatePropertyOrAttributeName(mappedPropName, boundProp.sourceSpan, false);
}
}
@ -455,4 +456,4 @@ export function calcPossibleSecurityContexts(
elementName => registry.securityContext(elementName, propName, isAttribute)));
});
return ctxs.length === 0 ? [SecurityContext.NONE] : Array.from(new Set(ctxs)).sort();
}
}

View File

@ -176,10 +176,10 @@ describe('R3 template transform', () => {
]);
});
it('should normalize property names via the element schema', () => {
it('should not normalize property names via the element schema', () => {
expectFromHtml('<div [mappedAttr]="v"></div>').toEqual([
['Element', 'div'],
['BoundAttribute', BindingType.Property, 'mappedProp', 'v'],
['BoundAttribute', BindingType.Property, 'mappedAttr', 'v'],
]);
});
@ -499,4 +499,4 @@ describe('R3 template transform', () => {
]);
});
});
});
});

View File

@ -1213,6 +1213,18 @@ export function componentHostSyntheticProperty<T>(
elementPropertyInternal(index, propName, value, sanitizer, nativeOnly, loadComponentRenderer);
}
/**
* Mapping between attributes names that don't correspond to their element property names.
*/
const ATTR_TO_PROP: {[name: string]: string} = {
'class': 'className',
'for': 'htmlFor',
'formaction': 'formAction',
'innerHtml': 'innerHTML',
'readonly': 'readOnly',
'tabindex': 'tabIndex',
};
function elementPropertyInternal<T>(
index: number, propName: string, value: T | NO_CHANGE, sanitizer?: SanitizerFn | null,
nativeOnly?: boolean,
@ -1233,6 +1245,8 @@ function elementPropertyInternal<T>(
}
}
} else if (tNode.type === TNodeType.Element) {
propName = ATTR_TO_PROP[propName] || propName;
if (ngDevMode) {
validateAgainstEventProperties(propName);
validateAgainstUnknownProperties(lView, element, propName, tNode);

View File

@ -5,6 +5,9 @@
{
"name": "ANIMATION_PROP_PREFIX"
},
{
"name": "ATTR_TO_PROP"
},
{
"name": "BINDING_INDEX"
},

View File

@ -11,7 +11,6 @@ import {EventEmitter} from '@angular/core';
import {defineComponent, defineDirective} from '../../src/render3/index';
import {bind, container, containerRefreshEnd, containerRefreshStart, element, elementEnd, elementProperty, elementStart, embeddedViewEnd, embeddedViewStart, interpolation1, listener, load, reference, text, textBinding} from '../../src/render3/instructions';
import {RenderFlags} from '../../src/render3/interfaces/definition';
import {NO_CHANGE} from '../../src/render3/tokens';
import {ComponentFixture, createComponent, renderToHtml} from './render_util';
@ -77,6 +76,26 @@ describe('elementProperty', () => {
expect(fixture.html).toEqual('<span id="_otherId_"></span>');
});
it('should map properties whose names do not correspond to their attribute names', () => {
const App = createComponent('app', function(rf: RenderFlags, ctx: any) {
if (rf & RenderFlags.Create) {
element(0, 'label');
}
if (rf & RenderFlags.Update) {
elementProperty(0, 'for', bind(ctx.forValue));
}
}, 1, 1);
const fixture = new ComponentFixture(App);
fixture.component.forValue = 'some-input';
fixture.update();
expect(fixture.html).toEqual('<label for="some-input"></label>');
fixture.component.forValue = 'some-textarea';
fixture.update();
expect(fixture.html).toEqual('<label for="some-textarea"></label>');
});
describe('input properties', () => {
let button: MyButton;
let otherDir: OtherDir;
@ -365,6 +384,50 @@ describe('elementProperty', () => {
expect(otherDir !.id).toEqual(3);
});
it('should not map properties whose names do not correspond to their attribute names, ' +
'if they correspond to inputs',
() => {
let comp: Comp;
class Comp {
// TODO(issue/24571): remove '!'.
// clang-format off
for !: string;
// clang-format on
static ngComponentDef = defineComponent({
type: Comp,
selectors: [['comp']],
consts: 0,
vars: 0,
template: function(rf: RenderFlags, ctx: any) {},
factory: () => comp = new Comp(),
inputs: {for: 'for'}
});
}
/** <comp [for]="forValue"></comp> */
const App = createComponent('app', function(rf: RenderFlags, ctx: any) {
if (rf & RenderFlags.Create) {
element(0, 'comp');
}
if (rf & RenderFlags.Update) {
elementProperty(0, 'for', bind(ctx.forValue));
}
}, 1, 1, [Comp]);
const fixture = new ComponentFixture(App);
fixture.component.forValue = 'hello';
fixture.update();
expect(fixture.html).toEqual(`<comp></comp>`);
expect(comp !.for).toEqual('hello');
fixture.component.forValue = 'hej';
fixture.update();
expect(fixture.html).toEqual(`<comp></comp>`);
expect(comp !.for).toEqual('hej');
});
});
describe('attributes and input properties', () => {