From d6dcd471bb663a8bfbc465e75e61d3ae903cab56 Mon Sep 17 00:00:00 2001 From: Martin Dragnev Date: Tue, 15 Sep 2026 16:55:56 +0300 Subject: [PATCH 1/4] fix(elements): attach nested elements to their parent's view --- .../src/app/custom-strategy.spec.ts | 119 +++++++++++++++++- .../src/app/custom-strategy.ts | 25 ++-- 2 files changed, 130 insertions(+), 14 deletions(-) diff --git a/projects/igniteui-angular-elements/src/app/custom-strategy.spec.ts b/projects/igniteui-angular-elements/src/app/custom-strategy.spec.ts index d5ec22f100e..13297272e4d 100644 --- a/projects/igniteui-angular-elements/src/app/custom-strategy.spec.ts +++ b/projects/igniteui-angular-elements/src/app/custom-strategy.spec.ts @@ -1,7 +1,9 @@ +import { ApplicationRef } from '@angular/core'; import { IgxActionStripComponent, IgxColumnComponent, IgxGridComponent, IgxHierarchicalGridComponent, PivotGridType } from 'igniteui-angular'; import { html } from 'lit'; import { firstValueFrom, fromEvent, timer } from 'rxjs'; import { ComponentRefKey, IgcNgElement } from './custom-strategy'; +import { injector } from '../utils/injector-ref'; import hgridData from '../assets/data/projects-hgrid.js'; import { SampleTestData } from 'igniteui-angular/test-utils/sample-test-data.spec'; import { @@ -15,6 +17,11 @@ import { IgcActionStripComponent, IgcGridEditingActionsComponent, IgcPivotDataSelectorComponent, + IgcGridToolbarComponent, + IgcGridToolbarActionsComponent, + IgcGridToolbarTitleComponent, + IgcGridToolbarPinningComponent, + IgcGridToolbarHidingComponent, } from './components'; import { defineComponents } from '../utils/register'; @@ -32,7 +39,12 @@ describe('Elements: ', () => { IgcPaginatorComponent, IgcGridStateComponent, IgcActionStripComponent, - IgcGridEditingActionsComponent + IgcGridEditingActionsComponent, + IgcGridToolbarComponent, + IgcGridToolbarActionsComponent, + IgcGridToolbarTitleComponent, + IgcGridToolbarPinningComponent, + IgcGridToolbarHidingComponent ); }); @@ -371,6 +383,111 @@ describe('Elements: ', () => { expect(actionStrip.isConnected).toBeTrue(); }); + it('should attach nested elements into the parent view instead of as separate application roots', async () => { + testContainer.innerHTML = ` + + + Title + + + + + + + + + `; + + const gridEl = document.querySelector>('#testGrid'); + + await firstValueFrom(fromEvent(gridEl, "childrenResolved")); + + const hostViewOf = async (selector: string) => + (await gridEl.querySelector(selector).ngElementStrategy[ComponentRefKey]).hostView; + // views the ApplicationRef ticks directly; anything else is reached through its parent + const rootViews = (injector.get(ApplicationRef) as any)._views as unknown[]; + + // no element parent to attach to, so the grid stays a root + expect(rootViews.includes((await gridEl.ngElementStrategy[ComponentRefKey]).hostView)).toBeTrue(); + + for (const selector of ['igc-grid-toolbar', 'igc-grid-toolbar-title', 'igc-grid-toolbar-actions', + 'igc-grid-toolbar-hiding', 'igc-grid-toolbar-pinning', 'igc-column', 'igc-paginator']) { + expect(rootViews.includes(await hostViewOf(selector))) + .withContext(`${selector} should not be attached as a separate root view`).toBeFalse(); + } + }); + + it('should preserve the DOM position of nested elements when attaching them to the parent view', async () => { + // the attach moves the element next to the parent's host element, so it has to be put back + testContainer.innerHTML = ` + + + Title + + + + + + + + + `; + + const gridEl = document.querySelector>('#testGrid'); + + await firstValueFrom(fromEvent(gridEl, "childrenResolved")); + + // nothing stranded next to the grid, where the insert temporarily moves elements + expect(Array.from(testContainer.children).map(x => x.tagName)).toEqual(['IGC-GRID']); + + const toolbarEl = gridEl.querySelector('igc-grid-toolbar'); + const actionsEl = gridEl.querySelector('igc-grid-toolbar-actions'); + const paginatorEl = gridEl.querySelector('igc-paginator'); + + expect(toolbarEl.parentElement).toBe(gridEl); + expect(gridEl.querySelector('igc-grid-toolbar-title').parentElement).toBe(toolbarEl); + expect(actionsEl.parentElement).toBe(toolbarEl); + expect(Array.from(gridEl.querySelectorAll('igc-column')).every(x => x.parentElement === gridEl)).toBeTrue(); + + // sibling order kept as authored + expect(Array.from(actionsEl.children).map(x => x.tagName)) + .toEqual(['IGC-GRID-TOOLBAR-HIDING', 'IGC-GRID-TOOLBAR-PINNING']); + + // the paginator is projected deeper (into the footer) - that spot survives the attach too + expect(gridEl.contains(paginatorEl)).toBeTrue(); + expect(paginatorEl.parentElement).not.toBe(gridEl); + }); + + it('should refresh a nested toolbar action when only the parent grid is marked for check', async () => { + // nothing reaches the toolbar action here - no input, no event. It re-renders only because the + // grid's own `notifyChanges()` reaches it through the view hierarchy. + testContainer.innerHTML = ` + + + + + + + + + `; + + const gridEl = document.querySelector>('#testGrid'); + + await firstValueFrom(fromEvent(gridEl, "childrenResolved")); + + const pinnedCount = () => gridEl.querySelector('igc-grid-toolbar-pinning span')?.textContent.trim(); + expect(pinnedCount()).toEqual('0'); + + gridEl.pinColumn('ProductID'); + await firstValueFrom(timer(10 /* SCHEDULE_DELAY */ * 2)); + expect(pinnedCount()).toEqual('1'); + + gridEl.unpinColumn('ProductID'); + await firstValueFrom(timer(10 /* SCHEDULE_DELAY */ * 2)); + expect(pinnedCount()).toEqual('0'); + }); + it('should update the UI correctly after invoking a method', async () => { // Regression coverage for UI updates after removing the zone.js dependency. const gridEl = document.createElement("igc-grid"); diff --git a/projects/igniteui-angular-elements/src/app/custom-strategy.ts b/projects/igniteui-angular-elements/src/app/custom-strategy.ts index 4f06b415140..a49dd56f1c2 100644 --- a/projects/igniteui-angular-elements/src/app/custom-strategy.ts +++ b/projects/igniteui-angular-elements/src/app/custom-strategy.ts @@ -132,17 +132,12 @@ class IgxCustomNgElementStrategy extends ComponentNgElementStrategy { if (parent?.ngElementStrategy) { this.angularParent = parent.ngElementStrategy.angularParent; this.parentElement = new WeakRef(parent); - let parentComponentRef = await parent?.ngElementStrategy[ComponentRefKey]; + const parentComponentRef = await parent?.ngElementStrategy[ComponentRefKey]; parentInjector = parentComponentRef?.injector; - - // TODO: Consider general solution (as in Parent w/ @igxAnchor tag) - if (element.tagName.toLocaleLowerCase() === 'igc-grid-toolbar' - || element.tagName.toLocaleLowerCase() === 'igc-paginator') { - // NOPE: viewcontainerRef will re-render this node again, no option for rootNode :S - // this.componentRef = parentAnchor.createComponent(this.componentFactory.componentType, { projectableNodes, injector: childInjector }); - parentComponentRef = await parent?.ngElementStrategy[ComponentRefKey]; - parentAnchor = parentComponentRef?.instance.anchor; - } + // Container anchored at the parent's host element, so this becomes a child in the parent's + // view tree instead of a standalone root. Zoneless CD only walks views reachable from what + // was marked dirty, so the parent's `markForCheck()` has to be able to reach it. + parentAnchor = parentInjector.get(ViewContainerRef); } else if ((parent as any)?.__componentRef) { this.angularParent = (parent as any).__componentRef; parentInjector = this.angularParent.injector; @@ -197,9 +192,13 @@ class IgxCustomNgElementStrategy extends ComponentNgElementStrategy { // const parentViewRef = parentInjector.get(ViewContainerRef); // preserve original position in DOM (in case of projection, e.g. grid pager): const domParent = element.parentElement; - const nextSibling = element.nextSibling; - parentAnchor.insert((this as any).componentRef.hostView); //bad, moves in DOM, AND need to be in inner anchor :S - //restore original DOM position + // `insert` moves all root nodes, and some components have more than the element itself + // (igc-action-strip/igc-grid-state also have a trailing anchor comment). Skipping past them + // keeps the reference node outside the view, so it stays put and `insertBefore` won't throw. + const nextSibling = (this as any).componentRef.hostView.rootNodes.at(-1).nextSibling; + parentAnchor.insert((this as any).componentRef.hostView); + // only the view hierarchy is wanted here, so undo the DOM move `insert` does + // and restore original DOM position domParent!.insertBefore(element, nextSibling); (this as any).componentRef.hostView.detectChanges(); } else if (!parentAnchor) { From bd53aa60e63fd734f7c371e0f4396676f5ba1be3 Mon Sep 17 00:00:00 2001 From: Martin Dragnev Date: Tue, 15 Sep 2026 16:56:08 +0300 Subject: [PATCH 2/4] chore(*): Remove sink container used for elements nested components --- .../src/lib/grids/row-island.component.ts | 4 ++-- .../igniteui-angular/grids/grid/src/grid-base.directive.ts | 7 ------- .../igniteui-angular/grids/grid/src/grid.component.html | 1 - .../hierarchical-grid/src/hierarchical-grid.component.html | 1 - .../grids/pivot-grid/src/pivot-grid.component.html | 1 - .../grids/tree-grid/src/tree-grid.component.html | 1 - 6 files changed, 2 insertions(+), 13 deletions(-) diff --git a/projects/igniteui-angular-elements/src/lib/grids/row-island.component.ts b/projects/igniteui-angular-elements/src/lib/grids/row-island.component.ts index 0b9d752e8b7..21b7911cc55 100644 --- a/projects/igniteui-angular-elements/src/lib/grids/row-island.component.ts +++ b/projects/igniteui-angular-elements/src/lib/grids/row-island.component.ts @@ -26,10 +26,10 @@ import { IgxActionStripToken } from 'igniteui-angular/core'; @Component({ changeDetection: ChangeDetectionStrategy.OnPush, selector: 'igx-row-island', - template: `
+ template: ` -
`, + `, providers: [ IgxRowIslandAPIService, IgxFilteringService, diff --git a/projects/igniteui-angular/grids/grid/src/grid-base.directive.ts b/projects/igniteui-angular/grids/grid/src/grid-base.directive.ts index 1204bbabbc0..db13936f250 100644 --- a/projects/igniteui-angular/grids/grid/src/grid-base.directive.ts +++ b/projects/igniteui-angular/grids/grid/src/grid-base.directive.ts @@ -1754,13 +1754,6 @@ export abstract class IgxGridBaseDirective implements GridType, @ViewChild('igxFilteringOverlayOutlet', { read: IgxOverlayOutletDirective, static: true }) protected _outletDirective!: IgxOverlayOutletDirective; - /** - * @hidden @internal - * @igxElementsAnchor - */ - @ViewChild('sink', { read: ViewContainerRef, static: true }) - public anchor!: ViewContainerRef; - /** * @hidden @internal */ diff --git a/projects/igniteui-angular/grids/grid/src/grid.component.html b/projects/igniteui-angular/grids/grid/src/grid.component.html index 2c59086caba..6fa4152190c 100644 --- a/projects/igniteui-angular/grids/grid/src/grid.component.html +++ b/projects/igniteui-angular/grids/grid/src/grid.component.html @@ -323,7 +323,6 @@ }
@if (platform.isElements) { -
} diff --git a/projects/igniteui-angular/grids/hierarchical-grid/src/hierarchical-grid.component.html b/projects/igniteui-angular/grids/hierarchical-grid/src/hierarchical-grid.component.html index d7b99fdf073..5c3961f357c 100644 --- a/projects/igniteui-angular/grids/hierarchical-grid/src/hierarchical-grid.component.html +++ b/projects/igniteui-angular/grids/hierarchical-grid/src/hierarchical-grid.component.html @@ -274,7 +274,6 @@ }
@if (platform.isElements) { -
diff --git a/projects/igniteui-angular/grids/pivot-grid/src/pivot-grid.component.html b/projects/igniteui-angular/grids/pivot-grid/src/pivot-grid.component.html index e60cfcea708..10171dc629f 100644 --- a/projects/igniteui-angular/grids/pivot-grid/src/pivot-grid.component.html +++ b/projects/igniteui-angular/grids/pivot-grid/src/pivot-grid.component.html @@ -231,6 +231,5 @@ @if (platform.isElements) { -
} diff --git a/projects/igniteui-angular/grids/tree-grid/src/tree-grid.component.html b/projects/igniteui-angular/grids/tree-grid/src/tree-grid.component.html index 15ef80849c6..55d1131661e 100644 --- a/projects/igniteui-angular/grids/tree-grid/src/tree-grid.component.html +++ b/projects/igniteui-angular/grids/tree-grid/src/tree-grid.component.html @@ -574,7 +574,6 @@ }
@if (platform.isElements) { -
Date: Wed, 16 Sep 2026 11:04:07 +0300 Subject: [PATCH 3/4] chore(*): update comments --- .../igniteui-angular-elements/src/app/custom-strategy.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/projects/igniteui-angular-elements/src/app/custom-strategy.ts b/projects/igniteui-angular-elements/src/app/custom-strategy.ts index a49dd56f1c2..dd27277d4e3 100644 --- a/projects/igniteui-angular-elements/src/app/custom-strategy.ts +++ b/projects/igniteui-angular-elements/src/app/custom-strategy.ts @@ -134,9 +134,7 @@ class IgxCustomNgElementStrategy extends ComponentNgElementStrategy { this.parentElement = new WeakRef(parent); const parentComponentRef = await parent?.ngElementStrategy[ComponentRefKey]; parentInjector = parentComponentRef?.injector; - // Container anchored at the parent's host element, so this becomes a child in the parent's - // view tree instead of a standalone root. Zoneless CD only walks views reachable from what - // was marked dirty, so the parent's `markForCheck()` has to be able to reach it. + // Use anchor to attach to the parent's view tree instead of a standalone root. parentAnchor = parentInjector.get(ViewContainerRef); } else if ((parent as any)?.__componentRef) { this.angularParent = (parent as any).__componentRef; @@ -192,9 +190,8 @@ class IgxCustomNgElementStrategy extends ComponentNgElementStrategy { // const parentViewRef = parentInjector.get(ViewContainerRef); // preserve original position in DOM (in case of projection, e.g. grid pager): const domParent = element.parentElement; - // `insert` moves all root nodes, and some components have more than the element itself - // (igc-action-strip/igc-grid-state also have a trailing anchor comment). Skipping past them - // keeps the reference node outside the view, so it stays put and `insertBefore` won't throw. + // `insert` moves all root nodes & some components have more than one, + // so a potential `nextSibling` is always the one after the _last_ root node. const nextSibling = (this as any).componentRef.hostView.rootNodes.at(-1).nextSibling; parentAnchor.insert((this as any).componentRef.hostView); // only the view hierarchy is wanted here, so undo the DOM move `insert` does From 4f209872a392042e437e2aee790242ce2663222e Mon Sep 17 00:00:00 2001 From: Martin Dragnev Date: Wed, 16 Sep 2026 11:05:32 +0300 Subject: [PATCH 4/4] test(strategy): change test to check the actual view hierarchy --- .../src/app/custom-strategy.spec.ts | 53 ++++++++++++------- 1 file changed, 35 insertions(+), 18 deletions(-) diff --git a/projects/igniteui-angular-elements/src/app/custom-strategy.spec.ts b/projects/igniteui-angular-elements/src/app/custom-strategy.spec.ts index 13297272e4d..15428665123 100644 --- a/projects/igniteui-angular-elements/src/app/custom-strategy.spec.ts +++ b/projects/igniteui-angular-elements/src/app/custom-strategy.spec.ts @@ -1,4 +1,4 @@ -import { ApplicationRef } from '@angular/core'; +import { ApplicationRef, ViewContainerRef } from '@angular/core'; import { IgxActionStripComponent, IgxColumnComponent, IgxGridComponent, IgxHierarchicalGridComponent, PivotGridType } from 'igniteui-angular'; import { html } from 'lit'; import { firstValueFrom, fromEvent, timer } from 'rxjs'; @@ -398,22 +398,41 @@ describe('Elements: ', () => { `; - const gridEl = document.querySelector>('#testGrid'); + const gridEl = document.querySelector>('#testGrid')!; await firstValueFrom(fromEvent(gridEl, "childrenResolved")); + const elementOf = (selector: string) => + selector === 'igc-grid' ? gridEl : gridEl.querySelector(selector); const hostViewOf = async (selector: string) => - (await gridEl.querySelector(selector).ngElementStrategy[ComponentRefKey]).hostView; + (await (elementOf(selector))?.ngElementStrategy[ComponentRefKey])?.hostView; + // the view container each element inserts its children's views into + const anchorOf = async (selector: string) => + (await (elementOf(selector))?.ngElementStrategy[ComponentRefKey])?.injector.get(ViewContainerRef); // views the ApplicationRef ticks directly; anything else is reached through its parent const rootViews = (injector.get(ApplicationRef) as any)._views as unknown[]; // no element parent to attach to, so the grid stays a root - expect(rootViews.includes((await gridEl.ngElementStrategy[ComponentRefKey]).hostView)).toBeTrue(); + expect(rootViews.includes(await hostViewOf('igc-grid'))).toBeTrue(); + + // each nested element's view lives in its parent element's container, not in the app's roots + const expectedParents = { + 'igc-grid-toolbar': 'igc-grid', + 'igc-grid-toolbar-title': 'igc-grid-toolbar', + 'igc-grid-toolbar-actions': 'igc-grid-toolbar', + 'igc-grid-toolbar-hiding': 'igc-grid-toolbar-actions', + 'igc-grid-toolbar-pinning': 'igc-grid-toolbar-actions', + 'igc-column': 'igc-grid', + 'igc-paginator': 'igc-grid' + }; - for (const selector of ['igc-grid-toolbar', 'igc-grid-toolbar-title', 'igc-grid-toolbar-actions', - 'igc-grid-toolbar-hiding', 'igc-grid-toolbar-pinning', 'igc-column', 'igc-paginator']) { - expect(rootViews.includes(await hostViewOf(selector))) + for (const [selector, parentSelector] of Object.entries(expectedParents)) { + const hostView = await hostViewOf(selector); + expect(rootViews.includes(hostView)) .withContext(`${selector} should not be attached as a separate root view`).toBeFalse(); + expect((await anchorOf(parentSelector))?.indexOf(hostView!)) + .withContext(`${selector} should be attached in the view of ${parentSelector}`) + .toBeGreaterThan(-1); } }); @@ -433,7 +452,7 @@ describe('Elements: ', () => { `; - const gridEl = document.querySelector>('#testGrid'); + const gridEl = document.querySelector>('#testGrid')!; await firstValueFrom(fromEvent(gridEl, "childrenResolved")); @@ -444,23 +463,21 @@ describe('Elements: ', () => { const actionsEl = gridEl.querySelector('igc-grid-toolbar-actions'); const paginatorEl = gridEl.querySelector('igc-paginator'); - expect(toolbarEl.parentElement).toBe(gridEl); - expect(gridEl.querySelector('igc-grid-toolbar-title').parentElement).toBe(toolbarEl); - expect(actionsEl.parentElement).toBe(toolbarEl); - expect(Array.from(gridEl.querySelectorAll('igc-column')).every(x => x.parentElement === gridEl)).toBeTrue(); + expect(toolbarEl?.parentElement).toBe(gridEl); + expect(toolbarEl?.querySelector('igc-grid-toolbar-title')?.parentElement).toBe(toolbarEl); + expect(actionsEl?.parentElement).toBe(toolbarEl); + expect(Array.from(gridEl?.querySelectorAll('igc-column') || []).every(x => x.parentElement === gridEl)).toBeTrue(); // sibling order kept as authored - expect(Array.from(actionsEl.children).map(x => x.tagName)) + expect(Array.from(actionsEl?.children || []).map(x => x.tagName)) .toEqual(['IGC-GRID-TOOLBAR-HIDING', 'IGC-GRID-TOOLBAR-PINNING']); // the paginator is projected deeper (into the footer) - that spot survives the attach too - expect(gridEl.contains(paginatorEl)).toBeTrue(); - expect(paginatorEl.parentElement).not.toBe(gridEl); + expect(gridEl?.contains(paginatorEl)).toBeTrue(); + expect(paginatorEl?.parentElement).not.toBe(gridEl); }); it('should refresh a nested toolbar action when only the parent grid is marked for check', async () => { - // nothing reaches the toolbar action here - no input, no event. It re-renders only because the - // grid's own `notifyChanges()` reaches it through the view hierarchy. testContainer.innerHTML = ` @@ -472,7 +489,7 @@ describe('Elements: ', () => { `; - const gridEl = document.querySelector>('#testGrid'); + const gridEl = document.querySelector>('#testGrid')!; await firstValueFrom(fromEvent(gridEl, "childrenResolved"));