refactor(core): built-in control flow - repeaters (#51422)

Draft of the runtime implementation for the built-in repeaters.

PR Close #51422
This commit is contained in:
Pawel Kozlowski 2023-08-01 11:16:29 +02:00 committed by Jessica Janiuk
parent 88fcd27c6d
commit cdcfa09ab3
4 changed files with 428 additions and 3 deletions

View file

@ -174,6 +174,11 @@ export {
ɵɵresolveWindow,
ɵɵrestoreView,
ɵɵrepeater,
ɵɵrepeaterCreate,
ɵɵrepeaterTrackByIdentity,
ɵɵrepeaterTrackByIndex,
ɵɵsetComponentScope,
ɵɵsetNgModuleScope,
ɵɵgetComponentDepsFactory,

View file

@ -92,6 +92,11 @@ export {
ɵɵreference,
ɵɵrepeater,
ɵɵrepeaterCreate,
ɵɵrepeaterTrackByIdentity,
ɵɵrepeaterTrackByIndex,
ɵɵstyleMap,
ɵɵstyleMapInterpolate1,
ɵɵstyleMapInterpolate2,

View file

@ -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<T>(containerIndex: number, matchingTemplateIndex
}
}
export class RepeaterContext<T> {
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<T>(_: number, value: T) {
return value;
}
class RepeaterMetadata {
constructor(public hasEmptyBlock: boolean, public differ: DefaultIterableDiffer<unknown>) {}
}
/**
* 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<unknown>, decls: number, vars: number,
trackByFn: TrackByFunction<unknown>, emptyTemplateFn?: ComponentTemplate<unknown>,
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<unknown>|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<unknown>, 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<RepeaterContext<unknown>>(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<unknown>) => {
const viewIdx = adjustToLastLContainerIndex(lContainer, record.currentIndex);
const lView = getExistingLViewFromLContainer<RepeaterContext<unknown>>(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<RepeaterContext<unknown>>(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<T>(lContainer: LContainer, index: number): LView<T> {
const existingLView = detachView(lContainer, index);
ngDevMode && assertLView(existingLView);
return existingLView as LView<T>;
}
function getExistingLViewFromLContainer<T>(lContainer: LContainer, index: number): LView<T> {
const existingLView = getLViewFromLContainer<T>(lContainer, index);
ngDevMode && assertLView(existingLView);
return existingLView!;
}
function getExistingTNode(tView: TView, index: number): TNode {
const tNode = getTNode(tView, index + HEADER_OFFSET);
ngDevMode && assertTNode(tNode);

View file

@ -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');
});
});
});