fix(core): properly move embedded views of dynamic component's projectable nodes (#37167)

This commit fixes the issue of the ASSERTION ERROR issue when
a projected node(RNode) inside an array is checked against the types
of TNodeType.Element, TNodeType.Container, TNodeType.ElementContainer,
TNodeType.IcuContainer, TNodeType.Projection. As it's inside an array,
it doesn't fall into any of those types, as a result, it throws
the ASSERTION ERROR.

PR Close #37120

PR Close #37167
This commit is contained in:
stsogoo 2020-05-17 16:58:02 -07:00 committed by Alex Rickabaugh
parent 378da71f27
commit 0654c05c41
7 changed files with 206 additions and 29 deletions

View File

@ -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 (<ng-content>) 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

View File

@ -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 (<ng-content>) 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);
}
}

View File

@ -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;

View File

@ -1088,6 +1088,9 @@
{
"name": "getPreviousIndex"
},
{
"name": "getProjectionNodes"
},
{
"name": "getPromiseCtor"
},

View File

@ -1403,6 +1403,9 @@
{
"name": "getPreviousIndex"
},
{
"name": "getProjectionNodes"
},
{
"name": "getPromiseCtor"
},

View File

@ -437,6 +437,9 @@
{
"name": "getPreviousIndex"
},
{
"name": "getProjectionNodes"
},
{
"name": "getSelectedIndex"
},

View File

@ -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<any>;
itemContent!: string;
}
@NgModule({
declarations: [DynamicMenuItem],
entryComponents: [DynamicMenuItem],
})
class DynamicMenuItemModule {
}
@Component({selector: 'test', template: `<ng-container #menuItemsContainer></ng-container>`})
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, `<ng-template #templateRef><ng-content></ng-content></ng-template>`);
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,
`<ng-template #templateRef><ng-content select="span"></ng-content><ng-content select="button"></ng-content></ng-template>`);
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,
`<ng-template #templateRef><ng-content select="span"></ng-content>{{itemContent}}<ng-content select="button"></ng-content></ng-template>`);
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, `<ng-template #templateRef><ng-content></ng-content></ng-template>`);
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', () => {