Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(compiler): detect dangling property bindings #2598

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
Expand Up @@ -149,6 +149,7 @@ export class DirectiveParser implements CompileStep {
if (isPresent(bindingAst)) {
directiveBinderBuilder.bindProperty(dirProperty, bindingAst);
}
compileElement.bindElement().bindPropertyToDirective(dashCaseToCamelCase(elProp));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need this extra information? The ProtoViewBuilder has all binding information of the element and all of it's directives already...

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@tbosch I believe that we need it due to the properties re-mapping. When we bind to directives we know only about property name on the directive side, but not about the original property binding.

}

_bindDirectiveEvent(eventName, action, compileElement, directiveBinderBuilder) {
Expand Down
Expand Up @@ -18,7 +18,7 @@ const CLASS_PREFIX = 'class.';
const STYLE_PREFIX = 'style.';

export class PropertySetterFactory {
private static _noopSetter(el, value) {}
static noopSetter(el, value) {}

private _lazyPropertySettersCache: StringMap<string, Function> = StringMapWrapper.create();
private _eagerPropertySettersCache: StringMap<string, Function> = StringMapWrapper.create();
Expand Down Expand Up @@ -69,7 +69,7 @@ export class PropertySetterFactory {
if (DOM.hasProperty(protoElement, property)) {
setterFn = reflector.setter(property);
} else {
setterFn = PropertySetterFactory._noopSetter;
setterFn = PropertySetterFactory.noopSetter;
}
StringMapWrapper.set(this._eagerPropertySettersCache, property, setterFn);
}
Expand Down
20 changes: 17 additions & 3 deletions modules/angular2/src/render/dom/view/proto_view_builder.ts
Expand Up @@ -75,10 +75,17 @@ export class ProtoViewBuilder {
});

MapWrapper.forEach(ebb.propertyBindings, (_, propertyName) => {
var propSetter =
setterFactory.createSetter(ebb.element, isPresent(ebb.componentId), propertyName);

propertySetters.set(
propertyName,
setterFactory.createSetter(ebb.element, isPresent(ebb.componentId), propertyName));
if (propSetter === PropertySetterFactory.noopSetter) {
if (!SetWrapper.has(ebb.propertyBindingsToDirectives, propertyName)) {
throw new BaseException(
`Can't bind to '${propertyName}' since it isn't a know property of the '${DOM.tagName(ebb.element).toLowerCase()}' element and there are no matching directives with a corresponding property`);
}
}

propertySetters.set(propertyName, propSetter);
});

var nestedProtoView =
Expand Down Expand Up @@ -170,6 +177,7 @@ export class ElementBinderBuilder {
nestedProtoView: ProtoViewBuilder = null;
propertyBindings: Map<string, ASTWithSource> = new Map();
variableBindings: Map<string, string> = new Map();
propertyBindingsToDirectives: Set<string> = new Set();
eventBindings: List<api.EventBinding> = [];
eventBuilder: EventBuilder = new EventBuilder();
textBindingNodes: List</*node*/ any> = [];
Expand Down Expand Up @@ -210,6 +218,12 @@ export class ElementBinderBuilder {

bindProperty(name, expression) { this.propertyBindings.set(name, expression); }

bindPropertyToDirective(name: string) {
// we are filling in a set of property names that are bound to a property
// of at least one directive. This allows us to report "dangling" bindings.
this.propertyBindingsToDirectives.add(name);
}

bindVariable(name, value) {
// When current is a view root, the variable bindings are set to the *nested* proto view.
// The root view conceptually signifies a new "block scope" (the nested view), to which
Expand Down
59 changes: 32 additions & 27 deletions modules/angular2/test/core/compiler/integration_spec.ts
Expand Up @@ -192,22 +192,6 @@ export function main() {
});
}));

it('should ignore bindings to unknown properties',
inject([TestBed, AsyncTestCompleter], (tb: TestBed, async) => {
tb.overrideView(MyComp,
new viewAnn.View({template: '<div unknown="{{ctxProp}}"></div>'}));

tb.createView(MyComp, {context: ctx})
.then((view) => {

ctx.ctxProp = 'Some value';
view.detectChanges();
expect(DOM.hasProperty(view.rootNodes[0], 'unknown')).toBeFalsy();

async.done();
});
}));

it('should consume directive watch expression change.',
inject([TestBed, AsyncTestCompleter], (tb: TestBed, async) => {
var tpl = '<div>' +
Expand Down Expand Up @@ -247,7 +231,7 @@ export function main() {
it("should support pipes in bindings",
inject([TestBed, AsyncTestCompleter], (tb: TestBed, async) => {
tb.overrideView(MyComp, new viewAnn.View({
template: '<div [my-dir] #dir="mydir" [elprop]="ctxProp | double"></div>',
template: '<div my-dir #dir="mydir" [elprop]="ctxProp | double"></div>',
directives: [MyDir]
}));

Expand Down Expand Up @@ -442,7 +426,7 @@ export function main() {
it('should assign a directive to a var-',
inject([TestBed, AsyncTestCompleter], (tb: TestBed, async) => {
tb.overrideView(MyComp, new viewAnn.View({
template: '<p><div [export-dir] #localdir="dir"></div></p>',
template: '<p><div export-dir #localdir="dir"></div></p>',
directives: [ExportDir]
}));

Expand Down Expand Up @@ -1144,7 +1128,7 @@ export function main() {
it('should specify a location of an error that happened during change detection (element property)',
inject([TestBed, AsyncTestCompleter], (tb: TestBed, async) => {

tb.overrideView(MyComp, new viewAnn.View({template: '<div [prop]="a.b"></div>'}));
tb.overrideView(MyComp, new viewAnn.View({template: '<div [title]="a.b"></div>'}));

tb.createView(MyComp, {context: ctx})
.then((view) => {
Expand All @@ -1157,10 +1141,10 @@ export function main() {
it('should specify a location of an error that happened during change detection (directive property)',
inject([TestBed, AsyncTestCompleter], (tb: TestBed, async) => {

tb.overrideView(
MyComp,
new viewAnn.View(
{template: '<child-cmp [prop]="a.b"></child-cmp>', directives: [ChildComp]}));
tb.overrideView(MyComp, new viewAnn.View({
template: '<child-cmp [dir-prop]="a.b"></child-cmp>',
directives: [ChildComp]
}));

tb.createView(MyComp, {context: ctx})
.then((view) => {
Expand Down Expand Up @@ -1205,6 +1189,30 @@ export function main() {
});
}));

describe('Missing property bindings', () => {
it('should throw on bindings to unknown properties',
inject([TestBed, AsyncTestCompleter], (tb: TestBed, async) => {
tb.overrideView(MyComp,
new viewAnn.View({template: '<div unknown="{{ctxProp}}"></div>'}));

PromiseWrapper.catchError(tb.createView(MyComp, {context: ctx}), (e) => {
expect(e.message).toEqual(
`Can't bind to 'unknown' since it isn't a know property of the 'div' element and there are no matching directives with a corresponding property`);
async.done();
return null;
});
}));

it('should not throw for property binding to a non-existing property when there is a matching directive property',
inject([TestBed, AsyncTestCompleter], (tb: TestBed, async) => {
tb.overrideView(
MyComp,
new viewAnn.View(
{template: '<div my-dir [elprop]="ctxProp"></div>', directives: [MyDir]}));
tb.createView(MyComp, {context: ctx}).then((val) => { async.done(); });
}));
});

// Disabled until a solution is found, refs:
// - https://github.com/angular/angular/issues/776
// - https://github.com/angular/angular/commit/81f3f32
Expand Down Expand Up @@ -1353,10 +1361,7 @@ class ComponentWithPipes {
prop: string;
}

@Component({
selector: 'child-cmp',
appInjector: [MyService],
})
@Component({selector: 'child-cmp', properties: ['dirProp'], appInjector: [MyService]})
@View({directives: [MyDir], template: '{{ctxProp}}'})
@Injectable()
class ChildComp {
Expand Down
Expand Up @@ -36,7 +36,7 @@ export function main() {
tb.overrideView(
MyComp,
new viewAnn.View(
{template: '<div [field]="123" [lifecycle]></div>', directives: [LifecycleDir]}));
{template: '<div [field]="123" lifecycle></div>', directives: [LifecycleDir]}));

tb.createView(MyComp, {context: ctx})
.then((view) => {
Expand Down
2 changes: 1 addition & 1 deletion modules/angular2/test/directives/ng_for_spec.ts
Expand Up @@ -205,7 +205,7 @@ export function main() {

it('should repeat over nested arrays with no intermediate element',
inject([TestBed, AsyncTestCompleter], (tb: TestBed, async) => {
var template = '<div><template [ng-for] #item [ng-for-of]="items">' +
var template = '<div><template ng-for #item [ng-for-of]="items">' +
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

having [ng-for] with no expression should be an error:

A property binding [ng-for] must have an expression associated with it.

Could we add a test?

'<div template="ng-for #subitem of item">' +
'{{subitem}}-{{item.length}};' +
'</div></template></div>';
Expand Down
6 changes: 3 additions & 3 deletions modules/angular2/test/directives/ng_switch_spec.ts
Expand Up @@ -79,8 +79,8 @@ export function main() {
'<template [ng-switch-when]="\'b\'"><li>when b1;</li></template>' +
'<template [ng-switch-when]="\'a\'"><li>when a2;</li></template>' +
'<template [ng-switch-when]="\'b\'"><li>when b2;</li></template>' +
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should be able to change this to: ng-swich-when="b" Could you do that for consistency?

'<template [ng-switch-default]><li>when default1;</li></template>' +
'<template [ng-switch-default]><li>when default2;</li></template>' +
'<template ng-switch-default><li>when default1;</li></template>' +
'<template ng-switch-default><li>when default2;</li></template>' +
'</ul></div>';

tb.createView(TestComponent, {html: template})
Expand Down Expand Up @@ -108,7 +108,7 @@ export function main() {
'<ul [ng-switch]="switchValue">' +
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same here drop []

'<template [ng-switch-when]="when1"><li>when 1;</li></template>' +
'<template [ng-switch-when]="when2"><li>when 2;</li></template>' +
'<template [ng-switch-default]><li>when default;</li></template>' +
'<template ng-switch-default><li>when default;</li></template>' +
'</ul></div>';

tb.createView(TestComponent, {html: template})
Expand Down
Expand Up @@ -511,7 +511,7 @@ var conditionalContentComponent = DirectiveMetadata.create({
});

var autoViewportDirective = DirectiveMetadata.create(
{selector: '[auto]', id: '[auto]', type: DirectiveMetadata.DIRECTIVE_TYPE});
{selector: '[auto]', id: 'auto', properties: ['auto'], type: DirectiveMetadata.DIRECTIVE_TYPE});

var tabComponent =
DirectiveMetadata.create({selector: 'tab', id: 'tab', type: DirectiveMetadata.COMPONENT_TYPE});
Expand Down
2 changes: 1 addition & 1 deletion modules/benchmarks/src/costs/index.ts
Expand Up @@ -77,7 +77,7 @@ class DynamicDummy {
</div>

<div *ng-if="testingWithDirectives">
<dummy [dummy-decorator] *ng-for="#i of list"></dummy>
<dummy dummy-decorator *ng-for="#i of list"></dummy>
</div>

<div *ng-if="testingDynamicComponents">
Expand Down
4 changes: 2 additions & 2 deletions modules/benchmarks/src/largetable/largetable_benchmark.ts
Expand Up @@ -232,7 +232,7 @@ class CellData {
</tbody>
<tbody template="ng-switch-when 'interpolationAttr'">
<tr template="ng-for #row of data">
<td template="ng-for #column of row" i="{{column.i}}" j="{{column.j}}">
<td template="ng-for #column of row" attr.i="{{column.i}}" attr.j="{{column.j}}">
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@tbosch I hope that this doesn't change the nature of this test too much - I had to modify this one as we don't support binding to non-existing properties after this change.

i,j attrs
</td>
</tr>
Expand Down Expand Up @@ -269,7 +269,7 @@ class LargetableComponent {
@Component({selector: 'app'})
@View({
directives: [LargetableComponent],
template: `<largetable [data]='data' [benchmarkType]='benchmarkType'></largetable>`
template: `<largetable [data]='data' [benchmark-type]='benchmarkType'></largetable>`
})
class AppComponent {
data;
Expand Down