From d7b9345b6d5f105109991bf3a3a8ee8d310a7a3d Mon Sep 17 00:00:00 2001 From: Pawel Kozlowski Date: Wed, 17 Jun 2015 16:05:35 +0200 Subject: [PATCH] feat(compiler): detect dangling property bindings BREAKING CHANGE: compiler will throw on binding to non-existing properties. Till now it was possible to have a binding to a non-existing property, ex.: `
`. From now on this is compilation error - any property binding needs to have at least one associated property: eaither on an HTML element or on any directive associated with a given element (directives' properites need to be declared using the `properties` field in the `@Directive` / `@Component` annotation). Closes #2598 --- .../render/dom/compiler/directive_parser.ts | 1 + .../dom/view/property_setter_factory.ts | 4 +- .../src/render/dom/view/proto_view_builder.ts | 20 ++++++- .../test/core/compiler/integration_spec.ts | 59 ++++++++++--------- .../directive_lifecycle_integration_spec.ts | 2 +- .../angular2/test/directives/ng_for_spec.ts | 2 +- .../test/directives/ng_switch_spec.ts | 6 +- .../shadow_dom_emulation_integration_spec.ts | 2 +- modules/benchmarks/src/costs/index.ts | 2 +- .../src/largetable/largetable_benchmark.ts | 4 +- 10 files changed, 61 insertions(+), 41 deletions(-) diff --git a/modules/angular2/src/render/dom/compiler/directive_parser.ts b/modules/angular2/src/render/dom/compiler/directive_parser.ts index 7bcb499ebf..08acb733b4 100644 --- a/modules/angular2/src/render/dom/compiler/directive_parser.ts +++ b/modules/angular2/src/render/dom/compiler/directive_parser.ts @@ -149,6 +149,7 @@ export class DirectiveParser implements CompileStep { if (isPresent(bindingAst)) { directiveBinderBuilder.bindProperty(dirProperty, bindingAst); } + compileElement.bindElement().bindPropertyToDirective(dashCaseToCamelCase(elProp)); } _bindDirectiveEvent(eventName, action, compileElement, directiveBinderBuilder) { diff --git a/modules/angular2/src/render/dom/view/property_setter_factory.ts b/modules/angular2/src/render/dom/view/property_setter_factory.ts index 5fe4d8df13..403d897904 100644 --- a/modules/angular2/src/render/dom/view/property_setter_factory.ts +++ b/modules/angular2/src/render/dom/view/property_setter_factory.ts @@ -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 = StringMapWrapper.create(); private _eagerPropertySettersCache: StringMap = StringMapWrapper.create(); @@ -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); } diff --git a/modules/angular2/src/render/dom/view/proto_view_builder.ts b/modules/angular2/src/render/dom/view/proto_view_builder.ts index 61b7e10da0..06a8ff97ae 100644 --- a/modules/angular2/src/render/dom/view/proto_view_builder.ts +++ b/modules/angular2/src/render/dom/view/proto_view_builder.ts @@ -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 = @@ -170,6 +177,7 @@ export class ElementBinderBuilder { nestedProtoView: ProtoViewBuilder = null; propertyBindings: Map = new Map(); variableBindings: Map = new Map(); + propertyBindingsToDirectives: Set = new Set(); eventBindings: List = []; eventBuilder: EventBuilder = new EventBuilder(); textBindingNodes: List = []; @@ -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 diff --git a/modules/angular2/test/core/compiler/integration_spec.ts b/modules/angular2/test/core/compiler/integration_spec.ts index f898bdfd1e..2183ae5e31 100644 --- a/modules/angular2/test/core/compiler/integration_spec.ts +++ b/modules/angular2/test/core/compiler/integration_spec.ts @@ -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: '
'})); - - 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 = '
' + @@ -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: '
', + template: '
', directives: [MyDir] })); @@ -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: '

', + template: '

', directives: [ExportDir] })); @@ -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: '
'})); + tb.overrideView(MyComp, new viewAnn.View({template: '
'})); tb.createView(MyComp, {context: ctx}) .then((view) => { @@ -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: '', directives: [ChildComp]})); + tb.overrideView(MyComp, new viewAnn.View({ + template: '', + directives: [ChildComp] + })); tb.createView(MyComp, {context: ctx}) .then((view) => { @@ -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: '
'})); + + 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: '
', 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 @@ -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 { diff --git a/modules/angular2/test/core/directive_lifecycle_integration_spec.ts b/modules/angular2/test/core/directive_lifecycle_integration_spec.ts index 2ea9d8f9ac..a4d72553c3 100644 --- a/modules/angular2/test/core/directive_lifecycle_integration_spec.ts +++ b/modules/angular2/test/core/directive_lifecycle_integration_spec.ts @@ -36,7 +36,7 @@ export function main() { tb.overrideView( MyComp, new viewAnn.View( - {template: '
', directives: [LifecycleDir]})); + {template: '
', directives: [LifecycleDir]})); tb.createView(MyComp, {context: ctx}) .then((view) => { diff --git a/modules/angular2/test/directives/ng_for_spec.ts b/modules/angular2/test/directives/ng_for_spec.ts index 66564597e6..108e03106d 100644 --- a/modules/angular2/test/directives/ng_for_spec.ts +++ b/modules/angular2/test/directives/ng_for_spec.ts @@ -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 = '