diff --git a/packages/core/test/acceptance/change_detection_spec.ts b/packages/core/test/acceptance/change_detection_spec.ts index 340a224321..596e67e2b7 100644 --- a/packages/core/test/acceptance/change_detection_spec.ts +++ b/packages/core/test/acceptance/change_detection_spec.ts @@ -7,7 +7,8 @@ */ -import {ApplicationRef, ChangeDetectionStrategy, Component, ComponentFactoryResolver, ComponentRef, Directive, EmbeddedViewRef, NgModule, TemplateRef, Type, ViewChild, ViewContainerRef} from '@angular/core'; +import {CommonModule} from '@angular/common'; +import {ApplicationRef, ChangeDetectionStrategy, ChangeDetectorRef, Component, ComponentFactoryResolver, ComponentRef, Directive, DoCheck, EmbeddedViewRef, ErrorHandler, Input, NgModule, OnInit, TemplateRef, Type, ViewChild, ViewContainerRef} from '@angular/core'; import {TestBed} from '@angular/core/testing'; import {expect} from '@angular/platform-browser/testing/src/matchers'; @@ -127,4 +128,853 @@ describe('change detection', () => { }); }); -}); \ No newline at end of file + describe('OnPush', () => { + @Component({ + selector: 'my-comp', + changeDetection: ChangeDetectionStrategy.OnPush, + template: `{{ doCheckCount }} - {{ name }} ` + }) + class MyComponent implements DoCheck { + @Input() + name = 'Nancy'; + doCheckCount = 0; + + ngDoCheck(): void { this.doCheckCount++; } + + onClick() {} + } + + @Component({selector: 'my-app', template: ''}) + class MyApp { + @ViewChild(MyComponent) comp !: MyComponent; + name: string = 'Nancy'; + } + + it('should check OnPush components on initialization', () => { + TestBed.configureTestingModule({declarations: [MyComponent, MyApp]}); + const fixture = TestBed.createComponent(MyApp); + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent.trim()).toEqual('1 - Nancy'); + }); + + it('should call doCheck even when OnPush components are not dirty', () => { + TestBed.configureTestingModule({declarations: [MyComponent, MyApp]}); + const fixture = TestBed.createComponent(MyApp); + fixture.detectChanges(); + + fixture.detectChanges(); + expect(fixture.componentInstance.comp.doCheckCount).toEqual(2); + + fixture.detectChanges(); + expect(fixture.componentInstance.comp.doCheckCount).toEqual(3); + }); + + it('should skip OnPush components in update mode when they are not dirty', () => { + TestBed.configureTestingModule({declarations: [MyComponent, MyApp]}); + const fixture = TestBed.createComponent(MyApp); + fixture.detectChanges(); + + // doCheckCount is 2, but 1 should be rendered since it has not been marked dirty. + expect(fixture.nativeElement.textContent.trim()).toEqual('1 - Nancy'); + + fixture.detectChanges(); + + // doCheckCount is 3, but 1 should be rendered since it has not been marked dirty. + expect(fixture.nativeElement.textContent.trim()).toEqual('1 - Nancy'); + }); + + it('should check OnPush components in update mode when inputs change', () => { + TestBed.configureTestingModule({declarations: [MyComponent, MyApp]}); + const fixture = TestBed.createComponent(MyApp); + fixture.detectChanges(); + + fixture.componentInstance.name = 'Bess'; + fixture.detectChanges(); + + expect(fixture.componentInstance.comp.doCheckCount).toEqual(2); + // View should update, as changed input marks view dirty + expect(fixture.nativeElement.textContent.trim()).toEqual('2 - Bess'); + + fixture.componentInstance.name = 'George'; + fixture.detectChanges(); + + // View should update, as changed input marks view dirty + expect(fixture.componentInstance.comp.doCheckCount).toEqual(3); + expect(fixture.nativeElement.textContent.trim()).toEqual('3 - George'); + + fixture.detectChanges(); + + expect(fixture.componentInstance.comp.doCheckCount).toEqual(4); + // View should not be updated to "4", as inputs have not changed. + expect(fixture.nativeElement.textContent.trim()).toEqual('3 - George'); + }); + + it('should check OnPush components in update mode when component events occur', () => { + TestBed.configureTestingModule({declarations: [MyComponent, MyApp]}); + const fixture = TestBed.createComponent(MyApp); + fixture.detectChanges(); + + expect(fixture.componentInstance.comp.doCheckCount).toEqual(1); + expect(fixture.nativeElement.textContent.trim()).toEqual('1 - Nancy'); + + const button = fixture.nativeElement.querySelector('button') !; + button.click(); + + // No ticks should have been scheduled. + expect(fixture.componentInstance.comp.doCheckCount).toEqual(1); + expect(fixture.nativeElement.textContent.trim()).toEqual('1 - Nancy'); + + fixture.detectChanges(); + + // Because the onPush comp should be dirty, it should update once CD runs + expect(fixture.componentInstance.comp.doCheckCount).toEqual(2); + expect(fixture.nativeElement.textContent.trim()).toEqual('2 - Nancy'); + }); + + it('should not check OnPush components in update mode when parent events occur', () => { + @Component({ + selector: 'button-parent', + template: '' + }) + class ButtonParent { + @ViewChild(MyComponent) comp !: MyComponent; + noop() {} + } + + TestBed.configureTestingModule({declarations: [MyComponent, ButtonParent]}); + const fixture = TestBed.createComponent(ButtonParent); + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent.trim()).toEqual('1 - Nancy'); + + const button: HTMLButtonElement = fixture.nativeElement.querySelector('button#parent'); + button.click(); + fixture.detectChanges(); + + // The comp should still be clean. So doCheck will run, but the view should display 1. + expect(fixture.componentInstance.comp.doCheckCount).toEqual(2); + expect(fixture.nativeElement.textContent.trim()).toEqual('1 - Nancy'); + }); + + it('should check parent OnPush components in update mode when child events occur', () => { + @Component({ + selector: 'button-parent', + template: '{{ doCheckCount }} - ', + changeDetection: ChangeDetectionStrategy.OnPush + }) + class ButtonParent implements DoCheck { + @ViewChild(MyComponent) comp !: MyComponent; + noop() {} + + doCheckCount = 0; + ngDoCheck(): void { this.doCheckCount++; } + } + + @Component({selector: 'my-button-app', template: ''}) + class MyButtonApp { + @ViewChild(ButtonParent) parent !: ButtonParent; + } + + TestBed.configureTestingModule({declarations: [MyButtonApp, MyComponent, ButtonParent]}); + const fixture = TestBed.createComponent(MyButtonApp); + fixture.detectChanges(); + + const parent = fixture.componentInstance.parent; + const comp = parent.comp; + + expect(parent.doCheckCount).toEqual(1); + expect(comp.doCheckCount).toEqual(1); + expect(fixture.nativeElement.textContent.trim()).toEqual('1 - 1 - Nancy'); + + fixture.detectChanges(); + expect(parent.doCheckCount).toEqual(2); + // parent isn't checked, so child doCheck won't run + expect(comp.doCheckCount).toEqual(1); + expect(fixture.nativeElement.textContent.trim()).toEqual('1 - 1 - Nancy'); + + const button = fixture.nativeElement.querySelector('button'); + button.click(); + + // No ticks should have been scheduled. + expect(parent.doCheckCount).toEqual(2); + expect(comp.doCheckCount).toEqual(1); + + fixture.detectChanges(); + expect(parent.doCheckCount).toEqual(3); + expect(comp.doCheckCount).toEqual(2); + expect(fixture.nativeElement.textContent.trim()).toEqual('3 - 2 - Nancy'); + }); + + }); + + describe('ChangeDetectorRef', () => { + describe('detectChanges()', () => { + @Component({ + selector: 'my-comp', + template: '{{ name }}', + changeDetection: ChangeDetectionStrategy.OnPush + }) + class MyComp implements DoCheck { + doCheckCount = 0; + name = 'Nancy'; + + constructor(public cdr: ChangeDetectorRef) {} + + ngDoCheck() { this.doCheckCount++; } + } + + @Component({selector: 'parent-comp', template: `{{ doCheckCount}} - `}) + class ParentComp implements DoCheck { + @ViewChild(MyComp) myComp !: MyComp; + + doCheckCount = 0; + + constructor(public cdr: ChangeDetectorRef) {} + + ngDoCheck() { this.doCheckCount++; } + } + + @Directive({selector: '[dir]'}) + class Dir { + constructor(public cdr: ChangeDetectorRef) {} + } + + it('should check the component view when called by component (even when OnPush && clean)', + () => { + TestBed.configureTestingModule({declarations: [MyComp]}); + const fixture = TestBed.createComponent(MyComp); + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent).toEqual('Nancy'); + + fixture.componentInstance.name = + 'Bess'; // as this is not an Input, the component stays clean + fixture.componentInstance.cdr.detectChanges(); + expect(fixture.nativeElement.textContent).toEqual('Bess'); + }); + + it('should NOT call component doCheck when called by a component', () => { + TestBed.configureTestingModule({declarations: [MyComp]}); + const fixture = TestBed.createComponent(MyComp); + fixture.detectChanges(); + + expect(fixture.componentInstance.doCheckCount).toEqual(1); + + // NOTE: in current Angular, detectChanges does not itself trigger doCheck, but you + // may see doCheck called in some cases bc of the extra CD run triggered by zone.js. + // It's important not to call doCheck to allow calls to detectChanges in that hook. + fixture.componentInstance.cdr.detectChanges(); + expect(fixture.componentInstance.doCheckCount).toEqual(1); + }); + + it('should NOT check the component parent when called by a child component', () => { + TestBed.configureTestingModule({declarations: [MyComp, ParentComp]}); + const fixture = TestBed.createComponent(ParentComp); + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent).toEqual('1 - Nancy'); + + fixture.componentInstance.doCheckCount = 100; + fixture.componentInstance.myComp.cdr.detectChanges(); + expect(fixture.componentInstance.doCheckCount).toEqual(100); + expect(fixture.nativeElement.textContent).toEqual('1 - Nancy'); + }); + + it('should check component children when called by component if dirty or check-always', + () => { + TestBed.configureTestingModule({declarations: [MyComp, ParentComp]}); + const fixture = TestBed.createComponent(ParentComp); + fixture.detectChanges(); + expect(fixture.componentInstance.doCheckCount).toEqual(1); + + fixture.componentInstance.myComp.name = 'Bess'; + fixture.componentInstance.cdr.detectChanges(); + expect(fixture.componentInstance.doCheckCount).toEqual(1); + expect(fixture.componentInstance.myComp.doCheckCount).toEqual(2); + // OnPush child is not dirty, so its change isn't rendered. + expect(fixture.nativeElement.textContent).toEqual('1 - Nancy'); + }); + + it('should not group detectChanges calls (call every time)', () => { + TestBed.configureTestingModule({declarations: [MyComp, ParentComp]}); + const fixture = TestBed.createComponent(ParentComp); + fixture.detectChanges(); + + expect(fixture.componentInstance.doCheckCount).toEqual(1); + + fixture.componentInstance.cdr.detectChanges(); + fixture.componentInstance.cdr.detectChanges(); + expect(fixture.componentInstance.myComp.doCheckCount).toEqual(3); + }); + + it('should check component view when called by directive on component node', () => { + @Component({template: ''}) + class MyApp { + @ViewChild(MyComp) myComp !: MyComp; + @ViewChild(Dir) dir !: Dir; + } + + TestBed.configureTestingModule({declarations: [MyComp, Dir, MyApp]}); + const fixture = TestBed.createComponent(MyApp); + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent).toEqual('Nancy'); + + fixture.componentInstance.myComp.name = 'George'; + fixture.componentInstance.dir.cdr.detectChanges(); + expect(fixture.nativeElement.textContent).toEqual('George'); + }); + + it('should check host component when called by directive on element node', () => { + @Component({template: '{{ value }}
'}) + class MyApp { + @ViewChild(MyComp) myComp !: MyComp; + @ViewChild(Dir) dir !: Dir; + value = ''; + } + + TestBed.configureTestingModule({declarations: [Dir, MyApp]}); + const fixture = TestBed.createComponent(MyApp); + fixture.detectChanges(); + + fixture.componentInstance.value = 'Frank'; + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toEqual('Frank'); + + fixture.componentInstance.value = 'Joe'; + fixture.componentInstance.dir.cdr.detectChanges(); + expect(fixture.nativeElement.textContent).toEqual('Joe'); + }); + + it('should check the host component when called from EmbeddedViewRef', () => { + @Component({template: '{{ name }}
'}) + class MyApp { + @ViewChild(Dir) dir !: Dir; + showing = true; + name = 'Amelia'; + } + + TestBed.configureTestingModule({declarations: [Dir, MyApp], imports: [CommonModule]}); + const fixture = TestBed.createComponent(MyApp); + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent).toEqual('Amelia'); + + fixture.componentInstance.name = 'Emerson'; + fixture.componentInstance.dir.cdr.detectChanges(); + expect(fixture.nativeElement.textContent).toEqual('Emerson'); + }); + + it('should support call in ngOnInit', () => { + @Component({template: '{{ value }}'}) + class DetectChangesComp implements OnInit { + value = 0; + + constructor(public cdr: ChangeDetectorRef) {} + + ngOnInit() { + this.value++; + this.cdr.detectChanges(); + } + } + + TestBed.configureTestingModule({declarations: [DetectChangesComp]}); + const fixture = TestBed.createComponent(DetectChangesComp); + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent).toEqual('1'); + }); + + ['OnInit', 'AfterContentInit', 'AfterViewInit', 'OnChanges'].forEach(hook => { + it(`should not go infinite loop when recursively called from children's ng${hook}`, () => { + @Component({template: ''}) + class ParentComp { + constructor(public cdr: ChangeDetectorRef) {} + triggerChangeDetection() { this.cdr.detectChanges(); } + } + + @Component({template: '{{inp}}', selector: 'child-comp'}) + class ChildComp { + @Input() + inp: any = ''; + + count = 0; + constructor(public parentComp: ParentComp) {} + + ngOnInit() { this.check('OnInit'); } + ngAfterContentInit() { this.check('AfterContentInit'); } + ngAfterViewInit() { this.check('AfterViewInit'); } + ngOnChanges() { this.check('OnChanges'); } + + check(h: string) { + if (h === hook) { + this.count++; + if (this.count > 1) throw new Error(`ng${hook} should be called only once!`); + this.parentComp.triggerChangeDetection(); + } + } + } + + TestBed.configureTestingModule({declarations: [ParentComp, ChildComp]}); + + expect(() => { + const fixture = TestBed.createComponent(ParentComp); + fixture.detectChanges(); + }).not.toThrow(); + }); + }); + + it('should support call in ngDoCheck', () => { + @Component({template: '{{doCheckCount}}'}) + class DetectChangesComp { + doCheckCount = 0; + + constructor(public cdr: ChangeDetectorRef) {} + + ngDoCheck() { + this.doCheckCount++; + this.cdr.detectChanges(); + } + } + + TestBed.configureTestingModule({declarations: [DetectChangesComp]}); + const fixture = TestBed.createComponent(DetectChangesComp); + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent).toEqual('1'); + }); + + describe('dynamic views', () => { + @Component({selector: 'structural-comp', template: '{{ value }}'}) + class StructuralComp { + @Input() + tmp !: TemplateRef; + value = 'one'; + + constructor(public vcr: ViewContainerRef) {} + + create() { return this.vcr.createEmbeddedView(this.tmp, {ctx: this}); } + } + + it('should support ViewRef.detectChanges()', () => { + @Component({ + template: + '{{ ctx.value }}' + }) + class App { + @ViewChild(StructuralComp) structuralComp !: StructuralComp; + } + + TestBed.configureTestingModule({declarations: [App, StructuralComp]}); + const fixture = TestBed.createComponent(App); + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent).toEqual('one'); + + const viewRef: EmbeddedViewRef = fixture.componentInstance.structuralComp.create(); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toEqual('oneone'); + + // check embedded view update + fixture.componentInstance.structuralComp.value = 'two'; + viewRef.detectChanges(); + expect(fixture.nativeElement.textContent).toEqual('onetwo'); + + // check root view update + fixture.componentInstance.structuralComp.value = 'three'; + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toEqual('threethree'); + }); + + it('should support ViewRef.detectChanges() directly after creation', () => { + @Component({ + template: 'Template text' + }) + class App { + @ViewChild(StructuralComp) structuralComp !: StructuralComp; + } + + TestBed.configureTestingModule({declarations: [App, StructuralComp]}); + const fixture = TestBed.createComponent(App); + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent).toEqual('one'); + + const viewRef: EmbeddedViewRef = fixture.componentInstance.structuralComp.create(); + viewRef.detectChanges(); + expect(fixture.nativeElement.textContent).toEqual('oneTemplate text'); + }); + + }); + + }); + + describe('attach/detach', () => { + @Component({selector: 'detached-comp', template: '{{ value }}'}) + class DetachedComp implements DoCheck { + value = 'one'; + doCheckCount = 0; + + constructor(public cdr: ChangeDetectorRef) {} + + ngDoCheck() { this.doCheckCount++; } + } + + @Component({template: ''}) + class MyApp { + @ViewChild(DetachedComp) comp !: DetachedComp; + + constructor(public cdr: ChangeDetectorRef) {} + } + + it('should not check detached components', () => { + TestBed.configureTestingModule({declarations: [MyApp, DetachedComp]}); + const fixture = TestBed.createComponent(MyApp); + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent).toEqual('one'); + + fixture.componentInstance.comp.cdr.detach(); + + fixture.componentInstance.comp.value = 'two'; + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toEqual('one'); + }); + + it('should check re-attached components', () => { + TestBed.configureTestingModule({declarations: [MyApp, DetachedComp]}); + const fixture = TestBed.createComponent(MyApp); + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent).toEqual('one'); + + fixture.componentInstance.comp.cdr.detach(); + fixture.componentInstance.comp.value = 'two'; + + fixture.componentInstance.comp.cdr.reattach(); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toEqual('two'); + }); + + it('should call lifecycle hooks on detached components', () => { + TestBed.configureTestingModule({declarations: [MyApp, DetachedComp]}); + const fixture = TestBed.createComponent(MyApp); + fixture.detectChanges(); + + expect(fixture.componentInstance.comp.doCheckCount).toEqual(1); + + fixture.componentInstance.comp.cdr.detach(); + + fixture.detectChanges(); + expect(fixture.componentInstance.comp.doCheckCount).toEqual(2); + }); + + it('should check detached component when detectChanges is called', () => { + TestBed.configureTestingModule({declarations: [MyApp, DetachedComp]}); + const fixture = TestBed.createComponent(MyApp); + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent).toEqual('one'); + + fixture.componentInstance.comp.cdr.detach(); + + fixture.componentInstance.comp.value = 'two'; + fixture.componentInstance.comp.cdr.detectChanges(); + + expect(fixture.nativeElement.textContent).toEqual('two'); + }); + + it('should not check detached component when markDirty is called', () => { + TestBed.configureTestingModule({declarations: [MyApp, DetachedComp]}); + const fixture = TestBed.createComponent(MyApp); + fixture.detectChanges(); + const comp = fixture.componentInstance.comp; + + comp.cdr.detach(); + comp.value = 'two'; + comp.cdr.markForCheck(); + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent).toEqual('one'); + }); + + it('should detach any child components when parent is detached', () => { + TestBed.configureTestingModule({declarations: [MyApp, DetachedComp]}); + const fixture = TestBed.createComponent(MyApp); + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent).toEqual('one'); + + fixture.componentInstance.cdr.detach(); + + fixture.componentInstance.comp.value = 'two'; + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toEqual('one'); + + fixture.componentInstance.cdr.reattach(); + + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toEqual('two'); + }); + + it('should detach OnPush components properly', () => { + + @Component({ + selector: 'on-push-comp', + template: '{{ value }}', + changeDetection: ChangeDetectionStrategy.OnPush + }) + class OnPushComp { + @Input() + value !: string; + + constructor(public cdr: ChangeDetectorRef) {} + } + + @Component({template: ''}) + class OnPushApp { + @ViewChild(OnPushComp) onPushComp !: OnPushComp; + value = ''; + } + + TestBed.configureTestingModule({declarations: [OnPushApp, OnPushComp]}); + const fixture = TestBed.createComponent(OnPushApp); + fixture.detectChanges(); + + fixture.componentInstance.value = 'one'; + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toEqual('one'); + + fixture.componentInstance.onPushComp.cdr.detach(); + + fixture.componentInstance.value = 'two'; + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toEqual('one'); + + fixture.componentInstance.onPushComp.cdr.reattach(); + + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toEqual('two'); + }); + + }); + + describe('markForCheck()', () => { + @Component({ + selector: 'on-push-comp', + template: '{{ value }}', + changeDetection: ChangeDetectionStrategy.OnPush + }) + class OnPushComp implements DoCheck { + value = 'one'; + + doCheckCount = 0; + + constructor(public cdr: ChangeDetectorRef) {} + + ngDoCheck() { this.doCheckCount++; } + } + + @Component({ + template: '{{ value }} - ', + changeDetection: ChangeDetectionStrategy.OnPush + }) + class OnPushParent { + @ViewChild(OnPushComp) comp !: OnPushComp; + value = 'one'; + } + + it('should ensure OnPush components are checked', () => { + TestBed.configureTestingModule({declarations: [OnPushParent, OnPushComp]}); + const fixture = TestBed.createComponent(OnPushParent); + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent).toEqual('one - one'); + + fixture.componentInstance.comp.value = 'two'; + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toEqual('one - one'); + + fixture.componentInstance.comp.cdr.markForCheck(); + + // Change detection should not have run yet, since markForCheck + // does not itself schedule change detection. + expect(fixture.nativeElement.textContent).toEqual('one - one'); + + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toEqual('one - two'); + }); + + it('should never schedule change detection on its own', () => { + TestBed.configureTestingModule({declarations: [OnPushParent, OnPushComp]}); + const fixture = TestBed.createComponent(OnPushParent); + fixture.detectChanges(); + const comp = fixture.componentInstance.comp; + + expect(comp.doCheckCount).toEqual(1); + + comp.cdr.markForCheck(); + comp.cdr.markForCheck(); + + expect(comp.doCheckCount).toEqual(1); + }); + + it('should ensure ancestor OnPush components are checked', () => { + TestBed.configureTestingModule({declarations: [OnPushParent, OnPushComp]}); + const fixture = TestBed.createComponent(OnPushParent); + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent).toEqual('one - one'); + + fixture.componentInstance.value = 'two'; + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toEqual('one - one'); + + fixture.componentInstance.comp.cdr.markForCheck(); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toEqual('two - one'); + + }); + + it('should ensure OnPush components in embedded views are checked', () => { + @Component({ + template: '{{ value }} - ', + changeDetection: ChangeDetectionStrategy.OnPush + }) + class EmbeddedViewParent { + @ViewChild(OnPushComp) comp !: OnPushComp; + value = 'one'; + showing = true; + } + + TestBed.configureTestingModule( + {declarations: [EmbeddedViewParent, OnPushComp], imports: [CommonModule]}); + const fixture = TestBed.createComponent(EmbeddedViewParent); + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent).toEqual('one - one'); + + fixture.componentInstance.comp.value = 'two'; + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toEqual('one - one'); + + fixture.componentInstance.comp.cdr.markForCheck(); + // markForCheck should not trigger change detection on its own. + expect(fixture.nativeElement.textContent).toEqual('one - one'); + + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toEqual('one - two'); + + fixture.componentInstance.value = 'two'; + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toEqual('one - two'); + + fixture.componentInstance.comp.cdr.markForCheck(); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toEqual('two - two'); + }); + + // TODO(kara): add test for dynamic views once bug fix is in + }); + + describe('checkNoChanges', () => { + let comp: NoChangesComp; + + @Component({selector: 'no-changes-comp', template: '{{ value }}'}) + class NoChangesComp { + value = 1; + doCheckCount = 0; + contentCheckCount = 0; + viewCheckCount = 0; + + ngDoCheck() { this.doCheckCount++; } + + ngAfterContentChecked() { this.contentCheckCount++; } + + ngAfterViewChecked() { this.viewCheckCount++; } + + constructor(public cdr: ChangeDetectorRef) { comp = this; } + } + + @Component({template: '{{ value }} - '}) + class AppComp { + value = 1; + + constructor(public cdr: ChangeDetectorRef) {} + } + + // Custom error handler that just rethrows all the errors from the + // view, rather than logging them out. Used to keep our logs clean. + class RethrowErrorHandler extends ErrorHandler { + handleError(error: any) { throw error; } + } + + it('should throw if bindings in current view have changed', () => { + TestBed.configureTestingModule({ + declarations: [NoChangesComp], + providers: [{provide: ErrorHandler, useClass: RethrowErrorHandler}] + }); + const fixture = TestBed.createComponent(NoChangesComp); + + expect(() => { fixture.componentInstance.cdr.checkNoChanges(); }) + .toThrowError( + /ExpressionChangedAfterItHasBeenCheckedError: .+ Previous value: '.*undefined'. Current value: '.*1'/gi); + }); + + it('should throw if interpolations in current view have changed', () => { + TestBed.configureTestingModule({ + declarations: [AppComp, NoChangesComp], + providers: [{provide: ErrorHandler, useClass: RethrowErrorHandler}] + }); + const fixture = TestBed.createComponent(AppComp); + + expect(() => fixture.componentInstance.cdr.checkNoChanges()) + .toThrowError( + /ExpressionChangedAfterItHasBeenCheckedError: .+ Previous value: '.*undefined'. Current value: '.*1'/gi); + }); + + it('should throw if bindings in embedded view have changed', () => { + @Component({template: '{{ showing }}'}) + class EmbeddedViewApp { + showing = true; + constructor(public cdr: ChangeDetectorRef) {} + } + + TestBed.configureTestingModule({ + declarations: [EmbeddedViewApp], + imports: [CommonModule], + providers: [{provide: ErrorHandler, useClass: RethrowErrorHandler}] + }); + const fixture = TestBed.createComponent(EmbeddedViewApp); + + expect(() => fixture.componentInstance.cdr.checkNoChanges()) + .toThrowError( + /ExpressionChangedAfterItHasBeenCheckedError: .+ Previous value: '.*undefined'. Current value: '.*true'/gi); + }); + + it('should NOT call lifecycle hooks', () => { + TestBed.configureTestingModule({ + declarations: [AppComp, NoChangesComp], + providers: [{provide: ErrorHandler, useClass: RethrowErrorHandler}] + }); + + const fixture = TestBed.createComponent(AppComp); + fixture.detectChanges(); + + expect(comp.doCheckCount).toEqual(1); + expect(comp.contentCheckCount).toEqual(1); + expect(comp.viewCheckCount).toEqual(1); + + comp.value = 2; + expect(() => fixture.componentInstance.cdr.checkNoChanges()).toThrow(); + expect(comp.doCheckCount).toEqual(1); + expect(comp.contentCheckCount).toEqual(1); + expect(comp.viewCheckCount).toEqual(1); + }); + + }); + + }); + +}); diff --git a/packages/core/test/render3/change_detection_spec.ts b/packages/core/test/render3/change_detection_spec.ts index c5e38d76d5..52629da45b 100644 --- a/packages/core/test/render3/change_detection_spec.ts +++ b/packages/core/test/render3/change_detection_spec.ts @@ -6,18 +6,17 @@ * found in the LICENSE file at https://angular.io/license */ -import {EmbeddedViewRef, TemplateRef, ViewContainerRef} from '@angular/core'; import {withBody} from '@angular/private/testing'; -import {ChangeDetectionStrategy, ChangeDetectorRef, DoCheck, RendererType2} from '../../src/core'; +import {ChangeDetectionStrategy, DoCheck} from '../../src/core'; import {whenRendered} from '../../src/render3/component'; -import {LifecycleHooksFeature, getRenderedText, ɵɵNgOnChangesFeature, ɵɵdefineComponent, ɵɵdefineDirective, ɵɵgetCurrentView, ɵɵtemplateRefExtractor} from '../../src/render3/index'; -import {detectChanges, markDirty, tick, ɵɵbind, ɵɵcontainer, ɵɵcontainerRefreshEnd, ɵɵcontainerRefreshStart, ɵɵdirectiveInject, ɵɵelement, ɵɵelementEnd, ɵɵelementProperty, ɵɵelementStart, ɵɵembeddedViewEnd, ɵɵembeddedViewStart, ɵɵinterpolation1, ɵɵinterpolation2, ɵɵlistener, ɵɵreference, ɵɵtemplate, ɵɵtext, ɵɵtextBinding} from '../../src/render3/instructions/all'; +import {LifecycleHooksFeature, getRenderedText, ɵɵdefineComponent, ɵɵgetCurrentView} from '../../src/render3/index'; +import {detectChanges, markDirty, tick, ɵɵbind, ɵɵelement, ɵɵelementEnd, ɵɵelementProperty, ɵɵelementStart, ɵɵinterpolation1, ɵɵinterpolation2, ɵɵlistener, ɵɵtext, ɵɵtextBinding} from '../../src/render3/instructions/all'; import {RenderFlags} from '../../src/render3/interfaces/definition'; -import {RElement, Renderer3, RendererFactory3} from '../../src/render3/interfaces/renderer'; +import {Renderer3, RendererFactory3} from '../../src/render3/interfaces/renderer'; import {FLAGS, LViewFlags} from '../../src/render3/interfaces/view'; -import {ComponentFixture, containerEl, createComponent, renderComponent, requestAnimationFrame} from './render_util'; +import {containerEl, createComponent, renderComponent, requestAnimationFrame} from './render_util'; describe('change detection', () => { describe('markDirty, detectChanges, whenRendered, getRenderedText', () => { @@ -129,175 +128,6 @@ describe('change detection', () => { }); } - class MyApp { - name: string = 'Nancy'; - - static ngComponentDef = ɵɵdefineComponent({ - type: MyApp, - selectors: [['my-app']], - factory: () => new MyApp(), - consts: 1, - vars: 1, - /** */ - template: (rf: RenderFlags, ctx: MyApp) => { - if (rf & RenderFlags.Create) { - ɵɵelement(0, 'my-comp'); - } - if (rf & RenderFlags.Update) { - ɵɵelementProperty(0, 'name', ɵɵbind(ctx.name)); - } - }, - directives: () => [MyComponent] - }); - } - - it('should check OnPush components on initialization', () => { - const myApp = renderComponent(MyApp); - expect(getRenderedText(myApp)).toEqual('1 - Nancy'); - }); - - it('should call doCheck even when OnPush components are not dirty', () => { - const myApp = renderComponent(MyApp); - - tick(myApp); - expect(comp.doCheckCount).toEqual(2); - - tick(myApp); - expect(comp.doCheckCount).toEqual(3); - }); - - it('should skip OnPush components in update mode when they are not dirty', () => { - const myApp = renderComponent(MyApp); - - tick(myApp); - // doCheckCount is 2, but 1 should be rendered since it has not been marked dirty. - expect(getRenderedText(myApp)).toEqual('1 - Nancy'); - - tick(myApp); - // doCheckCount is 3, but 1 should be rendered since it has not been marked dirty. - expect(getRenderedText(myApp)).toEqual('1 - Nancy'); - }); - - it('should check OnPush components in update mode when inputs change', () => { - const myApp = renderComponent(MyApp); - - myApp.name = 'Bess'; - tick(myApp); - expect(comp.doCheckCount).toEqual(2); - // View should update, as changed input marks view dirty - expect(getRenderedText(myApp)).toEqual('2 - Bess'); - - myApp.name = 'George'; - tick(myApp); - // View should update, as changed input marks view dirty - expect(comp.doCheckCount).toEqual(3); - expect(getRenderedText(myApp)).toEqual('3 - George'); - - tick(myApp); - expect(comp.doCheckCount).toEqual(4); - // View should not be updated to "4", as inputs have not changed. - expect(getRenderedText(myApp)).toEqual('3 - George'); - }); - - it('should check OnPush components in update mode when component events occur', () => { - const myApp = renderComponent(MyApp); - expect(comp.doCheckCount).toEqual(1); - expect(getRenderedText(myApp)).toEqual('1 - Nancy'); - - const button = containerEl.querySelector('button') !; - button.click(); - requestAnimationFrame.flush(); - // No ticks should have been scheduled. - expect(comp.doCheckCount).toEqual(1); - expect(getRenderedText(myApp)).toEqual('1 - Nancy'); - - tick(myApp); - // Because the onPush comp should be dirty, it should update once CD runs - expect(comp.doCheckCount).toEqual(2); - expect(getRenderedText(myApp)).toEqual('2 - Nancy'); - }); - - it('should not check OnPush components in update mode when parent events occur', () => { - function noop() {} - - const ButtonParent = createComponent('button-parent', function(rf: RenderFlags, ctx: any) { - if (rf & RenderFlags.Create) { - ɵɵelement(0, 'my-comp'); - ɵɵelementStart(1, 'button', ['id', 'parent']); - { ɵɵlistener('click', () => noop()); } - ɵɵelementEnd(); - } - }, 2, 0, [MyComponent]); - - const buttonParent = renderComponent(ButtonParent); - expect(getRenderedText(buttonParent)).toEqual('1 - Nancy'); - - const button = containerEl.querySelector('button#parent') !; - (button as HTMLButtonElement).click(); - tick(buttonParent); - // The comp should still be clean. So doCheck will run, but the view should display 1. - expect(comp.doCheckCount).toEqual(2); - expect(getRenderedText(buttonParent)).toEqual('1 - Nancy'); - }); - - it('should check parent OnPush components in update mode when child events occur', () => { - let parent: ButtonParent; - - class ButtonParent implements DoCheck { - doCheckCount = 0; - ngDoCheck(): void { this.doCheckCount++; } - - static ngComponentDef = ɵɵdefineComponent({ - type: ButtonParent, - selectors: [['button-parent']], - factory: () => parent = new ButtonParent(), - consts: 2, - vars: 1, - /** {{ doCheckCount }} - */ - template: (rf: RenderFlags, ctx: ButtonParent) => { - if (rf & RenderFlags.Create) { - ɵɵtext(0); - ɵɵelement(1, 'my-comp'); - } - if (rf & RenderFlags.Update) { - ɵɵtextBinding(0, ɵɵinterpolation1('', ctx.doCheckCount, ' - ')); - } - }, - directives: () => [MyComponent], - changeDetection: ChangeDetectionStrategy.OnPush - }); - } - - const MyButtonApp = createComponent('my-button-app', function(rf: RenderFlags, ctx: any) { - if (rf & RenderFlags.Create) { - ɵɵelement(0, 'button-parent'); - } - }, 1, 0, [ButtonParent]); - - const myButtonApp = renderComponent(MyButtonApp); - expect(parent !.doCheckCount).toEqual(1); - expect(comp !.doCheckCount).toEqual(1); - expect(getRenderedText(myButtonApp)).toEqual('1 - 1 - Nancy'); - - tick(myButtonApp); - expect(parent !.doCheckCount).toEqual(2); - // parent isn't checked, so child doCheck won't run - expect(comp !.doCheckCount).toEqual(1); - expect(getRenderedText(myButtonApp)).toEqual('1 - 1 - Nancy'); - - const button = containerEl.querySelector('button'); - button !.click(); - requestAnimationFrame.flush(); - // No ticks should have been scheduled. - expect(parent !.doCheckCount).toEqual(2); - expect(comp !.doCheckCount).toEqual(1); - - tick(myButtonApp); - expect(parent !.doCheckCount).toEqual(3); - expect(comp !.doCheckCount).toEqual(2); - expect(getRenderedText(myButtonApp)).toEqual('3 - 2 - Nancy'); - }); - describe('Manual mode', () => { class ManualComponent implements DoCheck { /* @Input() */ @@ -420,12 +250,11 @@ describe('change detection', () => { }); } - const MyButtonApp = - createComponent('my-button-app', function(rf: RenderFlags, ctx: any) { - if (rf & RenderFlags.Create) { - ɵɵelement(0, 'button-parent'); - } - }, 1, 0, [ButtonParent]); + const MyButtonApp = createComponent('my-button-app', function(rf: RenderFlags) { + if (rf & RenderFlags.Create) { + ɵɵelement(0, 'button-parent'); + } + }, 1, 0, [ButtonParent]); const myButtonApp = renderComponent(MyButtonApp); expect(parent !.doCheckCount).toEqual(1); @@ -462,997 +291,11 @@ describe('change detection', () => { }); }); - describe('ChangeDetectorRef', () => { - - describe('detectChanges()', () => { - let myComp: MyComp; - let dir: Dir; - - class MyComp { - doCheckCount = 0; - name = 'Nancy'; - - constructor(public cdr: ChangeDetectorRef) {} - - ngDoCheck() { this.doCheckCount++; } - - static ngComponentDef = ɵɵdefineComponent({ - type: MyComp, - selectors: [['my-comp']], - factory: () => myComp = new MyComp(ɵɵdirectiveInject(ChangeDetectorRef as any)), - consts: 1, - vars: 1, - /** {{ name }} */ - template: (rf: RenderFlags, ctx: MyComp) => { - if (rf & RenderFlags.Create) { - ɵɵtext(0); - } - if (rf & RenderFlags.Update) { - ɵɵtextBinding(0, ɵɵbind(ctx.name)); - } - }, - changeDetection: ChangeDetectionStrategy.OnPush - }); - } - - class ParentComp { - doCheckCount = 0; - - constructor(public cdr: ChangeDetectorRef) {} - - ngDoCheck() { this.doCheckCount++; } - - static ngComponentDef = ɵɵdefineComponent({ - type: ParentComp, - selectors: [['parent-comp']], - factory: () => new ParentComp(ɵɵdirectiveInject(ChangeDetectorRef as any)), - consts: 2, - vars: 1, - /** - * {{ doCheckCount}} - - * - */ - template: (rf: RenderFlags, ctx: ParentComp) => { - if (rf & RenderFlags.Create) { - ɵɵtext(0); - ɵɵelement(1, 'my-comp'); - } - if (rf & RenderFlags.Update) { - ɵɵtextBinding(0, ɵɵinterpolation1('', ctx.doCheckCount, ' - ')); - } - }, - directives: () => [MyComp] - }); - } - - class Dir { - constructor(public cdr: ChangeDetectorRef) {} - - static ngDirectiveDef = ɵɵdefineDirective({ - type: Dir, - selectors: [['', 'dir', '']], - factory: () => dir = new Dir(ɵɵdirectiveInject(ChangeDetectorRef as any)) - }); - } - - - it('should check the component view when called by component (even when OnPush && clean)', - () => { - const comp = renderComponent(MyComp, {hostFeatures: [LifecycleHooksFeature]}); - expect(getRenderedText(comp)).toEqual('Nancy'); - - comp.name = 'Bess'; // as this is not an Input, the component stays clean - comp.cdr.detectChanges(); - expect(getRenderedText(comp)).toEqual('Bess'); - }); - - it('should NOT call component doCheck when called by a component', () => { - const comp = renderComponent(MyComp, {hostFeatures: [LifecycleHooksFeature]}); - expect(comp.doCheckCount).toEqual(1); - - // NOTE: in current Angular, detectChanges does not itself trigger doCheck, but you - // may see doCheck called in some cases bc of the extra CD run triggered by zone.js. - // It's important not to call doCheck to allow calls to detectChanges in that hook. - comp.cdr.detectChanges(); - expect(comp.doCheckCount).toEqual(1); - }); - - it('should NOT check the component parent when called by a child component', () => { - const parentComp = renderComponent(ParentComp, {hostFeatures: [LifecycleHooksFeature]}); - expect(getRenderedText(parentComp)).toEqual('1 - Nancy'); - - parentComp.doCheckCount = 100; - myComp.cdr.detectChanges(); - expect(parentComp.doCheckCount).toEqual(100); - expect(getRenderedText(parentComp)).toEqual('1 - Nancy'); - }); - - it('should check component children when called by component if dirty or check-always', - () => { - const parentComp = renderComponent(ParentComp, {hostFeatures: [LifecycleHooksFeature]}); - expect(parentComp.doCheckCount).toEqual(1); - - myComp.name = 'Bess'; - parentComp.cdr.detectChanges(); - expect(parentComp.doCheckCount).toEqual(1); - expect(myComp.doCheckCount).toEqual(2); - // OnPush child is not dirty, so its change isn't rendered. - expect(getRenderedText(parentComp)).toEqual('1 - Nancy'); - }); - - it('should not group detectChanges calls (call every time)', () => { - const parentComp = renderComponent(ParentComp, {hostFeatures: [LifecycleHooksFeature]}); - expect(myComp.doCheckCount).toEqual(1); - - parentComp.cdr.detectChanges(); - parentComp.cdr.detectChanges(); - expect(myComp.doCheckCount).toEqual(3); - }); - - it('should check component view when called by directive on component node', () => { - /** */ - const MyApp = createComponent('my-app', function(rf: RenderFlags, ctx: any) { - if (rf & RenderFlags.Create) { - ɵɵelement(0, 'my-comp', ['dir', '']); - } - }, 1, 0, [MyComp, Dir]); - - const app = renderComponent(MyApp); - expect(getRenderedText(app)).toEqual('Nancy'); - - myComp.name = 'George'; - dir !.cdr.detectChanges(); - expect(getRenderedText(app)).toEqual('George'); - }); - - it('should check host component when called by directive on element node', () => { - /** - * {{ name }} - *
- */ - const MyApp = createComponent('my-app', function(rf: RenderFlags, ctx: any) { - if (rf & RenderFlags.Create) { - ɵɵtext(0); - ɵɵelement(1, 'div', ['dir', '']); - } - if (rf & RenderFlags.Update) { - ɵɵtextBinding(1, ɵɵbind(ctx.value)); - } - }, 2, 1, [Dir]); - - const app = renderComponent(MyApp); - app.value = 'Frank'; - tick(app); - expect(getRenderedText(app)).toEqual('Frank'); - - app.value = 'Joe'; - dir !.cdr.detectChanges(); - expect(getRenderedText(app)).toEqual('Joe'); - }); - - it('should check the host component when called from EmbeddedViewRef', () => { - class MyApp { - showing = true; - name = 'Amelia'; - - constructor(public cdr: ChangeDetectorRef) {} - - static ngComponentDef = ɵɵdefineComponent({ - type: MyApp, - selectors: [['my-app']], - factory: () => new MyApp(ɵɵdirectiveInject(ChangeDetectorRef as any)), - consts: 2, - vars: 1, - /** - * {{ name}} - * % if (showing) { - *
- * % } - */ - template: function(rf: RenderFlags, ctx: MyApp) { - if (rf & RenderFlags.Create) { - ɵɵtext(0); - ɵɵcontainer(1); - } - if (rf & RenderFlags.Update) { - ɵɵtextBinding(0, ɵɵbind(ctx.name)); - ɵɵcontainerRefreshStart(1); - { - if (ctx.showing) { - let rf0 = ɵɵembeddedViewStart(0, 1, 0); - if (rf0 & RenderFlags.Create) { - ɵɵelement(0, 'div', ['dir', '']); - } - } - ɵɵembeddedViewEnd(); - } - ɵɵcontainerRefreshEnd(); - } - }, - directives: [Dir] - }); - } - - const app = renderComponent(MyApp); - expect(getRenderedText(app)).toEqual('Amelia'); - - app.name = 'Emerson'; - dir !.cdr.detectChanges(); - expect(getRenderedText(app)).toEqual('Emerson'); - }); - - it('should support call in ngOnInit', () => { - class DetectChangesComp { - value = 0; - - constructor(public cdr: ChangeDetectorRef) {} - - ngOnInit() { - this.value++; - this.cdr.detectChanges(); - } - - static ngComponentDef = ɵɵdefineComponent({ - type: DetectChangesComp, - selectors: [['detect-changes-comp']], - factory: () => new DetectChangesComp(ɵɵdirectiveInject(ChangeDetectorRef as any)), - consts: 1, - vars: 1, - /** {{ value }} */ - template: (rf: RenderFlags, ctx: DetectChangesComp) => { - if (rf & RenderFlags.Create) { - ɵɵtext(0); - } - if (rf & RenderFlags.Update) { - ɵɵtextBinding(0, ɵɵbind(ctx.value)); - } - } - }); - } - - const comp = renderComponent(DetectChangesComp, {hostFeatures: [LifecycleHooksFeature]}); - expect(getRenderedText(comp)).toEqual('1'); - }); - - - ['OnInit', 'AfterContentInit', 'AfterViewInit', 'OnChanges'].forEach(hook => { - it(`should not go infinite loop when recursively called from children's ng${hook}`, () => { - class ChildComp { - // @Input - inp = ''; - - count = 0; - constructor(public parentComp: ParentComp) {} - - ngOnInit() { this.check('OnInit'); } - ngAfterContentInit() { this.check('AfterContentInit'); } - ngAfterViewInit() { this.check('AfterViewInit'); } - ngOnChanges() { this.check('OnChanges'); } - - check(h: string) { - if (h === hook) { - this.count++; - if (this.count > 1) throw new Error(`ng${hook} should be called only once!`); - this.parentComp.triggerChangeDetection(); - } - } - - static ngComponentDef = ɵɵdefineComponent({ - type: ChildComp, - selectors: [['child-comp']], - factory: () => new ChildComp(ɵɵdirectiveInject(ParentComp as any)), - consts: 1, - vars: 0, - template: (rf: RenderFlags, ctx: ChildComp) => { - if (rf & RenderFlags.Create) { - ɵɵtext(0, 'foo'); - } - }, - inputs: {inp: 'inp'}, - features: [ɵɵNgOnChangesFeature] - }); - } - - class ParentComp { - constructor(public cdr: ChangeDetectorRef) {} - - triggerChangeDetection() { this.cdr.detectChanges(); } - - static ngComponentDef = ɵɵdefineComponent({ - type: ParentComp, - selectors: [['parent-comp']], - factory: () => new ParentComp(ɵɵdirectiveInject(ChangeDetectorRef as any)), - consts: 1, - vars: 1, - /** {{ value }} */ - template: (rf: RenderFlags, ctx: ParentComp) => { - if (rf & RenderFlags.Create) { - ɵɵelement(0, 'child-comp'); - } - if (rf & RenderFlags.Update) { - ɵɵelementProperty(0, 'inp', ɵɵbind(true)); - } - }, - directives: [ChildComp] - }); - } - - expect(() => renderComponent(ParentComp)).not.toThrow(); - }); - }); - - it('should support call in ngDoCheck', () => { - class DetectChangesComp { - doCheckCount = 0; - - constructor(public cdr: ChangeDetectorRef) {} - - ngDoCheck() { - this.doCheckCount++; - this.cdr.detectChanges(); - } - - static ngComponentDef = ɵɵdefineComponent({ - type: DetectChangesComp, - selectors: [['detect-changes-comp']], - factory: () => new DetectChangesComp(ɵɵdirectiveInject(ChangeDetectorRef as any)), - consts: 1, - vars: 1, - /** {{ doCheckCount }} */ - template: (rf: RenderFlags, ctx: DetectChangesComp) => { - if (rf & RenderFlags.Create) { - ɵɵtext(0); - } - if (rf & RenderFlags.Update) { - ɵɵtextBinding(0, ɵɵbind(ctx.doCheckCount)); - } - } - }); - } - - const comp = renderComponent(DetectChangesComp, {hostFeatures: [LifecycleHooksFeature]}); - expect(getRenderedText(comp)).toEqual('1'); - }); - - describe('dynamic views', () => { - let structuralComp: StructuralComp|null = null; - - beforeEach(() => structuralComp = null); - - class StructuralComp { - tmp !: TemplateRef; - value = 'one'; - - constructor(public vcr: ViewContainerRef) {} - - create() { return this.vcr.createEmbeddedView(this.tmp, this); } - - static ngComponentDef = ɵɵdefineComponent({ - type: StructuralComp, - selectors: [['structural-comp']], - factory: () => structuralComp = - new StructuralComp(ɵɵdirectiveInject(ViewContainerRef as any)), - inputs: {tmp: 'tmp'}, - consts: 1, - vars: 1, - template: function(rf: RenderFlags, ctx: any) { - if (rf & RenderFlags.Create) { - ɵɵtext(0); - } - if (rf & RenderFlags.Update) { - ɵɵtextBinding(0, ɵɵbind(ctx.value)); - } - } - }); - } - - it('should support ViewRef.detectChanges()', () => { - function FooTemplate(rf: RenderFlags, ctx: any) { - if (rf & RenderFlags.Create) { - ɵɵtext(0); - } - if (rf & RenderFlags.Update) { - ɵɵtextBinding(0, ɵɵbind(ctx.value)); - } - } - - /** - * {{ value }} - * - */ - const App = createComponent('app', function(rf: RenderFlags, ctx: any) { - if (rf & RenderFlags.Create) { - ɵɵtemplate( - 0, FooTemplate, 1, 1, 'ng-template', null, ['foo', ''], ɵɵtemplateRefExtractor); - ɵɵelement(2, 'structural-comp'); - } - if (rf & RenderFlags.Update) { - const foo = ɵɵreference(1) as any; - ɵɵelementProperty(2, 'tmp', ɵɵbind(foo)); - } - }, 3, 1, [StructuralComp]); - - const fixture = new ComponentFixture(App); - fixture.update(); - expect(fixture.html).toEqual('one'); - - const viewRef: EmbeddedViewRef = structuralComp !.create(); - fixture.update(); - expect(fixture.html).toEqual('oneone'); - - // check embedded view update - structuralComp !.value = 'two'; - viewRef.detectChanges(); - expect(fixture.html).toEqual('onetwo'); - - // check root view update - structuralComp !.value = 'three'; - fixture.update(); - expect(fixture.html).toEqual('threethree'); - }); - - it('should support ViewRef.detectChanges() directly after creation', () => { - function FooTemplate(rf: RenderFlags, ctx: any) { - if (rf & RenderFlags.Create) { - ɵɵtext(0, 'Template text'); - } - } - - /** - * Template text - * - */ - const App = createComponent('app', function(rf: RenderFlags, ctx: any) { - if (rf & RenderFlags.Create) { - ɵɵtemplate( - 0, FooTemplate, 1, 0, 'ng-template', null, ['foo', ''], ɵɵtemplateRefExtractor); - ɵɵelement(2, 'structural-comp'); - } - if (rf & RenderFlags.Update) { - const foo = ɵɵreference(1) as any; - ɵɵelementProperty(2, 'tmp', ɵɵbind(foo)); - } - }, 3, 1, [StructuralComp]); - - const fixture = new ComponentFixture(App); - fixture.update(); - expect(fixture.html).toEqual('one'); - - const viewRef: EmbeddedViewRef = structuralComp !.create(); - viewRef.detectChanges(); - expect(fixture.html).toEqual('oneTemplate text'); - }); - - }); - - }); - - describe('attach/detach', () => { - let comp: DetachedComp; - - class MyApp { - constructor(public cdr: ChangeDetectorRef) {} - - static ngComponentDef = ɵɵdefineComponent({ - type: MyApp, - selectors: [['my-app']], - factory: () => new MyApp(ɵɵdirectiveInject(ChangeDetectorRef as any)), - consts: 1, - vars: 0, - /** */ - template: (rf: RenderFlags, ctx: MyApp) => { - if (rf & RenderFlags.Create) { - ɵɵelement(0, 'detached-comp'); - } - }, - directives: () => [DetachedComp] - }); - } - - class DetachedComp { - value = 'one'; - doCheckCount = 0; - - constructor(public cdr: ChangeDetectorRef) {} - - ngDoCheck() { this.doCheckCount++; } - - static ngComponentDef = ɵɵdefineComponent({ - type: DetachedComp, - selectors: [['detached-comp']], - factory: () => comp = new DetachedComp(ɵɵdirectiveInject(ChangeDetectorRef as any)), - consts: 1, - vars: 1, - /** {{ value }} */ - template: (rf: RenderFlags, ctx: DetachedComp) => { - if (rf & RenderFlags.Create) { - ɵɵtext(0); - } - if (rf & RenderFlags.Update) { - ɵɵtextBinding(0, ɵɵbind(ctx.value)); - } - } - }); - } - - it('should not check detached components', () => { - const app = renderComponent(MyApp); - expect(getRenderedText(app)).toEqual('one'); - - comp.cdr.detach(); - - comp.value = 'two'; - tick(app); - expect(getRenderedText(app)).toEqual('one'); - }); - - it('should check re-attached components', () => { - const app = renderComponent(MyApp); - expect(getRenderedText(app)).toEqual('one'); - - comp.cdr.detach(); - comp.value = 'two'; - - comp.cdr.reattach(); - tick(app); - expect(getRenderedText(app)).toEqual('two'); - }); - - it('should call lifecycle hooks on detached components', () => { - const app = renderComponent(MyApp); - expect(comp.doCheckCount).toEqual(1); - - comp.cdr.detach(); - - tick(app); - expect(comp.doCheckCount).toEqual(2); - }); - - it('should check detached component when detectChanges is called', () => { - const app = renderComponent(MyApp); - expect(getRenderedText(app)).toEqual('one'); - - comp.cdr.detach(); - - comp.value = 'two'; - detectChanges(comp); - expect(getRenderedText(app)).toEqual('two'); - }); - - it('should not check detached component when markDirty is called', () => { - const app = renderComponent(MyApp); - expect(getRenderedText(app)).toEqual('one'); - - comp.cdr.detach(); - - comp.value = 'two'; - markDirty(comp); - requestAnimationFrame.flush(); - - expect(getRenderedText(app)).toEqual('one'); - }); - - it('should detach any child components when parent is detached', () => { - const app = renderComponent(MyApp); - expect(getRenderedText(app)).toEqual('one'); - - app.cdr.detach(); - - comp.value = 'two'; - tick(app); - expect(getRenderedText(app)).toEqual('one'); - - app.cdr.reattach(); - - tick(app); - expect(getRenderedText(app)).toEqual('two'); - }); - - it('should detach OnPush components properly', () => { - let onPushComp: OnPushComp; - - class OnPushComp { - /** @Input() */ - // TODO(issue/24571): remove '!'. - value !: string; - - constructor(public cdr: ChangeDetectorRef) {} - - static ngComponentDef = ɵɵdefineComponent({ - type: OnPushComp, - selectors: [['on-push-comp']], - factory: () => onPushComp = new OnPushComp(ɵɵdirectiveInject(ChangeDetectorRef as any)), - consts: 1, - vars: 1, - /** {{ value }} */ - template: (rf: RenderFlags, ctx: any) => { - if (rf & RenderFlags.Create) { - ɵɵtext(0); - } - if (rf & RenderFlags.Update) { - ɵɵtextBinding(0, ɵɵbind(ctx.value)); - } - }, - changeDetection: ChangeDetectionStrategy.OnPush, - inputs: {value: 'value'} - }); - } - - /** */ - const OnPushApp = createComponent('on-push-app', function(rf: RenderFlags, ctx: any) { - if (rf & RenderFlags.Create) { - ɵɵelement(0, 'on-push-comp'); - } - if (rf & RenderFlags.Update) { - ɵɵelementProperty(0, 'value', ɵɵbind(ctx.value)); - } - }, 1, 1, [OnPushComp]); - - const app = renderComponent(OnPushApp); - app.value = 'one'; - tick(app); - expect(getRenderedText(app)).toEqual('one'); - - onPushComp !.cdr.detach(); - - app.value = 'two'; - tick(app); - expect(getRenderedText(app)).toEqual('one'); - - onPushComp !.cdr.reattach(); - - tick(app); - expect(getRenderedText(app)).toEqual('two'); - }); - - }); - - describe('markForCheck()', () => { - let comp: OnPushComp; - - class OnPushComp { - value = 'one'; - - doCheckCount = 0; - - constructor(public cdr: ChangeDetectorRef) {} - - ngDoCheck() { this.doCheckCount++; } - - static ngComponentDef = ɵɵdefineComponent({ - type: OnPushComp, - selectors: [['on-push-comp']], - factory: () => comp = new OnPushComp(ɵɵdirectiveInject(ChangeDetectorRef as any)), - consts: 1, - vars: 1, - /** {{ value }} */ - template: (rf: RenderFlags, ctx: OnPushComp) => { - if (rf & RenderFlags.Create) { - ɵɵtext(0); - } - if (rf & RenderFlags.Update) { - ɵɵtextBinding(0, ɵɵbind(ctx.value)); - } - }, - changeDetection: ChangeDetectionStrategy.OnPush - }); - } - - class OnPushParent { - value = 'one'; - - static ngComponentDef = ɵɵdefineComponent({ - type: OnPushParent, - selectors: [['on-push-parent']], - factory: () => new OnPushParent(), - consts: 2, - vars: 1, - /** - * {{ value }} - - * - */ - template: (rf: RenderFlags, ctx: OnPushParent) => { - if (rf & RenderFlags.Create) { - ɵɵtext(0); - ɵɵelement(1, 'on-push-comp'); - } - if (rf & RenderFlags.Update) { - ɵɵtextBinding(0, ɵɵinterpolation1('', ctx.value, ' - ')); - } - }, - directives: () => [OnPushComp], - changeDetection: ChangeDetectionStrategy.OnPush - }); - } - - it('should ensure OnPush components are checked', () => { - const fixture = new ComponentFixture(OnPushParent); - expect(fixture.hostElement.textContent).toEqual('one - one'); - - comp.value = 'two'; - tick(fixture.component); - expect(fixture.hostElement.textContent).toEqual('one - one'); - - comp.cdr.markForCheck(); - - // Change detection should not have run yet, since markForCheck - // does not itself schedule change detection. - expect(fixture.hostElement.textContent).toEqual('one - one'); - - tick(fixture.component); - expect(fixture.hostElement.textContent).toEqual('one - two'); - }); - - it('should never schedule change detection on its own', () => { - const fixture = new ComponentFixture(OnPushParent); - expect(comp.doCheckCount).toEqual(1); - - comp.cdr.markForCheck(); - comp.cdr.markForCheck(); - requestAnimationFrame.flush(); - - expect(comp.doCheckCount).toEqual(1); - }); - - it('should ensure ancestor OnPush components are checked', () => { - const fixture = new ComponentFixture(OnPushParent); - expect(fixture.hostElement.textContent).toEqual('one - one'); - - fixture.component.value = 'two'; - tick(fixture.component); - expect(fixture.hostElement.textContent).toEqual('one - one'); - - comp.cdr.markForCheck(); - tick(fixture.component); - expect(fixture.hostElement.textContent).toEqual('two - one'); - - }); - - it('should ensure OnPush components in embedded views are checked', () => { - class EmbeddedViewParent { - value = 'one'; - showing = true; - - static ngComponentDef = ɵɵdefineComponent({ - type: EmbeddedViewParent, - selectors: [['embedded-view-parent']], - factory: () => new EmbeddedViewParent(), - consts: 2, - vars: 1, - /** - * {{ value }} - - * % if (ctx.showing) { - * - * % } - */ - template: (rf: RenderFlags, ctx: any) => { - if (rf & RenderFlags.Create) { - ɵɵtext(0); - ɵɵcontainer(1); - } - if (rf & RenderFlags.Update) { - ɵɵtextBinding(0, ɵɵinterpolation1('', ctx.value, ' - ')); - ɵɵcontainerRefreshStart(1); - { - if (ctx.showing) { - let rf0 = ɵɵembeddedViewStart(0, 1, 0); - if (rf0 & RenderFlags.Create) { - ɵɵelement(0, 'on-push-comp'); - } - ɵɵembeddedViewEnd(); - } - } - ɵɵcontainerRefreshEnd(); - } - }, - directives: () => [OnPushComp], - changeDetection: ChangeDetectionStrategy.OnPush - }); - } - - const fixture = new ComponentFixture(EmbeddedViewParent); - expect(fixture.hostElement.textContent).toEqual('one - one'); - - comp.value = 'two'; - tick(fixture.component); - expect(fixture.hostElement.textContent).toEqual('one - one'); - - comp.cdr.markForCheck(); - // markForCheck should not trigger change detection on its own. - expect(fixture.hostElement.textContent).toEqual('one - one'); - - tick(fixture.component); - expect(fixture.hostElement.textContent).toEqual('one - two'); - - fixture.component.value = 'two'; - tick(fixture.component); - expect(fixture.hostElement.textContent).toEqual('one - two'); - - comp.cdr.markForCheck(); - tick(fixture.component); - expect(fixture.hostElement.textContent).toEqual('two - two'); - }); - - // TODO(kara): add test for dynamic views once bug fix is in - }); - - describe('checkNoChanges', () => { - let comp: NoChangesComp; - - class NoChangesComp { - value = 1; - doCheckCount = 0; - contentCheckCount = 0; - viewCheckCount = 0; - - ngDoCheck() { this.doCheckCount++; } - - ngAfterContentChecked() { this.contentCheckCount++; } - - ngAfterViewChecked() { this.viewCheckCount++; } - - constructor(public cdr: ChangeDetectorRef) {} - - static ngComponentDef = ɵɵdefineComponent({ - type: NoChangesComp, - selectors: [['no-changes-comp']], - factory: () => comp = new NoChangesComp(ɵɵdirectiveInject(ChangeDetectorRef as any)), - consts: 1, - vars: 1, - template: (rf: RenderFlags, ctx: NoChangesComp) => { - if (rf & RenderFlags.Create) { - ɵɵtext(0); - } - if (rf & RenderFlags.Update) { - ɵɵtextBinding(0, ɵɵbind(ctx.value)); - } - } - }); - } - - class AppComp { - value = 1; - - constructor(public cdr: ChangeDetectorRef) {} - - static ngComponentDef = ɵɵdefineComponent({ - type: AppComp, - selectors: [['app-comp']], - factory: () => new AppComp(ɵɵdirectiveInject(ChangeDetectorRef as any)), - consts: 2, - vars: 1, - /** - * {{ value }} - - * - */ - template: (rf: RenderFlags, ctx: AppComp) => { - if (rf & RenderFlags.Create) { - ɵɵtext(0); - ɵɵelement(1, 'no-changes-comp'); - } - if (rf & RenderFlags.Update) { - ɵɵtextBinding(0, ɵɵinterpolation1('', ctx.value, ' - ')); - } - }, - directives: () => [NoChangesComp] - }); - } - - it('should throw if bindings in current view have changed', () => { - const comp = renderComponent(NoChangesComp, {hostFeatures: [LifecycleHooksFeature]}); - - expect(() => comp.cdr.checkNoChanges()).not.toThrow(); - - comp.value = 2; - expect(() => comp.cdr.checkNoChanges()) - .toThrowError( - /ExpressionChangedAfterItHasBeenCheckedError: .+ Previous value: '1'. Current value: '2'/gi); - }); - - it('should throw if interpolations in current view have changed', () => { - const app = renderComponent(AppComp); - - expect(() => app.cdr.checkNoChanges()).not.toThrow(); - - app.value = 2; - expect(() => app.cdr.checkNoChanges()) - .toThrowError( - /ExpressionChangedAfterItHasBeenCheckedError: .+ Previous value: '1'. Current value: '2'/gi); - }); - - it('should throw if bindings in children of current view have changed', () => { - const app = renderComponent(AppComp); - - expect(() => app.cdr.checkNoChanges()).not.toThrow(); - - comp.value = 2; - expect(() => app.cdr.checkNoChanges()) - .toThrowError( - /ExpressionChangedAfterItHasBeenCheckedError: .+ Previous value: '1'. Current value: '2'/gi); - }); - - it('should throw if bindings in embedded view have changed', () => { - class EmbeddedViewApp { - value = 1; - showing = true; - - constructor(public cdr: ChangeDetectorRef) {} - - static ngComponentDef = ɵɵdefineComponent({ - type: EmbeddedViewApp, - selectors: [['embedded-view-app']], - factory: () => new EmbeddedViewApp(ɵɵdirectiveInject(ChangeDetectorRef as any)), - consts: 1, - vars: 0, - /** - * % if (showing) { - * {{ value }} - * %} - */ - template: (rf: RenderFlags, ctx: EmbeddedViewApp) => { - if (rf & RenderFlags.Create) { - ɵɵcontainer(0); - } - if (rf & RenderFlags.Update) { - ɵɵcontainerRefreshStart(0); - { - if (ctx.showing) { - let rf0 = ɵɵembeddedViewStart(0, 1, 1); - if (rf0 & RenderFlags.Create) { - ɵɵtext(0); - } - if (rf0 & RenderFlags.Update) { - ɵɵtextBinding(0, ɵɵbind(ctx.value)); - } - ɵɵembeddedViewEnd(); - } - } - ɵɵcontainerRefreshEnd(); - } - } - }); - } - - const app = renderComponent(EmbeddedViewApp); - - expect(() => app.cdr.checkNoChanges()).not.toThrow(); - - app.value = 2; - expect(() => app.cdr.checkNoChanges()) - .toThrowError( - /ExpressionChangedAfterItHasBeenCheckedError: .+ Previous value: '1'. Current value: '2'/gi); - }); - - it('should NOT call lifecycle hooks', () => { - const app = renderComponent(AppComp); - expect(comp.doCheckCount).toEqual(1); - expect(comp.contentCheckCount).toEqual(1); - expect(comp.viewCheckCount).toEqual(1); - - comp.value = 2; - expect(() => app.cdr.checkNoChanges()).toThrow(); - expect(comp.doCheckCount).toEqual(1); - expect(comp.contentCheckCount).toEqual(1); - expect(comp.viewCheckCount).toEqual(1); - }); - - it('should NOT throw if bindings in ancestors of current view have changed', () => { - const app = renderComponent(AppComp); - - app.value = 2; - expect(() => comp.cdr.checkNoChanges()).not.toThrow(); - }); - - }); - - }); - it('should call begin and end when the renderer factory implements them', () => { const log: string[] = []; const testRendererFactory: RendererFactory3 = { - createRenderer: (hostElement: RElement | null, rendererType: RendererType2 | null): - Renderer3 => { return document; }, + createRenderer: (): Renderer3 => { return document; }, begin: () => log.push('begin'), end: () => log.push('end'), };