diff --git a/packages/core/src/core_render3_private_export.ts b/packages/core/src/core_render3_private_export.ts index e437c48bfb1..e726ab5ef8e 100644 --- a/packages/core/src/core_render3_private_export.ts +++ b/packages/core/src/core_render3_private_export.ts @@ -174,6 +174,11 @@ export { ɵɵresolveWindow, ɵɵrestoreView, + ɵɵrepeater, + ɵɵrepeaterCreate, + ɵɵrepeaterTrackByIdentity, + ɵɵrepeaterTrackByIndex, + ɵɵsetComponentScope, ɵɵsetNgModuleScope, ɵɵgetComponentDepsFactory, diff --git a/packages/core/src/render3/index.ts b/packages/core/src/render3/index.ts index 7c40c622d12..18cf2b77a94 100644 --- a/packages/core/src/render3/index.ts +++ b/packages/core/src/render3/index.ts @@ -92,6 +92,11 @@ export { ɵɵreference, + ɵɵrepeater, + ɵɵrepeaterCreate, + ɵɵrepeaterTrackByIdentity, + ɵɵrepeaterTrackByIndex, + ɵɵstyleMap, ɵɵstyleMapInterpolate1, ɵɵstyleMapInterpolate2, diff --git a/packages/core/src/render3/instructions/control_flow.ts b/packages/core/src/render3/instructions/control_flow.ts index b6b00e90a50..7b041208048 100644 --- a/packages/core/src/render3/instructions/control_flow.ts +++ b/packages/core/src/render3/instructions/control_flow.ts @@ -6,15 +6,21 @@ * found in the LICENSE file at https://angular.io/license */ -import {assertLContainer, assertTNode} from '../assert'; +import {DefaultIterableDiffer, IterableChangeRecord, TrackByFunction} from '../../change_detection'; +import {assertDefined} from '../../util/assert'; +import {assertLContainer, assertLView, assertTNode} from '../assert'; import {bindingUpdated} from '../bindings'; -import {LContainer} from '../interfaces/container'; +import {CONTAINER_HEADER_OFFSET, LContainer} from '../interfaces/container'; +import {ComponentTemplate} from '../interfaces/definition'; import {TNode} from '../interfaces/node'; import {CONTEXT, HEADER_OFFSET, LView, TVIEW, TView} from '../interfaces/view'; +import {detachView} from '../node_manipulation'; import {getLView, nextBindingIndex} from '../state'; import {getTNode} from '../util/view_utils'; import {addLViewToLContainer, createAndRenderEmbeddedLView, getLViewFromLContainer, removeLViewFromLContainer} from '../view_manipulation'; +import {ɵɵtemplate} from './template'; + /** * The conditional instruction represents the basic building block on the runtime side to support * built-in "if" and "switch". On the high level this instruction is responsible for adding and @@ -55,6 +61,159 @@ export function ɵɵconditional(containerIndex: number, matchingTemplateIndex } } +export class RepeaterContext { + constructor(private lContainer: LContainer, public $implicit: T, public $index: number) {} + + get $count(): number { + return this.lContainer.length - CONTAINER_HEADER_OFFSET; + } +} + +/** + * A built-in trackBy function used for situations where users specified collection index as a + * tracking expression. Having this function body in the runtime avoids unnecessary code generation. + * + * @param index + * @returns + */ +export function ɵɵrepeaterTrackByIndex(index: number) { + return index; +} + +/** + * A built-in trackBy function used for situations where users specified collection item reference + * as a tracking expression. Having this function body in the runtime avoids unnecessary code + * generation. + * + * @param index + * @returns + */ +export function ɵɵrepeaterTrackByIdentity(_: number, value: T) { + return value; +} + +class RepeaterMetadata { + constructor(public hasEmptyBlock: boolean, public differ: DefaultIterableDiffer) {} +} + +/** + * The repeaterCreate instruction runs in the creation part of the template pass and initializes + * internal data structures required by the update pass of the built-in repeater logic. Repeater + * metadata are allocated in the data part of LView with the following layout: + * - LView[HEADER_OFFSET + index] - metadata + * - LView[HEADER_OFFSET + index + 1] - reference to a template function rendering an item + * - LView[HEADER_OFFSET + index + 2] - optional reference to a template function rendering an empty + * block + * + * @codeGenApi + */ +export function ɵɵrepeaterCreate( + index: number, templateFn: ComponentTemplate, decls: number, vars: number, + trackByFn: TrackByFunction, emptyTemplateFn?: ComponentTemplate, + emptyDecls?: number, emptyVars?: number): void { + const hasEmptyBlock = emptyTemplateFn !== undefined; + const hostLView = getLView(); + const metadata = new RepeaterMetadata(hasEmptyBlock, new DefaultIterableDiffer(trackByFn)); + hostLView[HEADER_OFFSET + index] = metadata; + + ɵɵtemplate(index + 1, templateFn, decls, vars); + + if (hasEmptyBlock) { + ngDevMode && + assertDefined(emptyDecls, 'Missing number of declarations for the empty repeater block.'); + ngDevMode && + assertDefined(emptyVars, 'Missing number of bindings for the empty repeater block.'); + + ɵɵtemplate(index + 2, emptyTemplateFn, emptyDecls!, emptyVars!); + } +} + +/** + * The repeater instruction does update-time diffing of a provided collection (against the + * collection seen previously) and maps changes in the collection to views structure (by adding, + * removing or moving views as needed). + * @param metadataSlotIdx - index in data where we can find an instance of RepeaterMetadata with + * additional information (ex. differ) needed to process collection diffing and view + * manipulation + * @param collection - the collection instance to be checked for changes + * @codeGenApi + */ +export function ɵɵrepeater( + metadataSlotIdx: number, collection: Iterable|undefined|null): void { + const hostLView = getLView(); + const hostTView = hostLView[TVIEW]; + const metadata = hostLView[HEADER_OFFSET + metadataSlotIdx] as RepeaterMetadata; + + const differ = metadata.differ; + const changes = differ.diff(collection); + + // handle repeater changes + if (changes !== null) { + const containerIndex = metadataSlotIdx + 1; + const itemTemplateTNode = getExistingTNode(hostTView, containerIndex); + const lContainer = getLContainer(hostLView, HEADER_OFFSET + containerIndex); + let needsIndexUpdate = false; + changes.forEachOperation( + (item: IterableChangeRecord, adjustedPreviousIndex: number|null, + currentIndex: number|null) => { + if (item.previousIndex === null) { + // add + const newViewIdx = adjustToLastLContainerIndex(lContainer, currentIndex); + const embeddedLView = createAndRenderEmbeddedLView( + hostLView, itemTemplateTNode, + new RepeaterContext(lContainer, item.item, newViewIdx)); + addLViewToLContainer(lContainer, embeddedLView, newViewIdx); + needsIndexUpdate = true; + } else if (currentIndex === null) { + // remove + adjustedPreviousIndex = adjustToLastLContainerIndex(lContainer, adjustedPreviousIndex); + removeLViewFromLContainer(lContainer, adjustedPreviousIndex); + needsIndexUpdate = true; + } else if (adjustedPreviousIndex !== null) { + // move + const existingLView = + detachExistingView>(lContainer, adjustedPreviousIndex); + addLViewToLContainer(lContainer, existingLView, currentIndex); + needsIndexUpdate = true; + } + }); + + // A trackBy function might return the same value even if the underlying item changed - re-bind + // it in the context. + changes.forEachIdentityChange((record: IterableChangeRecord) => { + const viewIdx = adjustToLastLContainerIndex(lContainer, record.currentIndex); + const lView = getExistingLViewFromLContainer>(lContainer, viewIdx); + lView[CONTEXT].$implicit = record.item; + }); + + // moves in the container might caused context's index to get out of order, re-adjust + if (needsIndexUpdate) { + for (let i = 0; i < lContainer.length - CONTAINER_HEADER_OFFSET; i++) { + const lView = getExistingLViewFromLContainer>(lContainer, i); + lView[CONTEXT].$index = i; + } + } + } + + // handle empty blocks + const bindingIndex = nextBindingIndex(); + if (metadata.hasEmptyBlock) { + const hasItemsInCollection = differ.length > 0; + if (bindingUpdated(hostLView, bindingIndex, hasItemsInCollection)) { + const emptyTemplateIndex = metadataSlotIdx + 2; + const lContainer = getLContainer(hostLView, HEADER_OFFSET + emptyTemplateIndex); + if (hasItemsInCollection) { + removeLViewFromLContainer(lContainer, 0); + } else { + const emptyTemplateTNode = getExistingTNode(hostTView, emptyTemplateIndex); + const embeddedLView = + createAndRenderEmbeddedLView(hostLView, emptyTemplateTNode, undefined); + addLViewToLContainer(lContainer, embeddedLView, 0); + } + } + } +} + function getLContainer(lView: LView, index: number): LContainer { const lContainer = lView[index]; ngDevMode && assertLContainer(lContainer); @@ -62,6 +221,24 @@ function getLContainer(lView: LView, index: number): LContainer { return lContainer; } +function adjustToLastLContainerIndex(lContainer: LContainer, index: number|null): number { + return index !== null ? index : lContainer.length - CONTAINER_HEADER_OFFSET; +} + +function detachExistingView(lContainer: LContainer, index: number): LView { + const existingLView = detachView(lContainer, index); + ngDevMode && assertLView(existingLView); + + return existingLView as LView; +} + +function getExistingLViewFromLContainer(lContainer: LContainer, index: number): LView { + const existingLView = getLViewFromLContainer(lContainer, index); + ngDevMode && assertLView(existingLView); + + return existingLView!; +} + function getExistingTNode(tView: TView, index: number): TNode { const tNode = getTNode(tView, index + HEADER_OFFSET); ngDevMode && assertTNode(tNode); diff --git a/packages/core/test/acceptance/control_flow_exploration_spec.ts b/packages/core/test/acceptance/control_flow_exploration_spec.ts index 3dbb0d12792..7a574d78dee 100644 --- a/packages/core/test/acceptance/control_flow_exploration_spec.ts +++ b/packages/core/test/acceptance/control_flow_exploration_spec.ts @@ -8,7 +8,7 @@ import {ɵsetEnabledBlockTypes as setEnabledBlockTypes} from '@angular/compiler/src/jit_compiler_facade'; -import {Component, Pipe, PipeTransform} from '@angular/core'; +import {Component, Pipe, PipeTransform, ɵɵadvance, ɵɵdefineComponent, ɵɵelementEnd, ɵɵelementStart, ɵɵrepeater, ɵɵrepeaterCreate, ɵɵrepeaterTrackByIdentity, ɵɵrepeaterTrackByIndex, ɵɵtext, ɵɵtextInterpolate, ɵɵtextInterpolate2} from '@angular/core'; import {TestBed} from '@angular/core/testing'; describe('control flow', () => { @@ -243,4 +243,242 @@ describe('control flow', () => { expect(fixture.nativeElement.textContent).toBe('default '); }); }); + + describe('for', () => { + it('should create, remove and move views corresponding to items in a collection', () => { + function App_ng_template_0_Template(rf: number, ctx: any) { + if (rf & 1) { + ɵɵtext(0); + } + if (rf & 2) { + const item = ctx.$implicit; + const idx = ctx.$index; + ɵɵtextInterpolate2('', item, '(', idx, ')|'); + } + } + + class TestComponent { + items = [1, 2, 3]; + + static ɵcmp = ɵɵdefineComponent({ + type: TestComponent, + selectors: [['some-cmp']], + decls: 2, + vars: 0, + + // {#for (item of items); track item; let idx = index}{{item}}({{idx}}){/for} + template: + function TestComponent_Template(rf: number, ctx: TestComponent) { + if (rf & 1) { + ɵɵrepeaterCreate(0, App_ng_template_0_Template, 1, 2, ɵɵrepeaterTrackByIdentity); + } + + if (rf & 2) { + ɵɵrepeater(0, ctx.items); + } + }, + encapsulation: 2 + }); + static ɵfac = function TestComponent_Factory(t: any) { + return new (t || TestComponent)(); + }; + } + + const fixture = TestBed.createComponent(TestComponent); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toBe('1(0)|2(1)|3(2)|'); + + fixture.componentInstance.items.pop(); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toBe('1(0)|2(1)|'); + + fixture.componentInstance.items.push(3); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toBe('1(0)|2(1)|3(2)|'); + + fixture.componentInstance.items[0] = 3; + fixture.componentInstance.items[2] = 1; + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toBe('3(0)|2(1)|1(2)|'); + }); + + it('should work correctly with trackBy index', () => { + function App_ng_template_0_Template(rf: number, ctx: any) { + if (rf & 1) { + ɵɵtext(0); + } + if (rf & 2) { + const item = ctx.$implicit; + const idx = ctx.$index; + ɵɵtextInterpolate2('', item, '(', idx, ')|'); + } + } + + class TestComponent { + items = [1, 2, 3]; + + static ɵcmp = ɵɵdefineComponent({ + type: TestComponent, + selectors: [['some-cmp']], + decls: 2, + vars: 0, + + // {#for (item of items); track index; let idx = index}{{item}}|{/for} + template: + function TestComponent_Template(rf: number, ctx: any) { + if (rf & 1) { + ɵɵrepeaterCreate(0, App_ng_template_0_Template, 1, 2, ɵɵrepeaterTrackByIndex); + } + + if (rf & 2) { + ɵɵrepeater(0, ctx.items); + } + }, + encapsulation: 2 + }); + static ɵfac = function TestComponent_Factory(t: any) { + return new (t || TestComponent)(); + }; + } + + const fixture = TestBed.createComponent(TestComponent); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toBe('1(0)|2(1)|3(2)|'); + + fixture.componentInstance.items.pop(); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toBe('1(0)|2(1)|'); + + fixture.componentInstance.items.push(3); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toBe('1(0)|2(1)|3(2)|'); + + fixture.componentInstance.items[0] = 3; + fixture.componentInstance.items[2] = 1; + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toBe('3(0)|2(1)|1(2)|'); + }); + + it('should support empty blocks', () => { + function App_ng_template_0_Template(rf: number) { + if (rf & 1) { + ɵɵtext(0, '|'); + } + } + + function App_ng_template_0_EMPTY(rf: number) { + if (rf & 1) { + ɵɵelementStart(0, 'div'); + ɵɵtext(1); + ɵɵelementEnd(); + } + if (rf & 2) { + ɵɵadvance(1); + ɵɵtextInterpolate('Empty'); + } + } + + class TestComponent { + items: number[]|null|undefined = [1, 2, 3]; + + static ɵcmp = ɵɵdefineComponent({ + type: TestComponent, + selectors: [['some-cmp']], + decls: 3, + vars: 1, + + // {#for (item of items); track index; let idx = index}{{item}}|{/for} + template: + function TestComponent_Template(rf: number, ctx: any) { + if (rf & 1) { + ɵɵrepeaterCreate( + 0, App_ng_template_0_Template, 1, 0, ɵɵrepeaterTrackByIndex, + App_ng_template_0_EMPTY, 2, 1); + } + if (rf & 2) { + ɵɵrepeater(0, ctx.items); + } + }, + encapsulation: 2 + }); + static ɵfac = function TestComponent_Factory(t: any) { + return new (t || TestComponent)(); + }; + } + + const fixture = TestBed.createComponent(TestComponent); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toBe('|||'); + + fixture.componentInstance.items = []; + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toBe('Empty'); + + fixture.componentInstance.items = [0, 1]; + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toBe('||'); + + fixture.componentInstance.items = null; + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toBe('Empty'); + + fixture.componentInstance.items = [0]; + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toBe('|'); + + fixture.componentInstance.items = undefined; + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toBe('Empty'); + }); + + it('should have access to the host context in the track function', () => { + function App_ng_template_0_Template(rf: number, ctx: any) { + if (rf & 1) { + ɵɵtext(0); + } + if (rf & 2) { + ɵɵtextInterpolate(ctx.$implicit); + } + } + + class TestComponent { + offset = 0; + items = ['a', 'b', 'c']; + + static ɵcmp = ɵɵdefineComponent({ + type: TestComponent, + selectors: [['some-cmp']], + decls: 2, + vars: 1, + + // {#for (item of items); track $index + offset}{{item}}{{item}}{/for} + template: + function TestComponent_Template(rf: number, ctx: any) { + if (rf & 1) { + ɵɵrepeaterCreate( + 0, App_ng_template_0_Template, 1, 1, ($index) => $index + ctx.offset); + } + if (rf & 2) { + ɵɵrepeater(0, ctx.items); + } + }, + encapsulation: 2 + }); + static ɵfac = function TestComponent_Factory(t: any) { + return new (t || TestComponent)(); + }; + } + + const fixture = TestBed.createComponent(TestComponent); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toBe('abc'); + + // explicitly modify the DOM text node to make sure that the list reconciliation algorithm + // based on tracking indices overrides it. + fixture.debugElement.childNodes[1].nativeNode.data = 'x'; + fixture.componentInstance.items.shift(); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toBe('bc'); + }); + }); });