From 075de7cc37383fad3cdb764b24dcbcc24fee72f4 Mon Sep 17 00:00:00 2001 From: teodosiah Date: Fri, 16 Oct 2026 12:33:05 +0300 Subject: [PATCH 1/4] fix(grid): preserve row order after viewport resize --- .../for-of/for_of.directive.spec.ts | 33 ++++++++++++++ .../src/directives/for-of/for_of.directive.ts | 44 ++++++++++++++----- .../grids/grid/src/grid.component.spec.ts | 33 ++++++++++++++ 3 files changed, 98 insertions(+), 12 deletions(-) diff --git a/projects/igniteui-angular/directives/src/directives/for-of/for_of.directive.spec.ts b/projects/igniteui-angular/directives/src/directives/for-of/for_of.directive.spec.ts index d6c252db2cb..644b34d24b1 100644 --- a/projects/igniteui-angular/directives/src/directives/for-of/for_of.directive.spec.ts +++ b/projects/igniteui-angular/directives/src/directives/for-of/for_of.directive.spec.ts @@ -586,6 +586,39 @@ describe('IgxForOf directive -', () => { } }); + it('should keep rows in order when the viewport grows at the bottom', async () => { + const component = fix.componentInstance; + component.scrollTop(Number.MAX_SAFE_INTEGER); + fix.detectChanges(); + await wait(); + + component.height = '200px'; + fix.detectChanges(); + await wait(); + + component.height = '600px'; + fix.detectChanges(); + await wait(); + component.scrollTop(Number.MAX_SAFE_INTEGER); + fix.detectChanges(); + await wait(); + + const rows = Array.from(displayContainer.children); + const startIndex = component.data.length - rows.length; + const chunkSize = component.parentVirtDir.state.chunkSize; + if (chunkSize) { + expect(rows.length).toBe(chunkSize); + } + rows.forEach((row, index) => { + const secondCell = row.querySelector(DISPLAY_CONTAINER)?.children[1]; + expect(secondCell?.textContent) + .toBe(component.data[startIndex + index]['1'].toString()); + }); + const lastCell = rows[rows.length - 1].querySelector(DISPLAY_CONTAINER)?.children[1]; + expect(lastCell?.textContent) + .toBe(component.data[component.data.length - 1]['1'].toString()); + }); + it('should scroll to wheel event correctly', async () => { fix.componentInstance.parentVirtDir.dc.instance._scrollInertia.smoothingDuration = 0; /* 120 is default mousewheel on Chrome, scroll 2 records down */ diff --git a/projects/igniteui-angular/directives/src/directives/for-of/for_of.directive.ts b/projects/igniteui-angular/directives/src/directives/for-of/for_of.directive.ts index 8e0b54c9427..7c8a6ca7c46 100644 --- a/projects/igniteui-angular/directives/src/directives/for-of/for_of.directive.ts +++ b/projects/igniteui-angular/directives/src/directives/for-of/for_of.directive.ts @@ -1501,20 +1501,30 @@ export class IgxForOfDirective extends IgxForOfToken= this.igxForOf!.length) { - elemIndex = this.igxForOf!.length - this.state.chunkSize!; + if (!this.isRemote && this.state.startIndex! > 0) { + elemIndex = --this.state.startIndex!; + prepend = true; + } else { + elemIndex = this.igxForOf!.length - this.state.chunkSize!; + } } const input = this.igxForOf![elemIndex]; - const embeddedView = this.dc.instance._vcr.createEmbeddedView( - this._template, - new IgxForOfContext(input, this.igxForOf!, this.getContextIndex(input), this.igxForOf!.length) as any - ); + const context = new IgxForOfContext(input, this.igxForOf!, this.getContextIndex(input), this.igxForOf!.length) as any; + const embeddedView = prepend + ? this.dc.instance._vcr.createEmbeddedView(this._template, context, { index: 0 }) + : this.dc.instance._vcr.createEmbeddedView(this._template, context); - this._embeddedViews.push(embeddedView); + if (prepend) { + this._embeddedViews.unshift(embeddedView); + } else { + this._embeddedViews.push(embeddedView); + } this.state.chunkSize!++; this._zone.run(() => this.cdr.markForCheck()); @@ -1946,20 +1956,30 @@ export class IgxGridForOfDirective extends IgxForOfDirec protected override addLastElem() { let elemIndex = this.state.startIndex! + this.state.chunkSize!; + let prepend = false; if (!this.isRemote && !this.igxForOf) { return; } if (elemIndex >= this.igxForOf!.length) { - elemIndex = this.igxForOf!.length - this.state.chunkSize!; + if (!this.isRemote && this.state.startIndex! > 0) { + elemIndex = --this.state.startIndex!; + prepend = true; + } else { + elemIndex = this.igxForOf!.length - this.state.chunkSize!; + } } const input = this.igxForOf![elemIndex]; - const embeddedView = this.dc.instance._vcr.createEmbeddedView( - this._template, - new IgxGridForOfContext(input, this.igxForOf!, this.getContextIndex(input), this.igxForOf!.length) as any - ); + const context = new IgxGridForOfContext(input, this.igxForOf!, this.getContextIndex(input), this.igxForOf!.length) as any; + const embeddedView = prepend + ? this.dc.instance._vcr.createEmbeddedView(this._template, context, { index: 0 }) + : this.dc.instance._vcr.createEmbeddedView(this._template, context); - this._embeddedViews.push(embeddedView); + if (prepend) { + this._embeddedViews.unshift(embeddedView); + } else { + this._embeddedViews.push(embeddedView); + } this.subscribeToViewObserver(this.getViewObservedNode(embeddedView)); this.state.chunkSize!++; } diff --git a/projects/igniteui-angular/grids/grid/src/grid.component.spec.ts b/projects/igniteui-angular/grids/grid/src/grid.component.spec.ts index 1441268afe0..99c72b5c7cc 100644 --- a/projects/igniteui-angular/grids/grid/src/grid.component.spec.ts +++ b/projects/igniteui-angular/grids/grid/src/grid.component.spec.ts @@ -156,6 +156,39 @@ describe('IgxGrid Component Tests #grid', () => { expect(grid.columns[1].field).toBe('index'); }); + it('should preserve row ordering when the viewport grows at the bottom', async () => { + const fix = TestBed.createComponent(IgxGridTestComponent); + fix.componentInstance.data = Array.from({ length: 1000 }, (_, index) => ({ index, value: index })); + fix.detectChanges(); + fix.detectChanges(); + + const grid = fix.componentInstance.grid; + const lastIndex = fix.componentInstance.data.length - 1; + + grid.verticalScrollContainer.scrollTo(lastIndex); + await wait(100); + + grid.height = '200px'; + fix.detectChanges(); + await wait(100); + + grid.height = '600px'; + fix.detectChanges(); + await wait(100); + + grid.verticalScrollContainer.scrollTo(lastIndex); + await wait(100); + + const renderedIndexes = grid.rowList.toArray().map(row => row.data.index); + expect(renderedIndexes[renderedIndexes.length - 1]).toBe(lastIndex); + renderedIndexes.forEach((index, position) => { + if (position > 0) { + expect(index).toBe(renderedIndexes[position - 1] + 1); + } + }); + fix.destroy(); + }); + it('should initialize grid with remote virtualization', async () => { const fix = TestBed.createComponent(IgxGridRemoteVirtualizationComponent); fix.detectChanges(); From d9d64d07b5dd7ff33a67490695c65541e9d134d3 Mon Sep 17 00:00:00 2001 From: teodosiah Date: Wed, 21 Oct 2026 10:00:07 +0300 Subject: [PATCH 2/4] fix(for-of): address copilot comment --- .../for-of/for_of.directive.spec.ts | 34 +++++++++++++++++++ .../src/directives/for-of/for_of.directive.ts | 21 ++++++++++++ 2 files changed, 55 insertions(+) diff --git a/projects/igniteui-angular/directives/src/directives/for-of/for_of.directive.spec.ts b/projects/igniteui-angular/directives/src/directives/for-of/for_of.directive.spec.ts index ecc94681039..efed64c9773 100644 --- a/projects/igniteui-angular/directives/src/directives/for-of/for_of.directive.spec.ts +++ b/projects/igniteui-angular/directives/src/directives/for-of/for_of.directive.spec.ts @@ -183,6 +183,40 @@ describe('IgxForOf directive -', () => { expect(state.startIndex).toBe(1); }); + for (const dir of ['ltr', 'rtl']) { + it(`should keep the scrolled content in place when growing the container prepends items (${dir})`, () => { + const sign = dir === 'rtl' ? -1 : 1; + fix.debugElement.nativeElement.dir = dir; + fix.componentInstance.width = '200px'; + fix.componentInstance.cols = [ + { field: '1', width: 100 }, + { field: '2', width: 100 }, + { field: '3', width: 100 }, + { field: '4', width: 100 }, + { field: '5', width: 100 } + ]; + fix.detectChanges(); + + fix.componentInstance.scrollLeft(sign * 250); + fix.detectChanges(); + const firstDir = fix.componentInstance.childVirtDirs.first; + expect(firstDir.state.startIndex).toBe(2); + expect(firstDir.state.chunkSize).toBe(3); + expect(parseFloat(displayContainer.style.left)).toBe(-sign * 50); + + // growing by 1px adds one item but leaves the scroll position unclamped, + // so no scroll event follows to restore the offset + fix.componentInstance.width = '201px'; + fix.detectChanges(); + + expect(Math.abs(horizontalScroller.scrollLeft)).toBe(250); + expect(firstDir.state.startIndex).toBe(1); + expect(firstDir.state.chunkSize).toBe(4); + expect(parseFloat(displayContainer.style.left)).toBe(-sign * 150); + expect(displayContainer.children[0].textContent).toBe(fix.componentInstance.data[0]['2'].toString()); + }); + } + it('should display the correct chunk items on resizing the container', async () => { // initially the container's width is narrow enough to be scrollable fix.componentInstance.width = '200px'; diff --git a/projects/igniteui-angular/directives/src/directives/for-of/for_of.directive.ts b/projects/igniteui-angular/directives/src/directives/for-of/for_of.directive.ts index a8c80b59dd1..17c1dfc538c 100644 --- a/projects/igniteui-angular/directives/src/directives/for-of/for_of.directive.ts +++ b/projects/igniteui-angular/directives/src/directives/for-of/for_of.directive.ts @@ -1486,7 +1486,13 @@ export class IgxForOfDirective extends IgxForOfToken extends IgxForOfToken Date: Wed, 28 Oct 2026 12:22:16 +0200 Subject: [PATCH 3/4] refactor(for-of): reuse scroll offset sync and share prepend logic - Sync the offset after a container resize through _updateScrollOffset, after the scrollbar size is recalculated, and make it RTL aware. - Extract the shared addLastElem logic into insertChunkElem with a createTemplateContext override for the grid directive. - Add LTR/RTL tests for the offset after igxForOf changes. - Add a changelog entry for #17575. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 + .../for-of/for_of.directive.spec.ts | 33 +++++++ .../src/directives/for-of/for_of.directive.ts | 98 ++++++++----------- 3 files changed, 79 insertions(+), 55 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e9e7a88faa9..b24fa0ca26c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -76,6 +76,9 @@ All notable changes for each version of this project will be documented in this - The `outlet` input is now applied to the dropdown/dialog. - The dialog header no longer throws a missing locale data error for a `locale` without registered Angular locale data when `provideIgniteIntl()` is used. - The `aria-valuemin` and `aria-valuemax` of the selected seconds item no longer depend on the milliseconds of the value. +- `IgxForOfDirective` + - Growing the container of a virtualized list or grid scrolled to its end (for example by resizing the grid) no longer renders the rows or columns out of order or shifts the visible content by one item (#17575). + - In right-to-left direction, changing the items of a horizontally scrolled `igxFor` no longer moves the visible items away from the scroll position. ## 22.2.4 diff --git a/projects/igniteui-angular/directives/src/directives/for-of/for_of.directive.spec.ts b/projects/igniteui-angular/directives/src/directives/for-of/for_of.directive.spec.ts index efed64c9773..0d513e11602 100644 --- a/projects/igniteui-angular/directives/src/directives/for-of/for_of.directive.spec.ts +++ b/projects/igniteui-angular/directives/src/directives/for-of/for_of.directive.spec.ts @@ -158,6 +158,39 @@ describe('IgxForOf directive -', () => { expect(parseInt(displayContainer.style.left, 10)).toEqual(0); }); + for (const dir of ['ltr', 'rtl']) { + it(`should keep the horizontal scroll offset if igxForOf changes while scrolled (${dir})`, () => { + const sign = dir === 'rtl' ? -1 : 1; + fix.debugElement.nativeElement.dir = dir; + fix.componentInstance.width = '500px'; + fix.componentInstance.cols = [ + { field: '1', width: 100 }, + { field: '2', width: 200 }, + { field: '3', width: 200 }, + { field: '4', width: 200 }, + { field: '5', width: 300 } + ]; + fix.detectChanges(); + + fix.componentInstance.scrollLeft(sign * 250); + fix.detectChanges(); + const firstDir = fix.componentInstance.childVirtDirs.first; + expect(firstDir.state.startIndex).toBe(1); + expect(parseFloat(displayContainer.style.left)).toBe(-sign * 150); + + // widening the last column keeps the scroll position and the start index + fix.componentInstance.cols = [ + ...fix.componentInstance.cols.slice(0, 4), + { field: '5', width: 400 } + ]; + fix.detectChanges(); + + expect(Math.abs(horizontalScroller.scrollLeft)).toBe(250); + expect(firstDir.state.startIndex).toBe(1); + expect(parseFloat(displayContainer.style.left)).toBe(-sign * 150); + }); + } + it('should allow scroll in rtl direction.', () => { fix.debugElement.nativeElement.dir = 'rtl'; fix.detectChanges(); diff --git a/projects/igniteui-angular/directives/src/directives/for-of/for_of.directive.ts b/projects/igniteui-angular/directives/src/directives/for-of/for_of.directive.ts index 17c1dfc538c..b679f910eac 100644 --- a/projects/igniteui-angular/directives/src/directives/for-of/for_of.directive.ts +++ b/projects/igniteui-angular/directives/src/directives/for-of/for_of.directive.ts @@ -1488,12 +1488,12 @@ export class IgxForOfDirective extends IgxForOfToken extends IgxForOfToken this.cdr.markForCheck()); + } + } + + /** + * @hidden + * Creates an embedded view for the item after the current chunk and updates chunkSize. + * When the chunk already reaches the end of the data, the view is created for the item before it + * and prepended instead, so that the rendered items stay in order. + */ + protected insertChunkElem(): EmbeddedViewRef | null { if (!this.isRemote && !this.igxForOf) { - return; + return null; } + let elemIndex = this.state.startIndex! + this.state.chunkSize!; + let prepend = false; if (elemIndex >= this.igxForOf!.length) { if (!this.isRemote && this.state.startIndex! > 0) { elemIndex = --this.state.startIndex!; @@ -1544,10 +1556,11 @@ export class IgxForOfDirective extends IgxForOfToken(input, this.igxForOf!, this.getContextIndex(input), this.igxForOf!.length) as any; - const embeddedView = prepend - ? this.dc.instance._vcr.createEmbeddedView(this._template, context, { index: 0 }) - : this.dc.instance._vcr.createEmbeddedView(this._template, context); + const embeddedView = this.dc.instance._vcr.createEmbeddedView( + this._template, + this.createTemplateContext(input) as any, + { index: prepend ? 0 : undefined } + ); if (prepend) { this._embeddedViews.unshift(embeddedView); @@ -1555,8 +1568,15 @@ export class IgxForOfDirective extends IgxForOfToken this.cdr.markForCheck()); + /** + * @hidden + * Creates the template context for an item rendered by insertChunkElem(). + */ + protected createTemplateContext(input: T): IgxForOfContext { + return new IgxForOfContext(input, this.igxForOf!, this.getContextIndex(input), this.igxForOf!.length); } /** @@ -1596,9 +1616,11 @@ export class IgxForOfDirective extends IgxForOfToken extends IgxForOfToken extends IgxForOfDirec } protected override addLastElem() { - let elemIndex = this.state.startIndex! + this.state.chunkSize!; - let prepend = false; - if (!this.isRemote && !this.igxForOf) { - return; + const embeddedView = this.insertChunkElem(); + if (embeddedView) { + this.subscribeToViewObserver(this.getViewObservedNode(embeddedView)); } + } - if (elemIndex >= this.igxForOf!.length) { - if (!this.isRemote && this.state.startIndex! > 0) { - elemIndex = --this.state.startIndex!; - prepend = true; - } else { - elemIndex = this.igxForOf!.length - this.state.chunkSize!; - } - } - const input = this.igxForOf![elemIndex]; - const context = new IgxGridForOfContext(input, this.igxForOf!, this.getContextIndex(input), this.igxForOf!.length) as any; - const embeddedView = prepend - ? this.dc.instance._vcr.createEmbeddedView(this._template, context, { index: 0 }) - : this.dc.instance._vcr.createEmbeddedView(this._template, context); - - if (prepend) { - this._embeddedViews.unshift(embeddedView); - } else { - this._embeddedViews.push(embeddedView); - } - this.subscribeToViewObserver(this.getViewObservedNode(embeddedView)); - this.state.chunkSize!++; + protected override createTemplateContext(input: T): IgxGridForOfContext { + return new IgxGridForOfContext(input, this.igxForOf!, this.getContextIndex(input), this.igxForOf!.length); } protected _updateViews(prevChunkSize: number | undefined) { From 059aa9b0585a76abe2c0cf7611b0b113a36c915b Mon Sep 17 00:00:00 2001 From: teodosiah Date: Thu, 29 Oct 2026 10:17:10 +0200 Subject: [PATCH 4/4] fix(for-of): use isRTL getter and make grow test fail before fix - Use the isRTL getter in _updateScrollOffset instead of the scroll sign. - Drop @hidden from the protected insertChunkElem. - Grow the viewport without clamping the scroll position in the vertical test and assert the rows order and offset without re-scrolling. Co-Authored-By: Claude Opus 5.5 --- .../for-of/for_of.directive.spec.ts | 22 +++++-------------- .../src/directives/for-of/for_of.directive.ts | 5 +---- 2 files changed, 7 insertions(+), 20 deletions(-) diff --git a/projects/igniteui-angular/directives/src/directives/for-of/for_of.directive.spec.ts b/projects/igniteui-angular/directives/src/directives/for-of/for_of.directive.spec.ts index 0d513e11602..ecc9a1ded3e 100644 --- a/projects/igniteui-angular/directives/src/directives/for-of/for_of.directive.spec.ts +++ b/projects/igniteui-angular/directives/src/directives/for-of/for_of.directive.spec.ts @@ -696,35 +696,25 @@ describe('IgxForOf directive -', () => { it('should keep rows in order when the viewport grows at the bottom', async () => { const component = fix.componentInstance; - component.scrollTop(Number.MAX_SAFE_INTEGER); + // scroll 25px short of the end, so that growing by 10px does not clamp the scroll position + const scrollTop = 50000 * 50 - 400 - 25; + component.scrollTop(scrollTop); fix.detectChanges(); await wait(); - component.height = '200px'; - fix.detectChanges(); - await wait(); - - component.height = '600px'; - fix.detectChanges(); - await wait(); - component.scrollTop(Number.MAX_SAFE_INTEGER); + component.height = '410px'; fix.detectChanges(); await wait(); const rows = Array.from(displayContainer.children); const startIndex = component.data.length - rows.length; - const chunkSize = component.parentVirtDir.state.chunkSize; - if (chunkSize) { - expect(rows.length).toBe(chunkSize); - } rows.forEach((row, index) => { const secondCell = row.querySelector(DISPLAY_CONTAINER)?.children[1]; expect(secondCell?.textContent) .toBe(component.data[startIndex + index]['1'].toString()); }); - const lastCell = rows[rows.length - 1].querySelector(DISPLAY_CONTAINER)?.children[1]; - expect(lastCell?.textContent) - .toBe(component.data[component.data.length - 1]['1'].toString()); + // the rows are offset so that the content at the scroll position stays in view + expect(displayContainer.style.transform).toBe(`translateY(${startIndex * 50 - scrollTop}px)`); }); it('should scroll to wheel event correctly', async () => { diff --git a/projects/igniteui-angular/directives/src/directives/for-of/for_of.directive.ts b/projects/igniteui-angular/directives/src/directives/for-of/for_of.directive.ts index b679f910eac..59b6a31eddd 100644 --- a/projects/igniteui-angular/directives/src/directives/for-of/for_of.directive.ts +++ b/projects/igniteui-angular/directives/src/directives/for-of/for_of.directive.ts @@ -1535,7 +1535,6 @@ export class IgxForOfDirective extends IgxForOfToken extends IgxForOfToken extends IgxForOfToken