feat(core): conditional built-in control flow (#51346)

Initial PoC of the built-in control flow support. The goal is to
unblock compiler work.

PR Close #51346
This commit is contained in:
Pawel Kozlowski 2023-08-01 11:16:29 +02:00
parent 6a60a29ff2
commit 93675dc797
5 changed files with 289 additions and 11 deletions

View file

@ -90,6 +90,7 @@ export {
ɵɵclassMapInterpolateV,
ɵɵclassProp,
ɵɵComponentDeclaration,
ɵɵconditional,
ɵɵcontentQuery,
ɵɵCopyDefinitionFeature,
ɵɵdefineComponent,

View file

@ -118,6 +118,8 @@ export {
ɵɵtemplate,
ɵɵconditional,
ɵɵdefer,
ɵɵdeferWhen,
ɵɵdeferOnIdle,

View file

@ -25,30 +25,31 @@
*
* Jira Issue = FW-1184
*/
export * from './advance';
export * from './attribute';
export * from './attribute_interpolation';
export * from './change_detection';
export * from './template';
export * from './storage';
export * from './class_map_interpolation';
export * from './control_flow';
export * from './defer';
export * from './di';
export * from './di_attr';
export * from './element';
export * from './element_container';
export {ɵgetUnknownElementStrictMode, ɵgetUnknownPropertyStrictMode, ɵsetUnknownElementStrictMode, ɵsetUnknownPropertyStrictMode} from './element_validation';
export * from './get_current_view';
export * from './host_property';
export * from './i18n';
export * from './listener';
export * from './namespace';
export * from './next_context';
export * from './projection';
export * from './property';
export * from './property_interpolation';
export * from './advance';
export * from './styling';
export * from './text';
export * from './text_interpolation';
export * from './class_map_interpolation';
export * from './storage';
export * from './style_map_interpolation';
export * from './style_prop_interpolation';
export * from './host_property';
export * from './i18n';
export * from './defer';
export {ɵgetUnknownElementStrictMode, ɵsetUnknownElementStrictMode, ɵgetUnknownPropertyStrictMode, ɵsetUnknownPropertyStrictMode} from './element_validation';
export * from './styling';
export * from './template';
export * from './text';
export * from './text_interpolation';

View file

@ -0,0 +1,70 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import {assertLContainer, assertTNode} from '../assert';
import {bindingUpdated} from '../bindings';
import {LContainer} from '../interfaces/container';
import {TNode} from '../interfaces/node';
import {CONTEXT, HEADER_OFFSET, LView, TVIEW, TView} from '../interfaces/view';
import {getLView, nextBindingIndex} from '../state';
import {getTNode} from '../util/view_utils';
import {addLViewToLContainer, createAndRenderEmbeddedLView, getLViewFromLContainer, removeLViewFromLContainer} from '../view_manipulation';
/**
* 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
* removing views selected by a conditional expression.
*
* @param containerIndex index of a container in a host view (indexed from HEADER_OFFSET) where
* conditional views should be inserted.
* @param matchingTemplateIndex index of a template TNode representing a conditional view to be
* inserted; -1 represents a special case when there is no view to insert.
* @codeGenApi
*/
export function ɵɵconditional<T>(containerIndex: number, matchingTemplateIndex: number, value?: T) {
const hostLView = getLView();
const bindingIndex = nextBindingIndex();
const lContainer = getLContainer(hostLView, HEADER_OFFSET + containerIndex);
const viewInContainerIdx = 0;
if (bindingUpdated(hostLView, bindingIndex, matchingTemplateIndex)) {
// The index of the view to show changed - remove the previously displayed one
// (it is a noop if there are no active views in a container).
removeLViewFromLContainer(lContainer, viewInContainerIdx);
// Index -1 is a special case where none of the conditions evaluates to
// a truthy value and as the consequence we've got no view to show.
if (matchingTemplateIndex !== -1) {
const templateTNode = getExistingTNode(hostLView[TVIEW], matchingTemplateIndex);
const embeddedLView = createAndRenderEmbeddedLView(hostLView, templateTNode, value);
addLViewToLContainer(lContainer, embeddedLView, viewInContainerIdx);
}
} else {
// We might keep displaying the same template but the actual value of the expression could have
// changed - re-bind in context.
const lView = getLViewFromLContainer<T|undefined>(lContainer, viewInContainerIdx);
if (lView !== undefined) {
lView[CONTEXT] = value;
}
}
}
function getLContainer(lView: LView, index: number): LContainer {
const lContainer = lView[index];
ngDevMode && assertLContainer(lContainer);
return lContainer;
}
function getExistingTNode(tView: TView, index: number): TNode {
const tNode = getTNode(tView, index + HEADER_OFFSET);
ngDevMode && assertTNode(tNode);
return tNode;
}

View file

@ -0,0 +1,204 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import {ɵɵconditional, ɵɵdefineComponent, ɵɵtemplate, ɵɵtext, ɵɵtextInterpolate} from '@angular/core';
import {TestBed} from '@angular/core/testing';
describe('control flow', () => {
describe('if', () => {
function App_ng_template_0_Template(rf: number, ctx: unknown) {
if (rf & 1) {
ɵɵtext(0, 'Something');
}
}
function App_ng_template_1_Template(rf: number, ctx: unknown) {
if (rf & 1) {
ɵɵtext(0, 'Nothing');
}
}
it('should add and remove views based on conditions change', () => {
class TestComponent {
show = true;
static ɵcmp = ɵɵdefineComponent({
type: TestComponent,
selectors: [['some-cmp']],
decls: 2,
vars: 1,
template:
function TestComponent_Template(rf: number, ctx: any) {
if (rf & 1) {
ɵɵtemplate(0, App_ng_template_0_Template, 1, 0);
ɵɵtemplate(1, App_ng_template_1_Template, 1, 0);
}
if (rf & 2) {
ɵɵconditional(0, ctx.show ? 0 : 1);
}
},
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('Something');
fixture.componentInstance.show = false;
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toBe('Nothing');
});
it('should expose expression value in context', () => {
function App_ng_template_0_Template(rf: number, ctx: any) {
if (rf & 1) {
ɵɵtext(0, 'Something');
}
if (rf & 2) {
ɵɵtextInterpolate(ctx);
}
}
class TestComponent {
show: any = true;
static ɵcmp = ɵɵdefineComponent({
type: TestComponent,
selectors: [['some-cmp']],
decls: 2,
vars: 1,
template:
function TestComponent_Template(rf: number, ctx: any) {
if (rf & 1) {
ɵɵtemplate(0, App_ng_template_0_Template, 1, 1);
}
if (rf & 2) {
let temp: any;
ɵɵconditional(0, (temp = ctx.show) ? 0 : -1, temp);
}
},
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('true');
fixture.componentInstance.show = 1;
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toBe('1');
});
it('should destroy all views if there is nothing to display', () => {
class TestComponent {
show = true;
static ɵcmp = ɵɵdefineComponent({
type: TestComponent,
selectors: [['some-cmp']],
decls: 1,
vars: 1,
template:
function TestComponent_Template(rf: number, ctx: any) {
if (rf & 1) {
// QUESTION: fundamental mismatch between the "template" and "container" concepts
// those 2 calls to the ɵɵtemplate instruction will generate comment nodes and
// LContainer
ɵɵtemplate(0, App_ng_template_0_Template, 1, 0);
}
if (rf & 2) {
ɵɵconditional(0, ctx.show ? 0 : -1);
}
},
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('Something');
fixture.componentInstance.show = false;
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toBe('');
});
});
describe('switch', () => {
function App_ng_template_0_Template(rf: number, ctx: unknown) {
if (rf & 1) {
ɵɵtext(0, 'case 0');
}
}
function App_ng_template_1_Template(rf: number, ctx: unknown) {
if (rf & 1) {
ɵɵtext(0, 'case 1');
}
}
function App_ng_template_default_Template(rf: number, ctx: unknown) {
if (rf & 1) {
ɵɵtext(0, 'default');
}
}
it('should show a template based on a matching case', () => {
class TestComponent {
case = 0;
static ɵcmp = ɵɵdefineComponent({
type: TestComponent,
selectors: [['some-cmp']],
decls: 3,
vars: 1,
template:
function TestComponent_Template(rf: number, ctx: any) {
if (rf & 1) {
ɵɵtemplate(0, App_ng_template_0_Template, 1, 0);
ɵɵtemplate(1, App_ng_template_1_Template, 1, 0);
ɵɵtemplate(2, App_ng_template_default_Template, 1, 0);
}
if (rf & 2) {
const expValue = ctx.case;
// Open question: == vs. === for comparison
// == is the current Angular implementation
// === is used by JavaScript semantics
ɵɵconditional(0, expValue === 0 ? 0 : expValue === 1 ? 1 : 2);
}
},
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('case 0');
fixture.componentInstance.case = 1;
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toBe('case 1');
fixture.componentInstance.case = 5;
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toBe('default');
});
});
});