From 4c089c1d931c0ea35591837706de205a75a61ccb Mon Sep 17 00:00:00 2001 From: Kara Erickson Date: Tue, 6 Mar 2018 11:58:08 -0800 Subject: [PATCH] feat(ivy): support ChangeDetectorRef.detectChanges (#22614) PR Close #22614 --- packages/core/src/render3/component.ts | 23 +- packages/core/src/render3/index.ts | 4 +- packages/core/src/render3/instructions.ts | 84 +- packages/core/src/render3/view_ref.ts | 8 +- .../hello_world/bundle.golden_symbols.json | 18 +- .../test/render3/change_detection_spec.ts | 722 +++++++++++++----- packages/core/test/render3/component_spec.ts | 66 -- packages/core/test/render3/lifecycle_spec.ts | 14 +- packages/core/test/render3/render_util.ts | 4 +- .../test/render3/renderer_factory_spec.ts | 6 +- 10 files changed, 655 insertions(+), 294 deletions(-) diff --git a/packages/core/src/render3/component.ts b/packages/core/src/render3/component.ts index b878ab0d3f..0fbaac464b 100644 --- a/packages/core/src/render3/component.ts +++ b/packages/core/src/render3/component.ts @@ -13,7 +13,7 @@ import {ComponentRef as viewEngine_ComponentRef} from '../linker/component_facto import {assertNotNull} from './assert'; import {queueLifecycleHooks} from './hooks'; -import {CLEAN_PROMISE, _getComponentHostLElementNode, createLView, createTView, detectChanges, directiveCreate, enterView, getDirectiveInstance, hostElement, initChangeDetectorIfExisting, leaveView, locateHostElement, scheduleChangeDetection} from './instructions'; +import {CLEAN_PROMISE, _getComponentHostLElementNode, createLView, createTView, directiveCreate, enterView, getDirectiveInstance, getRootView, hostElement, initChangeDetectorIfExisting, leaveView, locateHostElement, scheduleTick, tick} from './instructions'; import {ComponentDef, ComponentType} from './interfaces/definition'; import {RElement, RendererFactory3, domRendererFactory3} from './interfaces/renderer'; import {LViewFlags, RootContext} from './interfaces/view'; @@ -43,12 +43,12 @@ export interface CreateComponentOptions { * Typically, the features in this list are features that cannot be added to the * other features list in the component definition because they rely on other factors. * - * Example: RootLifecycleHooks is a function that adds lifecycle hook capabilities + * Example: `RootLifecycleHooks` is a function that adds lifecycle hook capabilities * to root components in a tree-shakable way. It cannot be added to the component * features list because there's no way of knowing when the component will be used as * a root component. */ - features?: ((component: T, componentDef: ComponentDef) => void)[]; + hostFeatures?: ((component: T, componentDef: ComponentDef) => void)[]; /** * A function which is used to schedule change detection work in the future. @@ -141,12 +141,11 @@ export function renderComponent( enterView(oldView, null); } - opts.features && opts.features.forEach((feature) => feature(component, componentDef)); - detectChanges(component); + opts.hostFeatures && opts.hostFeatures.forEach((feature) => feature(component, componentDef)); + tick(component); return component; } - /** * Used to enable lifecycle hooks on the root component. * @@ -156,9 +155,11 @@ export function renderComponent( * * Example: * + * ``` * renderComponent(AppComponent, {features: [RootLifecycleHooks]}); + * ``` */ -export function RootLifecycleHooks(component: any, def: ComponentDef): void { +export function LifecycleHooksFeature(component: any, def: ComponentDef): void { const elementNode = _getComponentHostLElementNode(component); queueLifecycleHooks(elementNode.flags, elementNode.view); } @@ -170,13 +171,7 @@ export function RootLifecycleHooks(component: any, def: ComponentDef): void * @param component any component */ function getRootContext(component: any): RootContext { - ngDevMode && assertNotNull(component, 'component'); - const lElementNode = _getComponentHostLElementNode(component); - let lView = lElementNode.view; - while (lView.parent) { - lView = lView.parent; - } - const rootContext = lView.context as RootContext; + const rootContext = getRootView(component).context as RootContext; ngDevMode && assertNotNull(rootContext, 'rootContext'); return rootContext; } diff --git a/packages/core/src/render3/index.ts b/packages/core/src/render3/index.ts index 9151b460c8..134414a94a 100644 --- a/packages/core/src/render3/index.ts +++ b/packages/core/src/render3/index.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ -import {RootLifecycleHooks, createComponentRef, getHostElement, getRenderedText, renderComponent, whenRendered} from './component'; +import {LifecycleHooksFeature, createComponentRef, getHostElement, getRenderedText, renderComponent, whenRendered} from './component'; import {NgOnChangesFeature, PublicFeature, defineComponent, defineDirective, definePipe} from './definition'; import {InjectFlags} from './di'; import {ComponentDef, ComponentTemplate, ComponentType, DirectiveDef, DirectiveDefFlags, DirectiveType} from './interfaces/definition'; @@ -109,7 +109,7 @@ export { DirectiveType, NgOnChangesFeature, PublicFeature, - RootLifecycleHooks, + LifecycleHooksFeature, defineComponent, defineDirective, definePipe, diff --git a/packages/core/src/render3/instructions.ts b/packages/core/src/render3/instructions.ts index c059b5ee5a..78ed8128f0 100644 --- a/packages/core/src/render3/instructions.ts +++ b/packages/core/src/render3/instructions.ts @@ -1284,14 +1284,7 @@ export function directiveRefresh(directiveIndex: number, elementIndex: number // Only CheckAlways components or dirty OnPush components should be checked if (hostView.flags & (LViewFlags.CheckAlways | LViewFlags.Dirty)) { ngDevMode && assertDataInRange(directiveIndex); - const directive = getDirectiveInstance(data[directiveIndex]); - const oldView = enterView(hostView, element); - try { - template(directive, creationMode); - } finally { - refreshDynamicChildren(); - leaveView(oldView); - } + detectChangesInternal(hostView, element, getDirectiveInstance(data[directiveIndex])); } } } @@ -1511,23 +1504,71 @@ function markViewDirty(view: LView): void { currentView.flags |= LViewFlags.Dirty; ngDevMode && assertNotNull(currentView !.context, 'rootContext'); - scheduleChangeDetection(currentView !.context as RootContext); + scheduleTick(currentView !.context as RootContext); } -/** Given a root context, schedules change detection at that root. */ -export function scheduleChangeDetection(rootContext: RootContext) { +/** + * Used to schedule change detection on the whole application. + * + * Unlike `tick`, `scheduleTick` coalesces multiple calls into one change detection run. + * It is usually called indirectly by calling `markDirty` when the view needs to be + * re-rendered. + * + * Typically `scheduleTick` uses `requestAnimationFrame` to coalesce multiple + * `scheduleTick` requests. The scheduling function can be overridden in + * `renderComponent`'s `scheduler` option. + */ +export function scheduleTick(rootContext: RootContext) { if (rootContext.clean == _CLEAN_PROMISE) { let res: null|((val: null) => void); rootContext.clean = new Promise((r) => res = r); rootContext.scheduler(() => { - detectChanges(rootContext.component); + tick(rootContext.component); res !(null); rootContext.clean = _CLEAN_PROMISE; }); } } +/** + * Used to perform change detection on the whole application. + * + * This is equivalent to `detectChanges`, but invoked on root component. Additionally, `tick` + * executes lifecycle hooks and conditionally checks components based on their + * `ChangeDetectionStrategy` and dirtiness. + * + * The preferred way to trigger change detection is to call `markDirty`. `markDirty` internally + * schedules `tick` using a scheduler in order to coalesce multiple `markDirty` calls into a + * single change detection run. By default, the scheduler is `requestAnimationFrame`, but can + * be changed when calling `renderComponent` and providing the `scheduler` option. + */ +export function tick(component: T): void { + const rootView = getRootView(component); + const rootComponent = (rootView.context as RootContext).component; + const hostNode = _getComponentHostLElementNode(rootComponent); + + ngDevMode && assertNotNull(hostNode.data, 'Component host node should be attached to an LView'); + renderComponentOrTemplate(hostNode, rootView, rootComponent); +} + +/** + * Retrieve the root view from any component by walking the parent `LView` until + * reaching the root `LView`. + * + * @param component any component + */ + +export function getRootView(component: any): LView { + ngDevMode && assertNotNull(component, 'component'); + const lElementNode = _getComponentHostLElementNode(component); + let lView = lElementNode.view; + while (lView.parent) { + lView = lView.parent; + } + return lView; +} + /** * Synchronously perform change detection on a component (and possibly its sub-components). * @@ -1544,7 +1585,24 @@ export function scheduleChangeDetection(rootContext: RootContext) { export function detectChanges(component: T): void { const hostNode = _getComponentHostLElementNode(component); ngDevMode && assertNotNull(hostNode.data, 'Component host node should be attached to an LView'); - renderComponentOrTemplate(hostNode, hostNode.view, component); + detectChangesInternal(hostNode.data as LView, hostNode, component); +} + + +/** Checks the view of the component provided. Does not gate on dirty checks or execute doCheck. */ +function detectChangesInternal(hostView: LView, hostNode: LElementNode, component: T) { + const componentIndex = hostNode.flags >> LNodeFlags.INDX_SHIFT; + const template = (hostNode.view.tView.data[componentIndex] as ComponentDef).template; + const oldView = enterView(hostView, hostNode); + + if (template != null) { + try { + template(component, creationMode); + } finally { + refreshDynamicChildren(); + leaveView(oldView); + } + } } diff --git a/packages/core/src/render3/view_ref.ts b/packages/core/src/render3/view_ref.ts index fe9965d8b2..92be0d9124 100644 --- a/packages/core/src/render3/view_ref.ts +++ b/packages/core/src/render3/view_ref.ts @@ -6,8 +6,8 @@ * found in the LICENSE file at https://angular.io/license */ -import {EmbeddedViewRef as viewEngine_EmbeddedViewRef, ViewRef as viewEngine_ViewRef} from '../linker/view_ref'; - +import {EmbeddedViewRef as viewEngine_EmbeddedViewRef} from '../linker/view_ref'; +import {detectChanges} from './instructions'; import {ComponentTemplate} from './interfaces/definition'; import {LViewNode} from './interfaces/node'; import {notImplemented} from './util'; @@ -26,7 +26,9 @@ export class ViewRef implements viewEngine_EmbeddedViewRef { onDestroy(callback: Function) { notImplemented(); } markForCheck(): void { notImplemented(); } detach(): void { notImplemented(); } - detectChanges(): void { notImplemented(); } + + detectChanges(): void { detectChanges(this.context); } + checkNoChanges(): void { notImplemented(); } reattach(): void { notImplemented(); } } diff --git a/packages/core/test/bundling/hello_world/bundle.golden_symbols.json b/packages/core/test/bundling/hello_world/bundle.golden_symbols.json index 538b2c74d5..78665fb467 100644 --- a/packages/core/test/bundling/hello_world/bundle.golden_symbols.json +++ b/packages/core/test/bundling/hello_world/bundle.golden_symbols.json @@ -23,6 +23,9 @@ { "name": "__window$1" }, + { + "name": "_getComponentHostLElementNode" + }, { "name": "_renderCompCount" }, @@ -41,9 +44,6 @@ { "name": "currentView" }, - { - "name": "detectChanges" - }, { "name": "domRendererFactory3" }, @@ -80,9 +80,21 @@ { "name": "noop$2" }, + { + "name": "queueContentHooks" + }, + { + "name": "queueDestroyHooks" + }, + { + "name": "queueViewHooks" + }, { "name": "refreshDynamicChildren" }, + { + "name": "renderComponentOrTemplate" + }, { "name": "renderEmbeddedTemplate" }, diff --git a/packages/core/test/render3/change_detection_spec.ts b/packages/core/test/render3/change_detection_spec.ts index 143c476790..259ccf6603 100644 --- a/packages/core/test/render3/change_detection_spec.ts +++ b/packages/core/test/render3/change_detection_spec.ts @@ -6,217 +6,577 @@ * found in the LICENSE file at https://angular.io/license */ -import {ChangeDetectionStrategy, DoCheck} from '../../src/core'; -import {getRenderedText} from '../../src/render3/component'; -import {defineComponent} from '../../src/render3/index'; -import {bind, detectChanges, directiveRefresh, elementEnd, elementProperty, elementStart, interpolation1, interpolation2, listener, text, textBinding} from '../../src/render3/instructions'; -import {containerEl, renderComponent, requestAnimationFrame} from './render_util'; +import {withBody} from '@angular/core/testing'; -describe('OnPush change detection', () => { - let comp: MyComponent; +import {ChangeDetectionStrategy, ChangeDetectorRef, DoCheck, EmbeddedViewRef, TemplateRef, ViewContainerRef} from '../../src/core'; +import {getRenderedText, whenRendered} from '../../src/render3/component'; +import {NgOnChangesFeature, PublicFeature, defineComponent, defineDirective, injectChangeDetectorRef, injectTemplateRef, injectViewContainerRef} from '../../src/render3/index'; +import {bind, container, containerRefreshEnd, containerRefreshStart, detectChanges, directiveRefresh, elementEnd, elementProperty, elementStart, embeddedViewEnd, embeddedViewStart, interpolation1, interpolation2, listener, markDirty, text, textBinding} from '../../src/render3/instructions'; - class MyComponent implements DoCheck { - /* @Input() */ - name = 'Nancy'; - doCheckCount = 0; +import {containerEl, renderComponent, requestAnimationFrame, toHtml} from './render_util'; - ngDoCheck(): void { this.doCheckCount++; } +describe('change detection', () => { - onClick() {} - - static ngComponentDef = defineComponent({ - type: MyComponent, - tag: 'my-comp', - factory: () => comp = new MyComponent(), - /** - * {{ doCheckCount }} - {{ name }} - * - */ - template: (ctx: MyComponent, cm: boolean) => { - if (cm) { - text(0); - elementStart(1, 'button'); - { - listener('click', () => { ctx.onClick(); }); - } - elementEnd(); - } - textBinding(0, interpolation2('', ctx.doCheckCount, ' - ', ctx.name, '')); - }, - changeDetection: ChangeDetectionStrategy.OnPush, - inputs: {name: 'name'} - }); - } - - class MyApp { - name: string = 'Nancy'; - - static ngComponentDef = defineComponent({ - type: MyApp, - tag: 'my-app', - factory: () => new MyApp(), - /** */ - template: (ctx: MyApp, cm: boolean) => { - if (cm) { - elementStart(0, MyComponent); - elementEnd(); - } - elementProperty(0, 'name', bind(ctx.name)); - MyComponent.ngComponentDef.h(1, 0); - directiveRefresh(1, 0); - } - }); - } - - 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); - - detectChanges(myApp); - expect(comp.doCheckCount).toEqual(2); - - detectChanges(myApp); - expect(comp.doCheckCount).toEqual(3); - }); - - it('should skip OnPush components in update mode when they are not dirty', () => { - const myApp = renderComponent(MyApp); - - detectChanges(myApp); - // doCheckCount is 2, but 1 should be rendered since it has not been marked dirty. - expect(getRenderedText(myApp)).toEqual('1 - Nancy'); - - detectChanges(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'; - detectChanges(myApp); - expect(getRenderedText(myApp)).toEqual('2 - Bess'); - - myApp.name = 'George'; - detectChanges(myApp); - expect(getRenderedText(myApp)).toEqual('3 - George'); - - detectChanges(myApp); - expect(getRenderedText(myApp)).toEqual('3 - George'); - }); - - it('should check OnPush components in update mode when component events occur', () => { - const myApp = renderComponent(MyApp); - expect(getRenderedText(myApp)).toEqual('1 - Nancy'); - - const button = containerEl.querySelector('button') !; - button.click(); - requestAnimationFrame.flush(); - expect(getRenderedText(myApp)).toEqual('2 - Nancy'); - - detectChanges(myApp); - expect(getRenderedText(myApp)).toEqual('2 - Nancy'); - }); - - it('should not check OnPush components in update mode when parent events occur', () => { - class ButtonParent { - noop() {} + describe('markDirty, detectChanges, whenRendered, getRenderedText', () => { + class MyComponent implements DoCheck { + value: string = 'works'; + doCheckCount = 0; + ngDoCheck(): void { this.doCheckCount++; } static ngComponentDef = defineComponent({ - type: ButtonParent, - tag: 'button-parent', - factory: () => new ButtonParent(), + type: MyComponent, + tag: 'my-comp', + factory: () => new MyComponent(), + template: (ctx: MyComponent, cm: boolean) => { + if (cm) { + elementStart(0, 'span'); + text(1); + elementEnd(); + } + textBinding(1, bind(ctx.value)); + } + }); + } + + it('should mark a component dirty and schedule change detection', withBody('my-comp', () => { + const myComp = renderComponent(MyComponent); + expect(getRenderedText(myComp)).toEqual('works'); + myComp.value = 'updated'; + markDirty(myComp); + expect(getRenderedText(myComp)).toEqual('works'); + requestAnimationFrame.flush(); + expect(getRenderedText(myComp)).toEqual('updated'); + })); + + it('should detectChanges on a component', withBody('my-comp', () => { + const myComp = renderComponent(MyComponent); + expect(getRenderedText(myComp)).toEqual('works'); + myComp.value = 'updated'; + detectChanges(myComp); + expect(getRenderedText(myComp)).toEqual('updated'); + })); + + it('should detectChanges only once if markDirty is called multiple times', + withBody('my-comp', () => { + const myComp = renderComponent(MyComponent); + expect(getRenderedText(myComp)).toEqual('works'); + expect(myComp.doCheckCount).toBe(1); + myComp.value = 'ignore'; + markDirty(myComp); + myComp.value = 'updated'; + markDirty(myComp); + expect(getRenderedText(myComp)).toEqual('works'); + requestAnimationFrame.flush(); + expect(getRenderedText(myComp)).toEqual('updated'); + expect(myComp.doCheckCount).toBe(2); + })); + + it('should notify whenRendered', withBody('my-comp', async() => { + const myComp = renderComponent(MyComponent); + await whenRendered(myComp); + myComp.value = 'updated'; + markDirty(myComp); + setTimeout(requestAnimationFrame.flush, 0); + await whenRendered(myComp); + expect(getRenderedText(myComp)).toEqual('updated'); + })); + }); + + describe('onPush', () => { + let comp: MyComponent; + + class MyComponent implements DoCheck { + /* @Input() */ + name = 'Nancy'; + doCheckCount = 0; + + ngDoCheck(): void { this.doCheckCount++; } + + onClick() {} + + static ngComponentDef = defineComponent({ + type: MyComponent, + tag: 'my-comp', + factory: () => comp = new MyComponent(), /** - * - * + * {{ doCheckCount }} - {{ name }} + * */ - template: (ctx: ButtonParent, cm: boolean) => { + template: (ctx: MyComponent, cm: boolean) => { + if (cm) { + text(0); + elementStart(1, 'button'); + { + listener('click', () => { ctx.onClick(); }); + } + elementEnd(); + } + textBinding(0, interpolation2('', ctx.doCheckCount, ' - ', ctx.name, '')); + }, + changeDetection: ChangeDetectionStrategy.OnPush, + inputs: {name: 'name'} + }); + } + + class MyApp { + name: string = 'Nancy'; + + static ngComponentDef = defineComponent({ + type: MyApp, + tag: 'my-app', + factory: () => new MyApp(), + /** */ + template: (ctx: MyApp, cm: boolean) => { if (cm) { elementStart(0, MyComponent); elementEnd(); - elementStart(2, 'button', ['id', 'parent']); - { listener('click', () => ctx.noop()); } - elementEnd(); } + elementProperty(0, 'name', bind(ctx.name)); MyComponent.ngComponentDef.h(1, 0); directiveRefresh(1, 0); } }); } - const buttonParent = renderComponent(ButtonParent); - expect(getRenderedText(buttonParent)).toEqual('1 - Nancy'); - const button = containerEl.querySelector('button#parent') !; - (button as HTMLButtonElement).click(); - requestAnimationFrame.flush(); - expect(getRenderedText(buttonParent)).toEqual('1 - Nancy'); + 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); + + detectChanges(myApp); + expect(comp.doCheckCount).toEqual(2); + + detectChanges(myApp); + expect(comp.doCheckCount).toEqual(3); + }); + + it('should skip OnPush components in update mode when they are not dirty', () => { + const myApp = renderComponent(MyApp); + + detectChanges(myApp); + // doCheckCount is 2, but 1 should be rendered since it has not been marked dirty. + expect(getRenderedText(myApp)).toEqual('1 - Nancy'); + + detectChanges(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'; + detectChanges(myApp); + expect(getRenderedText(myApp)).toEqual('2 - Bess'); + + myApp.name = 'George'; + detectChanges(myApp); + expect(getRenderedText(myApp)).toEqual('3 - George'); + + detectChanges(myApp); + expect(getRenderedText(myApp)).toEqual('3 - George'); + }); + + it('should check OnPush components in update mode when component events occur', () => { + const myApp = renderComponent(MyApp); + expect(getRenderedText(myApp)).toEqual('1 - Nancy'); + + const button = containerEl.querySelector('button') !; + button.click(); + requestAnimationFrame.flush(); + expect(getRenderedText(myApp)).toEqual('2 - Nancy'); + + detectChanges(myApp); + expect(getRenderedText(myApp)).toEqual('2 - Nancy'); + }); + + it('should not check OnPush components in update mode when parent events occur', () => { + class ButtonParent { + noop() {} + + static ngComponentDef = defineComponent({ + type: ButtonParent, + tag: 'button-parent', + factory: () => new ButtonParent(), + /** + * + * + */ + template: (ctx: ButtonParent, cm: boolean) => { + if (cm) { + elementStart(0, MyComponent); + elementEnd(); + elementStart(2, 'button', ['id', 'parent']); + { listener('click', () => ctx.noop()); } + elementEnd(); + } + MyComponent.ngComponentDef.h(1, 0); + directiveRefresh(1, 0); + } + }); + } + const buttonParent = renderComponent(ButtonParent); + expect(getRenderedText(buttonParent)).toEqual('1 - Nancy'); + + const button = containerEl.querySelector('button#parent') !; + (button as HTMLButtonElement).click(); + requestAnimationFrame.flush(); + 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, + tag: 'button-parent', + factory: () => parent = new ButtonParent(), + /** {{ doCheckCount }} - */ + template: (ctx: ButtonParent, cm: boolean) => { + if (cm) { + text(0); + elementStart(1, MyComponent); + elementEnd(); + } + textBinding(0, interpolation1('', ctx.doCheckCount, ' - ')); + MyComponent.ngComponentDef.h(2, 1); + directiveRefresh(2, 1); + }, + changeDetection: ChangeDetectionStrategy.OnPush + }); + } + + class MyButtonApp { + static ngComponentDef = defineComponent({ + type: MyButtonApp, + tag: 'my-button-app', + factory: () => new MyButtonApp(), + /** */ + template: (ctx: MyButtonApp, cm: boolean) => { + if (cm) { + elementStart(0, ButtonParent); + elementEnd(); + } + ButtonParent.ngComponentDef.h(1, 0); + directiveRefresh(1, 0); + } + }); + } + + const myButtonApp = renderComponent(MyButtonApp); + expect(parent !.doCheckCount).toEqual(1); + expect(comp !.doCheckCount).toEqual(1); + expect(getRenderedText(myButtonApp)).toEqual('1 - 1 - Nancy'); + + detectChanges(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(); + expect(parent !.doCheckCount).toEqual(3); + expect(comp !.doCheckCount).toEqual(2); + expect(getRenderedText(myButtonApp)).toEqual('3 - 2 - Nancy'); + }); }); - it('should check parent OnPush components in update mode when child events occur', () => { - let parent: ButtonParent; + describe('ChangeDetectorRef', () => { - class ButtonParent implements DoCheck { - doCheckCount = 0; - ngDoCheck(): void { this.doCheckCount++; } + describe('detectChanges()', () => { + let myComp: MyComp; + let dir: Dir; - static ngComponentDef = defineComponent({ - type: ButtonParent, - tag: 'button-parent', - factory: () => parent = new ButtonParent(), - /** {{ doCheckCount }} - */ - template: (ctx: ButtonParent, cm: boolean) => { - if (cm) { - text(0); - elementStart(1, MyComponent); - elementEnd(); + class MyComp { + doCheckCount = 0; + name = 'Nancy'; + + constructor(public cdr: ChangeDetectorRef) {} + + ngDoCheck() { this.doCheckCount++; } + + static ngComponentDef = defineComponent({ + type: MyComp, + tag: 'my-comp', + factory: () => myComp = new MyComp(injectChangeDetectorRef()), + /** {{ name }} */ + template: (ctx: MyComp, cm: boolean) => { + if (cm) { + text(0); + } + textBinding(0, bind(ctx.name)); + }, + changeDetection: ChangeDetectionStrategy.OnPush + }); + } + + class ParentComp { + doCheckCount = 0; + + constructor(public cdr: ChangeDetectorRef) {} + + ngDoCheck() { this.doCheckCount++; } + + static ngComponentDef = defineComponent({ + type: ParentComp, + tag: 'parent-comp', + factory: () => new ParentComp(injectChangeDetectorRef()), + /** + * {{ doCheckCount}} - + * + */ + template: (ctx: ParentComp, cm: boolean) => { + if (cm) { + text(0); + elementStart(1, MyComp); + elementEnd(); + } + textBinding(0, interpolation1('', ctx.doCheckCount, ' - ')); + MyComp.ngComponentDef.h(2, 1); + directiveRefresh(2, 1); } - textBinding(0, interpolation1('', ctx.doCheckCount, ' - ')); - MyComponent.ngComponentDef.h(2, 1); - directiveRefresh(2, 1); - }, - changeDetection: ChangeDetectionStrategy.OnPush + }); + } + + class Dir { + constructor(public cdr: ChangeDetectorRef) {} + + static ngDirectiveDef = + defineDirective({type: Dir, factory: () => dir = new Dir(injectChangeDetectorRef())}); + } + + + it('should check the component view when called by component (even when OnPush && clean)', + () => { + const comp = renderComponent(MyComp); + 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); + 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); }); - } - class MyButtonApp { - static ngComponentDef = defineComponent({ - type: MyButtonApp, - tag: 'my-button-app', - factory: () => new MyButtonApp(), - /** */ - template: (ctx: MyButtonApp, cm: boolean) => { - if (cm) { - elementStart(0, ButtonParent); - elementEnd(); - } - ButtonParent.ngComponentDef.h(1, 0); - directiveRefresh(1, 0); + it('should NOT check the component parent when called by a child component', () => { + const parentComp = renderComponent(ParentComp); + 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); + 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); + 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', () => { + class MyApp { + static ngComponentDef = defineComponent({ + type: MyApp, + tag: 'my-app', + factory: () => new MyApp(), + /** */ + template: (ctx: MyApp, cm: boolean) => { + if (cm) { + elementStart(0, MyComp, ['dir', ''], [Dir]); + elementEnd(); + } + MyComp.ngComponentDef.h(1, 0); + Dir.ngDirectiveDef.h(2, 0); + directiveRefresh(1, 0); + directiveRefresh(2, 0); + } + }); } + + const app = renderComponent(MyApp); + expect(getRenderedText(app)).toEqual('Nancy'); + + myComp.name = 'George'; + dir !.cdr.detectChanges(); + expect(getRenderedText(app)).toEqual('George'); }); - } - const myButtonApp = renderComponent(MyButtonApp); - expect(parent !.doCheckCount).toEqual(1); - expect(comp !.doCheckCount).toEqual(1); - expect(getRenderedText(myButtonApp)).toEqual('1 - 1 - Nancy'); + it('should check host component when called by directive on element node', () => { + class MyApp { + name = 'Frank'; - detectChanges(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'); + static ngComponentDef = defineComponent({ + type: MyApp, + tag: 'my-app', + factory: () => new MyApp(), + /** + * {{ name }} + *
+ */ + template: (ctx: MyApp, cm: boolean) => { + if (cm) { + text(0); + elementStart(1, 'div', ['dir', ''], [Dir]); + elementEnd(); + } + textBinding(1, bind(ctx.name)); + Dir.ngDirectiveDef.h(2, 1); + directiveRefresh(2, 1); + } + }); + } + + const app = renderComponent(MyApp); + expect(getRenderedText(app)).toEqual('Frank'); + + app.name = '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, + tag: 'my-app', + factory: () => new MyApp(injectChangeDetectorRef()), + /** + * {{ name}} + * % if (showing) { + *
+ * % } + */ + template: function(ctx: MyApp, cm: boolean) { + if (cm) { + text(0); + container(1); + } + textBinding(0, bind(ctx.name)); + containerRefreshStart(1); + { + if (ctx.showing) { + if (embeddedViewStart(0)) { + elementStart(0, 'div', ['dir', ''], [Dir]); + elementEnd(); + } + Dir.ngDirectiveDef.h(1, 0); + directiveRefresh(1, 0); + } + embeddedViewEnd(); + } + containerRefreshEnd(); + } + }); + } + + 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, + tag: 'detect-changes-comp', + factory: () => new DetectChangesComp(injectChangeDetectorRef()), + /** {{ value }} */ + template: (ctx: DetectChangesComp, cm: boolean) => { + if (cm) { + text(0); + } + textBinding(0, bind(ctx.value)); + } + }); + } + + const comp = renderComponent(DetectChangesComp); + expect(getRenderedText(comp)).toEqual('1'); + }); + + 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, + tag: 'detect-changes-comp', + factory: () => new DetectChangesComp(injectChangeDetectorRef()), + /** {{ doCheckCount }} */ + template: (ctx: DetectChangesComp, cm: boolean) => { + if (cm) { + text(0); + } + textBinding(0, bind(ctx.doCheckCount)); + } + }); + } + + const comp = renderComponent(DetectChangesComp); + expect(getRenderedText(comp)).toEqual('1'); + }); + + }); - const button = containerEl.querySelector('button'); - button !.click(); - requestAnimationFrame.flush(); - expect(parent !.doCheckCount).toEqual(3); - expect(comp !.doCheckCount).toEqual(2); - expect(getRenderedText(myButtonApp)).toEqual('3 - 2 - Nancy'); }); }); diff --git a/packages/core/test/render3/component_spec.ts b/packages/core/test/render3/component_spec.ts index 011c7357fc..ce1aefe3c2 100644 --- a/packages/core/test/render3/component_spec.ts +++ b/packages/core/test/render3/component_spec.ts @@ -6,10 +6,8 @@ * found in the LICENSE file at https://angular.io/license */ -import {withBody} from '@angular/core/testing'; import {DoCheck, ViewEncapsulation} from '../../src/core'; -import {getRenderedText, whenRendered} from '../../src/render3/component'; import {defineComponent, markDirty} from '../../src/render3/index'; import {bind, container, containerRefreshEnd, containerRefreshStart, detectChanges, directiveRefresh, elementEnd, elementProperty, elementStart, embeddedViewEnd, embeddedViewStart, text, textBinding} from '../../src/render3/instructions'; import {createRendererType2} from '../../src/view/index'; @@ -236,68 +234,4 @@ describe('encapsulation', () => { /
bar<\/span><\/leaf><\/div>/); }); - describe('markDirty, detectChanges, whenRendered, getRenderedText', () => { - class MyComponent implements DoCheck { - value: string = 'works'; - doCheckCount = 0; - ngDoCheck(): void { this.doCheckCount++; } - - static ngComponentDef = defineComponent({ - type: MyComponent, - tag: 'my-comp', - factory: () => new MyComponent(), - template: (ctx: MyComponent, cm: boolean) => { - if (cm) { - elementStart(0, 'span'); - text(1); - elementEnd(); - } - textBinding(1, bind(ctx.value)); - } - }); - } - - it('should mark a component dirty and schedule change detection', withBody('my-comp', () => { - const myComp = renderComponent(MyComponent); - expect(getRenderedText(myComp)).toEqual('works'); - myComp.value = 'updated'; - markDirty(myComp); - expect(getRenderedText(myComp)).toEqual('works'); - requestAnimationFrame.flush(); - expect(getRenderedText(myComp)).toEqual('updated'); - })); - - it('should detectChanges on a component', withBody('my-comp', () => { - const myComp = renderComponent(MyComponent); - expect(getRenderedText(myComp)).toEqual('works'); - myComp.value = 'updated'; - detectChanges(myComp); - expect(getRenderedText(myComp)).toEqual('updated'); - })); - - it('should detectChanges only once if markDirty is called multiple times', - withBody('my-comp', () => { - const myComp = renderComponent(MyComponent); - expect(getRenderedText(myComp)).toEqual('works'); - expect(myComp.doCheckCount).toBe(1); - myComp.value = 'ignore'; - markDirty(myComp); - myComp.value = 'updated'; - markDirty(myComp); - expect(getRenderedText(myComp)).toEqual('works'); - requestAnimationFrame.flush(); - expect(getRenderedText(myComp)).toEqual('updated'); - expect(myComp.doCheckCount).toBe(2); - })); - - it('should notify whenRendered', withBody('my-comp', async() => { - const myComp = renderComponent(MyComponent); - await whenRendered(myComp); - myComp.value = 'updated'; - markDirty(myComp); - setTimeout(requestAnimationFrame.flush, 0); - await whenRendered(myComp); - expect(getRenderedText(myComp)).toEqual('updated'); - })); - }); }); diff --git a/packages/core/test/render3/lifecycle_spec.ts b/packages/core/test/render3/lifecycle_spec.ts index 8c1c0cd5e8..631315fee7 100644 --- a/packages/core/test/render3/lifecycle_spec.ts +++ b/packages/core/test/render3/lifecycle_spec.ts @@ -7,7 +7,7 @@ */ import {SimpleChanges} from '../../src/core'; -import {ComponentTemplate, NgOnChangesFeature, RootLifecycleHooks, defineComponent, defineDirective} from '../../src/render3/index'; +import {ComponentTemplate, LifecycleHooksFeature, NgOnChangesFeature, defineComponent, defineDirective} from '../../src/render3/index'; import {bind, container, containerRefreshEnd, containerRefreshStart, directiveRefresh, elementEnd, elementProperty, elementStart, embeddedViewEnd, embeddedViewStart, listener, markDirty, projection, projectionDef, store, text} from '../../src/render3/instructions'; import {containerEl, renderComponent, renderToHtml, requestAnimationFrame} from './render_util'; @@ -87,7 +87,7 @@ describe('lifecycles', () => { }); it('should be called on root component in creation mode', () => { - const comp = renderComponent(Comp, {features: [RootLifecycleHooks]}); + const comp = renderComponent(Comp, {hostFeatures: [LifecycleHooksFeature]}); expect(events).toEqual(['comp']); markDirty(comp); @@ -423,7 +423,7 @@ describe('lifecycles', () => { }); it('should be called on root component', () => { - const comp = renderComponent(Comp, {features: [RootLifecycleHooks]}); + const comp = renderComponent(Comp, {hostFeatures: [LifecycleHooksFeature]}); expect(events).toEqual(['comp']); markDirty(comp); @@ -583,7 +583,7 @@ describe('lifecycles', () => { }); it('should be called on root component in creation mode', () => { - const comp = renderComponent(Comp, {features: [RootLifecycleHooks]}); + const comp = renderComponent(Comp, {hostFeatures: [LifecycleHooksFeature]}); expect(events).toEqual(['comp']); markDirty(comp); @@ -870,7 +870,7 @@ describe('lifecycles', () => { }); it('should be called on root component', () => { - const comp = renderComponent(Comp, {features: [RootLifecycleHooks]}); + const comp = renderComponent(Comp, {hostFeatures: [LifecycleHooksFeature]}); expect(allEvents).toEqual(['comp init', 'comp check']); markDirty(comp); @@ -985,7 +985,7 @@ describe('lifecycles', () => { }); it('should be called on root component in creation mode', () => { - const comp = renderComponent(Comp, {features: [RootLifecycleHooks]}); + const comp = renderComponent(Comp, {hostFeatures: [LifecycleHooksFeature]}); expect(events).toEqual(['comp']); markDirty(comp); @@ -1296,7 +1296,7 @@ describe('lifecycles', () => { }); it('should be called on root component', () => { - const comp = renderComponent(Comp, {features: [RootLifecycleHooks]}); + const comp = renderComponent(Comp, {hostFeatures: [LifecycleHooksFeature]}); expect(allEvents).toEqual(['comp init', 'comp check']); markDirty(comp); diff --git a/packages/core/test/render3/render_util.ts b/packages/core/test/render3/render_util.ts index 75dd37ffbf..56e82b0d48 100644 --- a/packages/core/test/render3/render_util.ts +++ b/packages/core/test/render3/render_util.ts @@ -8,9 +8,9 @@ import {stringifyElement} from '@angular/platform-browser/testing/src/browser_util'; +import {CreateComponentOptions} from '../../src/render3/component'; import {ComponentTemplate, ComponentType, DirectiveType, PublicFeature, defineComponent, defineDirective, renderComponent as _renderComponent} from '../../src/render3/index'; import {NG_HOST_SYMBOL, createLNode, createLView, renderTemplate} from '../../src/render3/instructions'; -import {CreateComponentOptions} from '../../src/render3/component'; import {DirectiveDefArgs} from '../../src/render3/interfaces/definition'; import {LElementNode, LNodeFlags} from '../../src/render3/interfaces/node'; import {RElement, RText, Renderer3, RendererFactory3, domRendererFactory3} from '../../src/render3/interfaces/renderer'; @@ -118,7 +118,7 @@ export function renderComponent(type: ComponentType, opts?: CreateComponen rendererFactory: opts && opts.rendererFactory || testRendererFactory, host: containerEl, scheduler: requestAnimationFrame, - features: opts && opts.features + hostFeatures: opts && opts.hostFeatures }); } diff --git a/packages/core/test/render3/renderer_factory_spec.ts b/packages/core/test/render3/renderer_factory_spec.ts index 4b6ca626c0..762b094f56 100644 --- a/packages/core/test/render3/renderer_factory_spec.ts +++ b/packages/core/test/render3/renderer_factory_spec.ts @@ -11,7 +11,7 @@ import {MockAnimationDriver, MockAnimationPlayer} from '@angular/animations/brow import {RendererType2, ViewEncapsulation} from '../../src/core'; import {defineComponent, detectChanges} from '../../src/render3/index'; -import {bind, directiveRefresh, elementEnd, elementProperty, elementStart, listener, text} from '../../src/render3/instructions'; +import {bind, directiveRefresh, elementEnd, elementProperty, elementStart, listener, text, tick} from '../../src/render3/instructions'; import {createRendererType2} from '../../src/view/index'; import {getAnimationRendererFactory2, getRendererFactory2} from './imported_renderer2'; @@ -78,7 +78,7 @@ describe('renderer factory lifecycle', () => { expect(logs).toEqual(['create', 'create', 'begin', 'component', 'end']); logs = []; - detectChanges(component); + tick(component); expect(logs).toEqual(['begin', 'component', 'end']); }); @@ -188,7 +188,7 @@ describe('animation renderer factory', () => { .toMatch(/
foo<\/div>/); component.exp = 'on'; - detectChanges(component); + tick(component); const [player] = getLog(); expect(player.keyframes).toEqual([