From 1fe55e252c5c78b04bdfd7521938913b7d081e69 Mon Sep 17 00:00:00 2001 From: Kara Erickson Date: Thu, 18 Jan 2018 14:18:21 -0800 Subject: [PATCH] feat(ivy): add afterContentInit and afterContentChecked to render3 (#21650) PR Close #21650 --- packages/core/src/render3/instructions.ts | 124 ++++-- packages/core/src/render3/interfaces/view.ts | 22 + packages/core/test/render3/lifecycle_spec.ts | 413 ++++++++++++++++++- 3 files changed, 526 insertions(+), 33 deletions(-) diff --git a/packages/core/src/render3/instructions.ts b/packages/core/src/render3/instructions.ts index f2fd567d08..e5b91cace5 100644 --- a/packages/core/src/render3/instructions.ts +++ b/packages/core/src/render3/instructions.ts @@ -37,8 +37,8 @@ export const enum LifecycleHook { ON_INIT = 1, ON_DESTROY = 2, ON_CHANGES = 4, - AFTER_VIEW_INIT = 8, - AFTER_VIEW_CHECKED = 16 + AFTER_INIT = 8, + AFTER_CHECKED = 16 } /** @@ -129,6 +129,19 @@ let bindingIndex: number; */ let cleanup: any[]|null; +/** + * Array of ngAfterContentInit and ngAfterContentChecked hooks. + * + * These need to be queued so they can be called all at once after init hooks + * and any embedded views are finished processing (to maintain backwards-compatible + * order). + * + * 1st index is: type of hook (afterContentInit or afterContentChecked) + * 2nd index is: method to call + * 3rd index is: context + */ +let contentHooks: any[]|null; + /** Index in the data array at which view hooks begin to be stored. */ let viewHookStartIndex: number|null; @@ -153,6 +166,7 @@ export function enterView(newView: LView, host: LElementNode | LViewNode | null) viewHookStartIndex = newView.viewHookStartIndex; cleanup = newView.cleanup; + contentHooks = newView.contentHooks; renderer = newView.renderer; if (host != null) { @@ -170,6 +184,7 @@ export function enterView(newView: LView, host: LElementNode | LViewNode | null) */ export function leaveView(newView: LView): void { executeViewHooks(); + currentView.contentHooksCalled = false; enterView(newView, null); } @@ -183,6 +198,7 @@ export function createLView( data: [], tView: tView, cleanup: null, + contentHooks: null, renderer: renderer, child: null, tail: null, @@ -193,6 +209,7 @@ export function createLView( template: template, context: context, dynamicViewCount: 0, + contentHooksCalled: false }; return newView; @@ -613,6 +630,32 @@ export function elementEnd() { ngDevMode && assertNodeType(previousOrParentNode, LNodeFlags.Element); const query = previousOrParentNode.query; query && query.addNode(previousOrParentNode); + saveContentHooks(); +} + +/** + * Loops through the directives on a node and queues their afterContentInit and + * afterContentChecked hooks, if they exist. + */ +function saveContentHooks(): void { + // It's necessary to loop through the directives at elementEnd() (rather than storing + // the hooks at creation time) so we can preserve the current hook order. All hooks + // for projected components and directives must be called *before* their hosts. + const flags = previousOrParentNode.flags; + const size = (flags & LNodeFlags.SIZE_MASK) >> LNodeFlags.SIZE_SHIFT; + const start = flags >> LNodeFlags.INDX_SHIFT; + + for (let i = start, end = start + size; i < end; i++) { + const instance = data[i]; + if (instance.ngAfterContentInit != null) { + (contentHooks || (currentView.contentHooks = contentHooks = [])) + .push(LifecycleHook.AFTER_INIT, instance.ngAfterContentInit, instance); + } + if (instance.ngAfterContentChecked != null) { + (contentHooks || (currentView.contentHooks = contentHooks = [])) + .push(LifecycleHook.AFTER_CHECKED, instance.ngAfterContentChecked, instance); + } + } } /** @@ -987,9 +1030,9 @@ function generateInitialInputs( */ export function lifecycle(lifecycle: LifecycleHook.ON_DESTROY, self: any, method: Function): void; export function lifecycle( - lifecycle: LifecycleHook.AFTER_VIEW_INIT, self: any, method: Function): void; + lifecycle: LifecycleHook.AFTER_INIT, self: any, method: Function): void; export function lifecycle( - lifecycle: LifecycleHook.AFTER_VIEW_CHECKED, self: any, method: Function): void; + lifecycle: LifecycleHook.AFTER_CHECKED, self: any, method: Function): void; export function lifecycle(lifecycle: LifecycleHook): boolean; export function lifecycle(lifecycle: LifecycleHook, self?: any, method?: Function): boolean { if (lifecycle === LifecycleHook.ON_INIT) { @@ -997,8 +1040,8 @@ export function lifecycle(lifecycle: LifecycleHook, self?: any, method?: Functio } else if (lifecycle === LifecycleHook.ON_DESTROY) { (cleanup || (currentView.cleanup = cleanup = [])).push(method, self); } else if ( - creationMode && (lifecycle === LifecycleHook.AFTER_VIEW_INIT || - lifecycle === LifecycleHook.AFTER_VIEW_CHECKED)) { + creationMode && (lifecycle === LifecycleHook.AFTER_INIT || + lifecycle === LifecycleHook.AFTER_CHECKED)) { if (viewHookStartIndex == null) { currentView.viewHookStartIndex = viewHookStartIndex = data.length; } @@ -1010,30 +1053,7 @@ export function lifecycle(lifecycle: LifecycleHook, self?: any, method?: Functio /** Iterates over view hook functions and calls them. */ export function executeViewHooks(): void { if (viewHookStartIndex == null) return; - - // Instead of using splice to remove init hooks after their first run (expensive), we - // shift over the AFTER_CHECKED hooks as we call them and truncate once at the end. - let checkIndex = viewHookStartIndex as number; - let writeIndex = checkIndex; - while (checkIndex < data.length) { - // Call lifecycle hook with its context - data[checkIndex + 1].call(data[checkIndex + 2]); - - if (data[checkIndex] === LifecycleHook.AFTER_VIEW_CHECKED) { - // We know if the writeIndex falls behind that there is an init that needs to - // be overwritten. - if (writeIndex < checkIndex) { - data[writeIndex] = data[checkIndex]; - data[writeIndex + 1] = data[checkIndex + 1]; - data[writeIndex + 2] = data[checkIndex + 2]; - } - writeIndex += 3; - } - checkIndex += 3; - } - - // Truncate once at the writeIndex - data.length = writeIndex; + executeHooksAndRemoveInits(data, viewHookStartIndex); } @@ -1245,6 +1265,7 @@ export const componentRefresh: ngDevMode && assertDataInRange(directiveIndex); const hostView = element.data !; ngDevMode && assertNotEqual(hostView, null, 'hostView'); + executeContentHooks(); const directive = data[directiveIndex]; const oldView = enterView(hostView, element); try { @@ -1256,6 +1277,49 @@ export const componentRefresh: } }; +/** + * Calls all afterContentInit and afterContentChecked hooks for the view, then splices + * out afterContentInit hooks to prep for the next run in update mode. + */ +function executeContentHooks(): void { + if (contentHooks == null || currentView.contentHooksCalled) return; + executeHooksAndRemoveInits(contentHooks, 0); + currentView.contentHooksCalled = true; +} + +/** + * Calls lifecycle hooks with their contexts, then splices out any init-only hooks + * to prep for the next run in update mode. + * + * @param arr The array in which the hooks are found + * @param startIndex The index at which to start calling hooks + */ +function executeHooksAndRemoveInits(arr: any[], startIndex: number): void { + // Instead of using splice to remove init hooks after their first run (expensive), we + // shift over the AFTER_CHECKED hooks as we call them and truncate once at the end. + let checkIndex = startIndex; + let writeIndex = startIndex; + while (checkIndex < arr.length) { + // Call lifecycle hook with its context + arr[checkIndex + 1].call(arr[checkIndex + 2]); + + if (arr[checkIndex] === LifecycleHook.AFTER_CHECKED) { + // We know if the writeIndex falls behind that there is an init that needs to + // be overwritten. + if (writeIndex < checkIndex) { + arr[writeIndex] = arr[checkIndex]; + arr[writeIndex + 1] = arr[checkIndex + 1]; + arr[writeIndex + 2] = arr[checkIndex + 2]; + } + writeIndex += 3; + } + checkIndex += 3; + } + + // Truncate once at the writeIndex + arr.length = writeIndex; +} + /** * Instruction to distribute projectable nodes among occurrences in a given template. * It takes all the selectors from the entire component's template and decides where diff --git a/packages/core/src/render3/interfaces/view.ts b/packages/core/src/render3/interfaces/view.ts index 2cf567e487..f6137e1aef 100644 --- a/packages/core/src/render3/interfaces/view.ts +++ b/packages/core/src/render3/interfaces/view.ts @@ -90,6 +90,28 @@ export interface LView { */ cleanup: any[]|null; + /** + * Array of ngAfterContentInit and ngAfterContentChecked hooks. + * + * These need to be queued so they can be called all at once after init hooks + * and any embedded views are finished processing (to maintain backwards-compatible + * order). + * + * 1st index is: type of hook (afterContentInit or afterContentChecked) + * 2nd index is: method to call + * 3rd index is: context + */ + contentHooks: any[]|null; + + /** + * Whether or not the content hooks have been called in this change detection run. + * + * Content hooks are executed by the first Comp.r() instruction that runs (to avoid + * adding to the code size), so it needs to be able to check whether or not they should + * be called. + */ + contentHooksCalled: boolean; + /** * The first LView or LContainer beneath this LView in the hierarchy. * diff --git a/packages/core/test/render3/lifecycle_spec.ts b/packages/core/test/render3/lifecycle_spec.ts index 25b951bee5..e518f53e6e 100644 --- a/packages/core/test/render3/lifecycle_spec.ts +++ b/packages/core/test/render3/lifecycle_spec.ts @@ -409,7 +409,414 @@ describe('lifecycles', () => { }); - describe('ngAfterViewInit', () => { + describe('afterContentInit', () => { + let events: string[]; + let allEvents: string[]; + + beforeEach(() => { + events = []; + allEvents = []; + }); + + let Comp = createAfterContentInitComp('comp', function(ctx: any, cm: boolean) { + if (cm) { + m(0, pD()); + P(1, 0); + } + }); + + let Parent = createAfterContentInitComp('parent', function(ctx: any, cm: boolean) { + if (cm) { + m(0, pD()); + E(1, Comp); + { + P(3, 0); + } + e(); + } + p(1, 'val', b(ctx.val)); + Comp.ngComponentDef.h(2, 1); + Comp.ngComponentDef.r(2, 1); + }); + + let ProjectedComp = createAfterContentInitComp('projected', (ctx: any, cm: boolean) => { + if (cm) { + m(0, pD()); + P(1, 0); + } + }); + + function createAfterContentInitComp(name: string, template: ComponentTemplate) { + return class Component { + val: string = ''; + ngAfterContentInit() { + events.push(`${name}${this.val}`); + allEvents.push(`${name}${this.val} init`); + } + ngAfterContentChecked() { allEvents.push(`${name}${this.val} check`); } + + static ngComponentDef = defineComponent({ + tag: name, + factory: () => new Component(), + inputs: {val: 'val'}, + template: template + }); + }; + } + + it('should be called only in creation mode', () => { + /** content */ + function Template(ctx: any, cm: boolean) { + if (cm) { + E(0, Comp); + { + T(2, 'content'); + } + e(); + } + Comp.ngComponentDef.h(1, 0); + Comp.ngComponentDef.r(1, 0); + } + + renderToHtml(Template, {}); + expect(events).toEqual(['comp']); + + renderToHtml(Template, {}); + expect(events).toEqual(['comp']); + }); + + it('should be called on every init (if blocks)', () => { + /** + * % if (condition) { + * content + * % } + */ + function Template(ctx: any, cm: boolean) { + if (cm) { + C(0); + } + cR(0); { + if (ctx.condition) { + if (V(0)) { + E(0, Comp); + { + T(2, 'content'); + } + e(); + } + Comp.ngComponentDef.h(1, 0); + Comp.ngComponentDef.r(1, 0); + v(); + } + } cr(); + } + + renderToHtml(Template, {condition: true}); + expect(events).toEqual(['comp']); + + renderToHtml(Template, {condition: false}); + expect(events).toEqual(['comp']); + + renderToHtml(Template, {condition: true}); + expect(events).toEqual(['comp', 'comp']); + }); + + it('should be called on directives after component', () => { + class Directive { + ngAfterContentInit() { + events.push('dir'); + } + + static ngDirectiveDef = defineDirective({ + factory: () => new Directive() + }); + } + + function Template(ctx: any, cm: boolean) { + if (cm) { + E(0, Comp, null, [Directive]); + e(); + } + Comp.ngComponentDef.h(1, 0); + Comp.ngComponentDef.r(1, 0); + } + + renderToHtml(Template, {}); + expect(events).toEqual(['comp', 'dir']); + + renderToHtml(Template, {}); + expect(events).toEqual(['comp', 'dir']); + + }); + + it('should be called in parents before children', () => { + /** + * content + * + * parent template: + */ + function Template(ctx: any, cm: boolean) { + if (cm) { + E(0, Parent); + { + T(2, 'content'); + } + e(); + } + Parent.ngComponentDef.h(1, 0); + Parent.ngComponentDef.r(1, 0); + } + + renderToHtml(Template, {}); + expect(events).toEqual(['parent', 'comp']); + }); + + it('should be called breadth-first in entire parent subtree before any children', () => { + /** + * content + * content + * + * parent template: + */ + function Template(ctx: any, cm: boolean) { + if (cm) { + E(0, Parent); + { + T(2, 'content'); + } + e(); + E(3, Parent); + { + T(5, 'content'); + } + e(); + } + p(0, 'val', 1); + p(3, 'val', 2); + Parent.ngComponentDef.h(1, 0); + Parent.ngComponentDef.h(4, 3); + Parent.ngComponentDef.r(1, 0); + Parent.ngComponentDef.r(4, 3); + } + + renderToHtml(Template, {}); + expect(events).toEqual(['parent1', 'parent2', 'comp1', 'comp2']); + }); + + it('should be called in projected components before their hosts', () => { + /** + * + * content + * + * + * parent template: + * + * + * projected comp: + */ + function Template(ctx: any, cm: boolean) { + if (cm) { + E(0, Parent); + { + E(2, ProjectedComp); + { + T(4, 'content'); + } + e(); + } + e(); + } + Parent.ngComponentDef.h(1, 0); + ProjectedComp.ngComponentDef.h(3, 2); + ProjectedComp.ngComponentDef.r(3,2); + Parent.ngComponentDef.r(1, 0); + } + + renderToHtml(Template, {}); + expect(events).toEqual(['projected', 'parent', 'comp']); + }); + + it('should be called in projected components and hosts before children', () => { + /** + * + * content + * + * * + * content + * + * + * parent template: + * + * + * projected comp: + */ + function Template(ctx: any, cm: boolean) { + if (cm) { + E(0, Parent); + { + E(2, ProjectedComp); + { + T(4, 'content'); + } + e(); + } + e(); + E(5, Parent); + { + E(7, ProjectedComp); + { + T(9, 'content'); + } + e(); + } + e(); + } + p(0, 'val', 1); + p(2, 'val', 1); + p(5, 'val', 2); + p(7, 'val', 2); + Parent.ngComponentDef.h(1, 0); + ProjectedComp.ngComponentDef.h(3, 2); + Parent.ngComponentDef.h(6, 5); + ProjectedComp.ngComponentDef.h(8, 7); + ProjectedComp.ngComponentDef.r(3, 2); + Parent.ngComponentDef.r(1, 0); + ProjectedComp.ngComponentDef.r(8, 7); + Parent.ngComponentDef.r(6, 5); + } + + renderToHtml(Template, {}); + expect(events).toEqual(['projected1', 'parent1', 'projected2', 'parent2', 'comp1', 'comp2']); + }); + + it('should be called in correct order in a for loop', () => { + /** + * content + * % for(let i = 2; i < 4; i++) { + * content + * % } + * content + */ + function Template(ctx: any, cm: boolean) { + if (cm) { + E(0, Comp); + { + T(2, 'content'); + } + e(); + C(3); + E(4, Comp); + { + T(6, 'content'); + } + e(); + } + p(0, 'val', 1); + p(4, 'val', 4); + Comp.ngComponentDef.h(1, 0); + Comp.ngComponentDef.h(5, 4); + cR(3); { + for (let i = 2; i < 4; i++) { + if (V(0)) { + E(0, Comp); + { + T(2, 'content'); + } + e(); + } + p(0, 'val', i); + Comp.ngComponentDef.h(1, 0); + Comp.ngComponentDef.r(1, 0); + v(); + } + } cr(); + Comp.ngComponentDef.r(1, 0); + Comp.ngComponentDef.r(5, 4); + } + + renderToHtml(Template, {}); + expect(events).toEqual(['comp2', 'comp3', 'comp1', 'comp4']); + }); + + function ForLoopWithChildrenTemplate(ctx: any, cm: boolean) { + if (cm) { + E(0, Parent); + { + T(2, 'content'); + } + e(); + C(3); + E(4, Parent); + { + T(6, 'content'); + } + e(); + } + p(0, 'val', 1); + p(4, 'val', 4); + Parent.ngComponentDef.h(1, 0); + Parent.ngComponentDef.h(5, 4); + cR(3); { + for (let i = 2; i < 4; i++) { + if (V(0)) { + E(0, Parent); + { + T(2, 'content'); + } + e(); + } + p(0, 'val', i); + Parent.ngComponentDef.h(1, 0); + Parent.ngComponentDef.r(1, 0); + v(); + } + } cr(); + Parent.ngComponentDef.r(1, 0); + Parent.ngComponentDef.r(5, 4); + } + + it('should be called in correct order in a for loop with children', () =>{ + /** + * content + * % for(let i = 2; i < 4; i++) { + * content + * % } + * content + */ + + renderToHtml(ForLoopWithChildrenTemplate, {}); + expect(events) + .toEqual(['parent2', 'comp2', 'parent3', 'comp3', 'parent1', 'parent4', 'comp1', 'comp4']); + }); + + describe('ngAfterContentChecked', () => { + + it('should be called every change detection run after afterContentInit', () => { + /** content */ + function Template(ctx: any, cm: boolean) { + if (cm) { + E(0, Comp); + { + T(2, 'content'); + } + e(); + } + Comp.ngComponentDef.h(1, 0); + Comp.ngComponentDef.r(1, 0); + } + + renderToHtml(Template, {}); + expect(allEvents).toEqual(['comp init', 'comp check']); + + renderToHtml(Template, {}); + expect(allEvents).toEqual(['comp init', 'comp check', 'comp check']); + + }); + + }); + }); + + describe('afterViewInit', () => { let events: string[]; let allEvents: string[]; @@ -450,8 +857,8 @@ describe('lifecycles', () => { refresh: (directiveIndex: number, elementIndex: number) => { r(directiveIndex, elementIndex, template); const comp = m(directiveIndex) as Component; - l(LifecycleHook.AFTER_VIEW_INIT, comp, comp.ngAfterViewInit); - l(LifecycleHook.AFTER_VIEW_CHECKED, comp, comp.ngAfterViewChecked); + l(LifecycleHook.AFTER_INIT, comp, comp.ngAfterViewInit); + l(LifecycleHook.AFTER_CHECKED, comp, comp.ngAfterViewChecked); }, inputs: {val: 'val'}, template: template