From 93675dc797cb9f897c19fe298455dec52b900113 Mon Sep 17 00:00:00 2001 From: Pawel Kozlowski Date: Tue, 1 Aug 2023 11:16:29 +0200 Subject: [PATCH] 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 --- .../core/src/core_render3_private_export.ts | 1 + packages/core/src/render3/index.ts | 2 + packages/core/src/render3/instructions/all.ts | 23 +- .../src/render3/instructions/control_flow.ts | 70 ++++++ .../control_flow_exploration_spec.ts | 204 ++++++++++++++++++ 5 files changed, 289 insertions(+), 11 deletions(-) create mode 100644 packages/core/src/render3/instructions/control_flow.ts create mode 100644 packages/core/test/acceptance/control_flow_exploration_spec.ts diff --git a/packages/core/src/core_render3_private_export.ts b/packages/core/src/core_render3_private_export.ts index 8a708000535..abca2c6e9af 100644 --- a/packages/core/src/core_render3_private_export.ts +++ b/packages/core/src/core_render3_private_export.ts @@ -90,6 +90,7 @@ export { ɵɵclassMapInterpolateV, ɵɵclassProp, ɵɵComponentDeclaration, + ɵɵconditional, ɵɵcontentQuery, ɵɵCopyDefinitionFeature, ɵɵdefineComponent, diff --git a/packages/core/src/render3/index.ts b/packages/core/src/render3/index.ts index 2547079734a..3a2e9bf0421 100644 --- a/packages/core/src/render3/index.ts +++ b/packages/core/src/render3/index.ts @@ -118,6 +118,8 @@ export { ɵɵtemplate, + ɵɵconditional, + ɵɵdefer, ɵɵdeferWhen, ɵɵdeferOnIdle, diff --git a/packages/core/src/render3/instructions/all.ts b/packages/core/src/render3/instructions/all.ts index f71a8c74fc1..1cc47c91381 100644 --- a/packages/core/src/render3/instructions/all.ts +++ b/packages/core/src/render3/instructions/all.ts @@ -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'; diff --git a/packages/core/src/render3/instructions/control_flow.ts b/packages/core/src/render3/instructions/control_flow.ts new file mode 100644 index 00000000000..b6b00e90a50 --- /dev/null +++ b/packages/core/src/render3/instructions/control_flow.ts @@ -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(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(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; +} diff --git a/packages/core/test/acceptance/control_flow_exploration_spec.ts b/packages/core/test/acceptance/control_flow_exploration_spec.ts new file mode 100644 index 00000000000..21bb4de8a4a --- /dev/null +++ b/packages/core/test/acceptance/control_flow_exploration_spec.ts @@ -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'); + }); + }); +});