diff --git a/packages/core/src/render3/assert.ts b/packages/core/src/render3/assert.ts index 2c14b2be03..aecb0e1fd2 100644 --- a/packages/core/src/render3/assert.ts +++ b/packages/core/src/render3/assert.ts @@ -7,6 +7,7 @@ */ import {assertDefined, assertEqual, assertNumber, throwError} from '../util/assert'; + import {getComponentDef, getNgModuleDef} from './definition'; import {LContainer} from './interfaces/container'; import {DirectiveDef} from './interfaces/definition'; @@ -14,8 +15,7 @@ import {TIcu} from './interfaces/i18n'; import {NodeInjectorOffset} from './interfaces/injector'; import {TNode} from './interfaces/node'; import {isLContainer, isLView} from './interfaces/type_checks'; -import {HEADER_OFFSET, LView, TVIEW, TView} from './interfaces/view'; - +import {DECLARATION_COMPONENT_VIEW, HEADER_OFFSET, LView, T_HOST, TVIEW, TView} from './interfaces/view'; // [Assert functions do not constraint type when they are guarded by a truthy // expression.](https://github.com/microsoft/TypeScript/issues/37295) @@ -135,6 +135,20 @@ export function assertBetween(lower: number, upper: number, index: number) { } } +export function assertProjectionSlots(lView: LView, errMessage?: string) { + assertDefined(lView[DECLARATION_COMPONENT_VIEW], 'Component views should exist.'); + assertDefined( + lView[DECLARATION_COMPONENT_VIEW][T_HOST]!.projection, + errMessage || + 'Components with projection nodes () must have projection slots defined.'); +} + +export function assertParentView(lView: LView|null, errMessage?: string) { + assertDefined( + lView, + errMessage || 'Component views should always have a parent view (component\'s host view)'); +} + /** * This is a basic sanity check that the `injectorIndex` seems to point to what looks like a diff --git a/packages/core/src/render3/collect_native_nodes.ts b/packages/core/src/render3/collect_native_nodes.ts index 9786b43e27..8b5881232a 100644 --- a/packages/core/src/render3/collect_native_nodes.ts +++ b/packages/core/src/render3/collect_native_nodes.ts @@ -6,15 +6,15 @@ * found in the LICENSE file at https://angular.io/license */ -import {assertDefined} from '../util/assert'; - +import {assertParentView} from './assert'; import {icuContainerIterate} from './i18n/i18n_tree_shaking'; import {CONTAINER_HEADER_OFFSET} from './interfaces/container'; -import {TElementNode, TIcuContainerNode, TNode, TNodeType} from './interfaces/node'; +import {TIcuContainerNode, TNode, TNodeType} from './interfaces/node'; import {RNode} from './interfaces/renderer_dom'; import {isLContainer} from './interfaces/type_checks'; import {DECLARATION_COMPONENT_VIEW, LView, T_HOST, TVIEW, TView} from './interfaces/view'; import {assertTNodeType} from './node_assert'; +import {getProjectionNodes} from './node_manipulation'; import {getLViewParent} from './util/view_traversal_utils'; import {unwrapRNode} from './util/view_utils'; @@ -58,23 +58,12 @@ export function collectNativeNodes( result.push(rNode); } } else if (tNodeType & TNodeType.Projection) { - const componentView = lView[DECLARATION_COMPONENT_VIEW]; - const componentHost = componentView[T_HOST] as TElementNode; - const slotIdx = tNode.projection as number; - ngDevMode && - assertDefined( - componentHost.projection, - 'Components with projection nodes () must have projection slots defined.'); - - const nodesInSlot = componentHost.projection![slotIdx]; + const nodesInSlot = getProjectionNodes(lView, tNode); if (Array.isArray(nodesInSlot)) { result.push(...nodesInSlot); } else { - const parentView = getLViewParent(componentView)!; - ngDevMode && - assertDefined( - parentView, - 'Component views should always have a parent view (component\'s host view)'); + const parentView = getLViewParent(lView[DECLARATION_COMPONENT_VIEW])!; + ngDevMode && assertParentView(parentView); collectNativeNodes(parentView[TVIEW], parentView, nodesInSlot, result, true); } } diff --git a/packages/core/src/render3/node_manipulation.ts b/packages/core/src/render3/node_manipulation.ts index 5178445c47..801957fe21 100644 --- a/packages/core/src/render3/node_manipulation.ts +++ b/packages/core/src/render3/node_manipulation.ts @@ -12,7 +12,8 @@ import {RendererStyleFlags2} from '../render/api_flags'; import {addToArray, removeFromArray} from '../util/array_utils'; import {assertDefined, assertDomNode, assertEqual, assertFunction, assertString} from '../util/assert'; import {escapeCommentText} from '../util/dom'; -import {assertLContainer, assertLView, assertTNodeForLView} from './assert'; + +import {assertLContainer, assertLView, assertParentView, assertProjectionSlots, assertTNodeForLView} from './assert'; import {attachPatchData} from './context_discovery'; import {icuContainerIterate} from './i18n/i18n_tree_shaking'; import {CONTAINER_HEADER_OFFSET, HAS_TRANSPLANTED_VIEWS, LContainer, MOVED_VIEWS, NATIVE, unusedValueExportToPlacateAjd as unused1} from './interfaces/container'; @@ -772,14 +773,14 @@ function getFirstNativeNode(lView: LView, tNode: TNode|null): RNode|null { // If the ICU container has no nodes, than we use the ICU anchor as the node. return rNode || unwrapRNode(lView[tNode.index]); } else { - const componentView = lView[DECLARATION_COMPONENT_VIEW]; - const componentHost = componentView[T_HOST] as TElementNode; - const parentView = getLViewParent(componentView); - const firstProjectedTNode: TNode|null = - (componentHost.projection as (TNode | null)[])[tNode.projection as number]; - - if (firstProjectedTNode != null) { - return getFirstNativeNode(parentView!, firstProjectedTNode); + const projectionNodes = getProjectionNodes(lView, tNode); + if (projectionNodes !== null) { + if (Array.isArray(projectionNodes)) { + return projectionNodes[0]; + } + const parentView = getLViewParent(lView[DECLARATION_COMPONENT_VIEW]); + ngDevMode && assertParentView(parentView); + return getFirstNativeNode(parentView!, projectionNodes); } else { return getFirstNativeNode(lView, tNode.next); } @@ -789,6 +790,17 @@ function getFirstNativeNode(lView: LView, tNode: TNode|null): RNode|null { return null; } +export function getProjectionNodes(lView: LView, tNode: TNode|null): TNode|RNode[]|null { + if (tNode !== null) { + const componentView = lView[DECLARATION_COMPONENT_VIEW]; + const componentHost = componentView[T_HOST] as TElementNode; + const slotIdx = tNode.projection as number; + ngDevMode && assertProjectionSlots(lView); + return componentHost.projection![slotIdx]; + } + return null; +} + export function getBeforeNodeForView(viewIndexInContainer: number, lContainer: LContainer): RNode| null { const nextViewIndex = CONTAINER_HEADER_OFFSET + viewIndexInContainer + 1; diff --git a/packages/core/test/bundling/forms/bundle.golden_symbols.json b/packages/core/test/bundling/forms/bundle.golden_symbols.json index 8ef0dcdff9..837fffe2b4 100644 --- a/packages/core/test/bundling/forms/bundle.golden_symbols.json +++ b/packages/core/test/bundling/forms/bundle.golden_symbols.json @@ -1088,6 +1088,9 @@ { "name": "getPreviousIndex" }, + { + "name": "getProjectionNodes" + }, { "name": "getPromiseCtor" }, diff --git a/packages/core/test/bundling/router/bundle.golden_symbols.json b/packages/core/test/bundling/router/bundle.golden_symbols.json index d7ca828b94..bbd0f52dbc 100644 --- a/packages/core/test/bundling/router/bundle.golden_symbols.json +++ b/packages/core/test/bundling/router/bundle.golden_symbols.json @@ -1403,6 +1403,9 @@ { "name": "getPreviousIndex" }, + { + "name": "getProjectionNodes" + }, { "name": "getPromiseCtor" }, diff --git a/packages/core/test/bundling/todo/bundle.golden_symbols.json b/packages/core/test/bundling/todo/bundle.golden_symbols.json index 86bd5b20cf..76de4a7718 100644 --- a/packages/core/test/bundling/todo/bundle.golden_symbols.json +++ b/packages/core/test/bundling/todo/bundle.golden_symbols.json @@ -437,6 +437,9 @@ { "name": "getPreviousIndex" }, + { + "name": "getProjectionNodes" + }, { "name": "getSelectedIndex" }, diff --git a/packages/core/test/linker/integration_spec.ts b/packages/core/test/linker/integration_spec.ts index 46fd11e60f..69e969a79a 100644 --- a/packages/core/test/linker/integration_spec.ts +++ b/packages/core/test/linker/integration_spec.ts @@ -7,7 +7,7 @@ */ import {CommonModule, DOCUMENT, ɵgetDOM as getDOM} from '@angular/common'; -import {Compiler, ComponentFactory, ComponentRef, ErrorHandler, EventEmitter, Host, Inject, Injectable, InjectionToken, Injector, NgModule, NgModuleRef, NO_ERRORS_SCHEMA, OnDestroy, SkipSelf, ViewRef, ɵivyEnabled as ivyEnabled} from '@angular/core'; +import {Compiler, ComponentFactory, ComponentRef, ErrorHandler, EventEmitter, Host, Inject, Injectable, InjectionToken, Injector, NgModule, NgModuleRef, NO_ERRORS_SCHEMA, OnDestroy, SkipSelf, ViewChild, ViewRef, ɵivyEnabled as ivyEnabled} from '@angular/core'; import {ChangeDetectionStrategy, ChangeDetectorRef, PipeTransform} from '@angular/core/src/change_detection/change_detection'; import {getDebugContext} from '@angular/core/src/errors'; import {ComponentFactoryResolver} from '@angular/core/src/linker/component_factory_resolver'; @@ -1641,6 +1641,159 @@ function declareTests(config?: {useJit: boolean}) { expect(fixture.nativeElement).toHaveText(''); }); + + describe('moving embedded views of projectable nodes in a dynamic component', () => { + @Component({selector: 'menu-item', template: ''}) + class DynamicMenuItem { + @ViewChild('templateRef', {static: true}) templateRef!: TemplateRef; + itemContent!: string; + } + + @NgModule({ + declarations: [DynamicMenuItem], + entryComponents: [DynamicMenuItem], + }) + class DynamicMenuItemModule { + } + + @Component({selector: 'test', template: ``}) + class TestCmp { + constructor(public cfr: ComponentFactoryResolver) {} + @ViewChild('menuItemsContainer', {static: true, read: ViewContainerRef}) + menuItemsContainer!: ViewContainerRef; + } + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [TestCmp], + imports: [DynamicMenuItemModule], + }); + }); + + const createElWithContent = (content: string, tagName = 'span') => { + const element = document.createElement(tagName); + element.textContent = content; + return element; + }; + + it('should support moving embedded views of projectable nodes', () => { + TestBed.overrideTemplate( + DynamicMenuItem, ``); + + const fixture = TestBed.createComponent(TestCmp); + const menuItemsContainer = fixture.componentInstance.menuItemsContainer; + const dynamicCmptFactory = + fixture.componentInstance.cfr.resolveComponentFactory(DynamicMenuItem); + + const cmptRefWithAa = + dynamicCmptFactory.create(Injector.NULL, [[createElWithContent('Aa')]]); + const cmptRefWithBb = + dynamicCmptFactory.create(Injector.NULL, [[createElWithContent('Bb')]]); + const cmptRefWithCc = + dynamicCmptFactory.create(Injector.NULL, [[createElWithContent('Cc')]]); + + menuItemsContainer.insert(cmptRefWithAa.instance.templateRef.createEmbeddedView({})); + menuItemsContainer.insert(cmptRefWithBb.instance.templateRef.createEmbeddedView({})); + menuItemsContainer.insert(cmptRefWithCc.instance.templateRef.createEmbeddedView({})); + + menuItemsContainer.move(menuItemsContainer.get(0)!, 1); + expect(fixture.nativeElement.textContent).toBe('BbAaCc'); + menuItemsContainer.move(menuItemsContainer.get(2)!, 1); + expect(fixture.nativeElement.textContent).toBe('BbCcAa'); + }); + + it('should support moving embedded views of projectable nodes in multiple slots', () => { + TestBed.overrideTemplate( + DynamicMenuItem, + ``); + + const fixture = TestBed.createComponent(TestCmp); + const menuItemsContainer = fixture.componentInstance.menuItemsContainer; + const dynamicCmptFactory = + fixture.componentInstance.cfr.resolveComponentFactory(DynamicMenuItem); + + const cmptRefWithAa = dynamicCmptFactory.create( + Injector.NULL, [[createElWithContent('A')], [createElWithContent('a', 'button')]]); + const cmptRefWithBb = dynamicCmptFactory.create( + Injector.NULL, [[createElWithContent('B')], [createElWithContent('b', 'button')]]); + const cmptRefWithCc = dynamicCmptFactory.create( + Injector.NULL, [[createElWithContent('C')], [createElWithContent('c', 'button')]]); + + menuItemsContainer.insert(cmptRefWithAa.instance.templateRef.createEmbeddedView({})); + menuItemsContainer.insert(cmptRefWithBb.instance.templateRef.createEmbeddedView({})); + menuItemsContainer.insert(cmptRefWithCc.instance.templateRef.createEmbeddedView({})); + + menuItemsContainer.move(menuItemsContainer.get(0)!, 1); + expect(fixture.nativeElement.textContent).toBe('BbAaCc'); + menuItemsContainer.move(menuItemsContainer.get(2)!, 1); + expect(fixture.nativeElement.textContent).toBe('BbCcAa'); + }); + + it('should support moving embedded views of projectable nodes in multiple slots and interpolations', + () => { + TestBed.overrideTemplate( + DynamicMenuItem, + `{{itemContent}}`); + + TestBed.configureTestingModule( + {declarations: [TestCmp], imports: [DynamicMenuItemModule]}); + + const fixture = TestBed.createComponent(TestCmp); + const menuItemsContainer = fixture.componentInstance.menuItemsContainer; + const dynamicCmptFactory = + fixture.componentInstance.cfr.resolveComponentFactory(DynamicMenuItem); + + const cmptRefWithAa = dynamicCmptFactory.create( + Injector.NULL, [[createElWithContent('A')], [createElWithContent('a', 'button')]]); + const cmptRefWithBb = dynamicCmptFactory.create( + Injector.NULL, [[createElWithContent('B')], [createElWithContent('b', 'button')]]); + const cmptRefWithCc = dynamicCmptFactory.create( + Injector.NULL, [[createElWithContent('C')], [createElWithContent('c', 'button')]]); + + menuItemsContainer.insert(cmptRefWithAa.instance.templateRef.createEmbeddedView({})); + menuItemsContainer.insert(cmptRefWithBb.instance.templateRef.createEmbeddedView({})); + menuItemsContainer.insert(cmptRefWithCc.instance.templateRef.createEmbeddedView({})); + + cmptRefWithAa.instance.itemContent = '0'; + cmptRefWithBb.instance.itemContent = '1'; + cmptRefWithCc.instance.itemContent = '2'; + + fixture.detectChanges(); + + menuItemsContainer.move(menuItemsContainer.get(0)!, 1); + expect(fixture.nativeElement.textContent).toBe('B1bA0aC2c'); + menuItemsContainer.move(menuItemsContainer.get(2)!, 1); + expect(fixture.nativeElement.textContent).toBe('B1bC2cA0a'); + }); + + it('should support moving embedded views with empty projectable slots', () => { + TestBed.overrideTemplate( + DynamicMenuItem, ``); + + const fixture = TestBed.createComponent(TestCmp); + const menuItemsContainer = fixture.componentInstance.menuItemsContainer; + const dynamicCmptFactory = + fixture.componentInstance.cfr.resolveComponentFactory(DynamicMenuItem); + + const cmptRefWithAa = dynamicCmptFactory.create(Injector.NULL, [[]]); + const cmptRefWithBb = + dynamicCmptFactory.create(Injector.NULL, [[createElWithContent('Bb')]]); + const cmptRefWithCc = + dynamicCmptFactory.create(Injector.NULL, [[createElWithContent('Cc')]]); + + menuItemsContainer.insert(cmptRefWithAa.instance.templateRef.createEmbeddedView({})); + menuItemsContainer.insert(cmptRefWithBb.instance.templateRef.createEmbeddedView({})); + menuItemsContainer.insert(cmptRefWithCc.instance.templateRef.createEmbeddedView({})); + + menuItemsContainer.move(menuItemsContainer.get(0)!, 1); // [ Bb, NULL, Cc] + expect(fixture.nativeElement.textContent).toBe('BbCc'); + menuItemsContainer.move(menuItemsContainer.get(2)!, 1); // [ Bb, Cc, NULL] + expect(fixture.nativeElement.textContent).toBe('BbCc'); + menuItemsContainer.move(menuItemsContainer.get(0)!, 1); // [ Cc, Bb, NULL] + expect(fixture.nativeElement.textContent).toBe('CcBb'); + }); + }); + describe('Property bindings', () => { modifiedInIvy('Unknown property error throws an error instead of logging it') .it('should throw on bindings to unknown properties', () => {