diff --git a/lib/scope.dart b/lib/scope.dart index dbc1c7ac7..6ab2465b6 100644 --- a/lib/scope.dart +++ b/lib/scope.dart @@ -282,6 +282,22 @@ class Scope implements Map { } + bool _rootDigestScheduled = false; + _scheduleRootDigest() { + if ($root._rootDigestScheduled) return; + $root._rootDigestScheduled = true; + async.runAsync(() { + $root._rootDigestScheduled = false; + try { + $root.$digest(); + } catch (e, s) { + // Sadly, this stack trace is mostly useless now. + _exceptionHandler(e, s); + throw e; + } + }); + } + $apply([expr]) { try { _beginPhase('\$apply'); @@ -290,12 +306,7 @@ class Scope implements Map { _exceptionHandler(e, s); } finally { _clearPhase(); - try { - $root.$digest(); - } catch (e, s) { - _exceptionHandler(e, s); - throw e; - } + _scheduleRootDigest(); } } diff --git a/test/compiler_spec.dart b/test/compiler_spec.dart index d30f336a9..29a9e323d 100644 --- a/test/compiler_spec.dart +++ b/test/compiler_spec.dart @@ -267,39 +267,46 @@ main() { expect(renderedText(element)).toEqual('inside '); }))); - it('should create a component with IO', inject(() { + it('should create a component with IO', async(inject(() { var element = $(r'
'); $compile(element)(injector, element); $rootScope.name = 'misko'; $rootScope.$apply(); + nextTurn(true); + var component = $rootScope.ioComponent; expect(component.scope.name).toEqual(null); expect(component.scope.attr).toEqual('A'); expect(component.scope.expr).toEqual('misko'); component.scope.expr = 'angular'; $rootScope.$apply(); + nextTurn(true); + expect($rootScope.name).toEqual('angular'); expect($rootScope.done).toEqual(null); component.scope.ondone(); expect($rootScope.done).toEqual(true); - })); + }))); - it('should create a component with IO and "=" binding value should be available', inject(() { + it('should create a component with IO and "=" binding value should be available', async(inject(() { $rootScope.name = 'misko'; var element = $(r'
'); $compile(element)(injector, element); var component = $rootScope.ioComponent; expect(component.scope.expr).toEqual('misko'); $rootScope.$apply(); + nextTurn(true); + component.scope.expr = 'angular'; $rootScope.$apply(); + nextTurn(true); + expect($rootScope.name).toEqual('angular'); - })); + }))); it('should expose mapped attributes as camel case', inject(() { var element = $(''); $compile(element)(injector, element); - $rootScope.$apply(); var componentScope = $rootScope.camelCase; expect(componentScope.camelCase).toEqual('6'); })); @@ -315,8 +322,8 @@ main() { var element = $(r'
'); $compile(element)(injector, element); $rootScope.$apply(); - nextTurn(true); + expect(element.textWithShadow()).toEqual('WORKED'); }))); @@ -327,12 +334,14 @@ main() { toBe(PublishTypesAttrDirective._injector.get(PublishTypesDirectiveSuperType)); })); - it('should allow repeaters over controllers', inject((Logger logger) { + it('should allow repeaters over controllers', async(inject((Logger logger) { var element = $(r''); $compile(element)(injector, element); $rootScope.$apply(); + nextTurn(true); + expect(logger.length).toEqual(2); - })); + }))); }); describe('controller scoping', () { @@ -343,12 +352,14 @@ main() { expect(log.result()).toEqual('TabComponent-0; LocalAttrDirective-0; PaneComponent-1; LocalAttrDirective-0; PaneComponent-2; LocalAttrDirective-0'); })); - it('should reuse controllers for transclusions', inject((Compiler $compile, Scope $rootScope, Log log, Injector injector) { + it('should reuse controllers for transclusions', async(inject((Compiler $compile, Scope $rootScope, Log log, Injector injector) { var element = $('
block
'); $compile(element)(injector, element); $rootScope.$apply(); + nextTurn(true); + expect(log.result()).toEqual('IncludeTransclude; SimpleTransclude'); - })); + }))); }); }); diff --git a/test/directives/ng_bind_spec.dart b/test/directives/ng_bind_spec.dart index c09197964..61c2e8d40 100644 --- a/test/directives/ng_bind_spec.dart +++ b/test/directives/ng_bind_spec.dart @@ -16,23 +16,26 @@ main() { })); - it('should bind to non string values', inject((Scope scope) { + it('should bind to non string values', async(inject((Scope scope) { var element = _.compile('
'); scope.$apply(() { scope['value'] = null; }); + nextTurn(true); expect(element.text()).toEqual(''); scope.$apply(() { scope['value'] = true; }); + nextTurn(true); expect(element.text()).toEqual('true'); scope.$apply(() { scope['value'] = 1; }); + nextTurn(true); expect(element.text()).toEqual('1'); - })); + }))); }); } diff --git a/test/directives/ng_class_spec.dart b/test/directives/ng_class_spec.dart index 5b925d643..c52dac668 100644 --- a/test/directives/ng_class_spec.dart +++ b/test/directives/ng_class_spec.dart @@ -17,23 +17,27 @@ main() { })); - it('should add and remove class dynamically', () { + it('should add and remove class dynamically', async(() { compile('
'); rootScope.$apply(() { rootScope['active'] = 'active'; }); + nextTurn(true); + expect(element.hasClass('active')).toBe(true); rootScope.$apply(() { rootScope['active'] = 'inactive'; }); + nextTurn(true); + expect(element.hasClass('active')).toBe(false); expect(element.hasClass('inactive')).toBe(true); - }); + })); - it('should preserve originally defined classes', () { + it('should preserve originally defined classes', async(() { compile('
'); expect(element.hasClass('original')).toBe(true); @@ -41,11 +45,13 @@ main() { rootScope.$apply(() { rootScope['active'] = 'something'; }); + nextTurn(true); + expect(element.hasClass('original')).toBe(true); - }); + })); - it('should preserve classes that has been added after compilation', () { + it('should preserve classes that has been added after compilation', async(() { compile('
'); element[0].classes.add('after-compile'); @@ -54,16 +60,20 @@ main() { rootScope.$apply(() { rootScope['active'] = 'something'; }); + nextTurn(true); + expect(element.hasClass('after-compile')).toBe(true); - }); + })); - it('should allow multiple classes separated by a space', () { + it('should allow multiple classes separated by a space', async(() { compile('
'); rootScope.$apply(() { rootScope['active'] = 'first second'; }); + nextTurn(true); + expect(element).toHaveClass('first'); expect(element).toHaveClass('second'); expect(element).toHaveClass('original'); @@ -71,17 +81,21 @@ main() { rootScope.$apply(() { rootScope['active'] = 'third first'; }); + nextTurn(true); + expect(element).toHaveClass('first'); expect(element).not.toHaveClass('second'); expect(element).toHaveClass('third'); expect(element).toHaveClass('original'); - }); + })); - it('should update value that was set before compilation', () { + it('should update value that was set before compilation', async(() { rootScope['active'] = 'something'; compile('
'); + nextTurn(true); + expect(element).toHaveClass('something'); - }); + })); }); } diff --git a/test/directives/ng_controller_spec.dart b/test/directives/ng_controller_spec.dart index 8d59cd5a0..59f27d1ad 100644 --- a/test/directives/ng_controller_spec.dart +++ b/test/directives/ng_controller_spec.dart @@ -25,29 +25,30 @@ main() { rootScope = scope; compiler(element)(injector, element); scope.$apply(applyFn); + nextTurn(true); }; })); - it('should instantiate controller', () { + it('should instantiate controller', async(() { compile('
Hi {{name}}
'); expect(element.find('.controller').text()).toEqual('Hi Vojta'); - }); + })); - it('should create a new scope', () { + it('should create a new scope', async(() { compile('
Hi {{name}}
', () { rootScope['name'] = 'parent'; }); expect(element.find('.controller').text()).toEqual('Hi Vojta'); expect(element.find('.siblink').text()).toEqual('parent'); - }); + })); - it('should export controller', () { + it('should export controller', async(() { compile('
Hi {{main.name}}
'); expect(element.find('.controller').text()).toEqual('Hi name on controller'); - }); + })); }); } diff --git a/test/directives/ng_disabled_spec.dart b/test/directives/ng_disabled_spec.dart index 3e785fb1c..5d0d398d1 100644 --- a/test/directives/ng_disabled_spec.dart +++ b/test/directives/ng_disabled_spec.dart @@ -8,37 +8,47 @@ describe('ng-disabled', () { beforeEach(beforeEachTestBed((tb) => _ = tb)); - it('should disable/enable the element based on the model', inject((Scope scope) { + it('should disable/enable the element based on the model', async(inject((Scope scope) { var element = _.compile(''); scope.$apply(() { scope['isDisabled'] = true; }); + nextTurn(true); + expect(element[0].disabled).toBe(true); scope.$apply(() { scope['isDisabled'] = false; }); + nextTurn(true); + expect(element[0].disabled).toBe(false); - })); + }))); - it('should accept non boolean values', inject((Scope scope) { + it('should accept non boolean values', async(inject((Scope scope) { var element = _.compile(''); scope.$apply(() { scope['isDisabled'] = null; }); + nextTurn(true); + expect(element[0].disabled).toBe(false); scope.$apply(() { scope['isDisabled'] = 1; }); + nextTurn(true); + expect(element[0].disabled).toBe(true); scope.$apply(() { scope['isDisabled'] = 0; }); + nextTurn(true); + expect(element[0].disabled).toBe(false); - })); + }))); }); diff --git a/test/directives/ng_hide_spec.dart b/test/directives/ng_hide_spec.dart index a8c084f66..09b253ac2 100644 --- a/test/directives/ng_hide_spec.dart +++ b/test/directives/ng_hide_spec.dart @@ -12,11 +12,12 @@ main() { rootScope = scope; compiler(element)(injector, element); scope.$apply(applyFn); + nextTurn(true); }; })); - it('should add/remove ng-hide class', () { + it('should add/remove ng-hide class', async(() { compile('
'); expect(element).not.toHaveClass('ng-hide'); @@ -24,12 +25,16 @@ main() { rootScope.$apply(() { rootScope['isHidden'] = true; }); + nextTurn(true); + expect(element).toHaveClass('ng-hide'); rootScope.$apply(() { rootScope['isHidden'] = false; }); + nextTurn(true); + expect(element).not.toHaveClass('ng-hide'); - }); + })); }); } diff --git a/test/directives/ng_if_spec.dart b/test/directives/ng_if_spec.dart index ed322d219..af0050b9f 100644 --- a/test/directives/ng_if_spec.dart +++ b/test/directives/ng_if_spec.dart @@ -18,10 +18,11 @@ main() { rootScope = scope; compiler(element)(injector, element); scope.$apply(applyFn); + nextTurn(true); }; })); - it('should add/remove the element', () { + it('should add/remove the element', async(() { compile('
content
'); expect(element.find('span').html()).toEqual(''); @@ -29,23 +30,29 @@ main() { rootScope.$apply(() { rootScope['isVisible'] = true; }); + nextTurn(true); + expect(element.find('span').html()).toEqual('content'); rootScope.$apply(() { rootScope['isVisible'] = false; }); + nextTurn(true); + expect(element.find('span').html()).toEqual(''); - }); + })); - it('should not cause ng-click to throw an exception', () { + it('should not cause ng-click to throw an exception', async(() { compile('
content
'); rootScope.$apply(() { rootScope['isVisible'] = false; }); + nextTurn(true); + expect(element.find('span').html()).toEqual(''); - }); + })); - it('should prevent other directives from running when disabled', inject((Log log) { + it('should prevent other directives from running when disabled', async(inject((Log log) { compile('
  • content
    '); expect(element.find('span').html()).toEqual(''); @@ -53,6 +60,8 @@ main() { rootScope.$apply(() { rootScope['isVisible'] = false; }); + nextTurn(true); + expect(element.find('span').html()).toEqual(''); expect(log.result()).toEqual('ALWAYS'); @@ -60,9 +69,10 @@ main() { rootScope.$apply(() { rootScope['isVisible'] = true; }); + nextTurn(true); + expect(element.find('span').html()).toEqual('content'); expect(log.result()).toEqual('ALWAYS; JAMES'); - - })); + }))); }); } diff --git a/test/directives/ng_include_spec.dart b/test/directives/ng_include_spec.dart index ae46c9aca..a26a4b66c 100644 --- a/test/directives/ng_include_spec.dart +++ b/test/directives/ng_include_spec.dart @@ -19,20 +19,22 @@ main() { scope['name'] = 'Vojta'; scope['template'] = 'tpl.html'; }); + nextTurn(true); - nextTurn(); // load the template from cache. expect(element.text()).toEqual('my name is Vojta'); }))); - it('should support inlined templates', inject((Scope scope) { + it('should support inlined templates', async(inject((Scope scope) { var element = _.compile('
    '); scope.$apply(() { scope['name'] = 'Vojta'; scope['template'] = 'my inlined name is {{name}}'; }); + nextTurn(true); + expect(element.text()).toEqual('my inlined name is Vojta'); - })); + }))); }); } diff --git a/test/directives/ng_model_spec.dart b/test/directives/ng_model_spec.dart index d64fa28bf..88e84093d 100644 --- a/test/directives/ng_model_spec.dart +++ b/test/directives/ng_model_spec.dart @@ -8,15 +8,17 @@ describe('ng-model', () { beforeEach(beforeEachTestBed((tb) => _ = tb)); describe('type="text"', () { - it('should update input value from model', inject(() { + it('should update input value from model', async(inject(() { _.compile(''); _.rootScope.$digest(); expect(_.rootElement.prop('value')).toEqual(''); _.rootScope.$apply('model = "misko"'); + nextTurn(true); + expect(_.rootElement.prop('value')).toEqual('misko'); - })); + }))); it('should update model from the input value', inject(() { _.compile(''); @@ -32,39 +34,49 @@ describe('ng-model', () { describe('type="checkbox"', () { - it('should update input value from model', inject((Scope scope) { + it('should update input value from model', async(inject((Scope scope) { var element = _.compile(''); scope.$apply(() { scope['model'] = true; }); + nextTurn(true); + expect(element[0].checked).toBe(true); scope.$apply(() { scope['model'] = false; }); + nextTurn(true); + expect(element[0].checked).toBe(false); - })); + }))); - it('should allow non boolean values like null, 0, 1', inject((Scope scope) { + it('should allow non boolean values like null, 0, 1', async(inject((Scope scope) { var element = _.compile(''); scope.$apply(() { scope['model'] = 0; }); + nextTurn(true); + expect(element[0].checked).toBe(false); scope.$apply(() { scope['model'] = 1; }); + nextTurn(true); + expect(element[0].checked).toBe(true); scope.$apply(() { scope['model'] = null; }); + nextTurn(true); + expect(element[0].checked).toBe(false); - })); + }))); it('should update model from the input value', inject((Scope scope) { diff --git a/test/directives/ng_repeat_spec.dart b/test/directives/ng_repeat_spec.dart index a52302a09..483bee19a 100644 --- a/test/directives/ng_repeat_spec.dart +++ b/test/directives/ng_repeat_spec.dart @@ -20,7 +20,7 @@ main() { BlockFactory blockFactory = compiler(element); Block block = blockFactory(injector, element); scope.items = ['a', 'b']; - scope.$apply(); + scope.$digest(); expect(element.text()).toEqual('ab'); })); diff --git a/test/directives/ng_show_spec.dart b/test/directives/ng_show_spec.dart index 45c638e81..26526aee2 100644 --- a/test/directives/ng_show_spec.dart +++ b/test/directives/ng_show_spec.dart @@ -12,11 +12,12 @@ main() { rootScope = scope; compiler(element)(injector, element); scope.$apply(applyFn); + nextTurn(true); }; })); - it('should add/remove ng-show class', () { + it('should add/remove ng-show class', async(() { compile('
    '); expect(element).not.toHaveClass('ng-show'); @@ -24,15 +25,19 @@ main() { rootScope.$apply(() { rootScope['isVisible'] = true; }); + nextTurn(true); + expect(element).toHaveClass('ng-show'); rootScope.$apply(() { rootScope['isVisible'] = false; }); + nextTurn(true); + expect(element).not.toHaveClass('ng-show'); - }); + })); - it('should work together with ng-class', () { + it('should work together with ng-class', async(() { compile('
    '); expect(element).not.toHaveClass('active'); @@ -41,14 +46,18 @@ main() { rootScope.$apply(() { rootScope['currentCls'] = 'active'; }); + nextTurn(true); + expect(element).toHaveClass('active'); expect(element).not.toHaveClass('ng-show'); rootScope.$apply(() { rootScope['isVisible'] = true; }); + nextTurn(true); + expect(element).toHaveClass('active'); expect(element).toHaveClass('ng-show'); - }); + })); }); } diff --git a/test/scope_spec.dart b/test/scope_spec.dart index 8f965a960..d4e32e44e 100644 --- a/test/scope_spec.dart +++ b/test/scope_spec.dart @@ -436,16 +436,18 @@ main() { describe(r'$apply', () { - it(r'should apply expression with full lifecycle', inject((Scope $rootScope) { + it(r'should apply expression with full lifecycle', async(inject((Scope $rootScope) { var log = ''; var child = $rootScope.$new(); $rootScope.$watch('a', (a, _, __) { log += '1'; }); child.$apply(r'$parent.a=0'); + nextTurn(true); + expect(log).toEqual('1'); - })); + }))); - it(r'should catch exceptions', () { + it(r'should catch exceptions', async(() { module((Module module) => module.type(ExceptionHandler, LogExceptionHandler)); inject((Scope $rootScope, ExceptionHandler $exceptionHandler) { var log = []; @@ -453,12 +455,14 @@ main() { $rootScope.$watch('a', (a, _, __) => log.add('1')); $rootScope.a = 0; child.$apply((_, __) { throw 'MyError'; }); + nextTurn(true); + expect(log.join(',')).toEqual('1'); expect($exceptionHandler.errors[0].error).toEqual('MyError'); $exceptionHandler.errors.removeAt(0); $exceptionHandler.assertEmpty(); }); - }); + })); it(r'should log exceptions from $digest', () { @@ -471,9 +475,10 @@ main() { $rootScope.$watch('b', (_, __, ___) {$rootScope.a++;}); $rootScope.a = $rootScope.b = 0; - expect(() { + expect(async(() { $rootScope.$apply(); - }).toThrow('2 \$digest() iterations reached. Aborting!'); + nextTurn(true); + })).toThrow('2 \$digest() iterations reached. Aborting!'); expect($exceptionHandler.errors[0].error).toContain('2 \$digest() iterations reached.'); $exceptionHandler.errors.removeAt(0); @@ -482,6 +487,17 @@ main() { }); }); + it(r'should run $digest a minimum number of times', async(inject((Scope scope) { + var a = 0; + // This watch value getter is executed twice during a digest. + scope.$watch(() => a++ == -4); + + scope.$apply(); + scope.$apply(); + nextTurn(true); + + expect(a).toEqual(2); + }))); describe(r'exceptions', () { var log; @@ -496,21 +512,25 @@ main() { })); - it(r'should execute and return value and update', inject( + it(r'should execute and return value and update', async(inject( (Scope $rootScope, ExceptionHandler $exceptionHandler) { $rootScope.name = 'abc'; expect($rootScope.$apply((scope) => scope.name)).toEqual('abc'); + nextTurn(true); + expect(log).toEqual(r'$digest;'); $exceptionHandler.assertEmpty(); - })); + }))); - it(r'should catch exception and update', inject((Scope $rootScope, ExceptionHandler $exceptionHandler) { + it(r'should catch exception and update', async(inject((Scope $rootScope, ExceptionHandler $exceptionHandler) { var error = 'MyError'; $rootScope.$apply(() { throw error; }); + nextTurn(true); + expect(log).toEqual(r'$digest;'); expect($exceptionHandler.errors[0].error).toEqual(error); - })); + }))); }); @@ -527,13 +547,14 @@ main() { it(r'should throw an exception if $apply is called while flushing evalAsync queue', inject( (Scope $rootScope) { - expect(() { + expect(async(() { $rootScope.$apply(() { $rootScope.$evalAsync(() { $rootScope.$apply(); }); }); - }).toThrow(r'$digest already in progress'); + nextTurn(true); + })).toThrow(r'$digest already in progress'); })); @@ -543,24 +564,39 @@ main() { childScope1.$watch('x', () { childScope1.$apply(); }); - expect(() { childScope1.$apply(); }).toThrow(r'$digest already in progress'); + expect(async(() { + childScope1.$apply(); + nextTurn(true); + })).toThrow(r'$digest already in progress'); })); it(r'should thrown an exception if $apply in called from a watch fn (after init)', inject( (Scope $rootScope) { + // async calls in this test. Since async() traps exceptions, we need + // to embed an async *inside* the expect().toThrow() call instead + // of wrapping the entire function in an async(). + // + // However, the first $apply() need to resolve before the second + // $apply. Thus, we have two async() calls. var childScope2 = $rootScope.$new(); - childScope2.$apply(() { - childScope2.$watch('x', (newVal, oldVal) { - if (newVal != oldVal) { - childScope2.$apply(); - } + async(() { + childScope2.$apply(() { + childScope2.$watch('x', (newVal, oldVal) { + if (newVal != oldVal) { + childScope2.$apply(); + } + }); }); - }); + nextTurn(true); + })(); - expect(() { childScope2.$apply(() { - childScope2.x = 'something'; - }); }).toThrow(r'$digest already in progress'); + expect(async(() { + childScope2.$apply(() { + childScope2.x = 'something'; + }); + nextTurn(true); + })).toThrow(r'$digest already in progress'); })); }); });