From 91bccb3d3d50385b0af06be21a2da30e842da4a9 Mon Sep 17 00:00:00 2001 From: Arkan Ahmedov Date: Fri, 24 Jul 2026 11:23:46 +0300 Subject: [PATCH 1/4] fix(grid): ensure final settle position in momentum scroll with trailing option --- .../igniteui-angular/grids/grid/src/grid-base.directive.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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 04af42f9e8d..456f2214e8b 100644 --- a/projects/igniteui-angular/grids/grid/src/grid-base.directive.ts +++ b/projects/igniteui-angular/grids/grid/src/grid-base.directive.ts @@ -3698,7 +3698,12 @@ export abstract class IgxGridBaseDirective implements GridType, this.throttleTime$.pipe( take(1), switchMap(time => timer(time, this.throttleScheduler)) - ) + ), + // `trailing: true` ensures the final settle position of a fast momentum + // scroll is processed; otherwise the last scroll events are dropped and the + // rows stay frozen at an intermediate startIndex while the scrollbar is at top. + // `leading: true` keeps the immediate response on scroll start. + { leading: true, trailing: true } ), destructor ) From 592caaad8793bdad4b549bc0dbfda671738331ae Mon Sep 17 00:00:00 2001 From: Arkan Ahmedov Date: Fri, 24 Jul 2026 11:24:00 +0300 Subject: [PATCH 2/4] test(grid): add regression coverage for momentum-scroll trailing settle Drives the vertical scrollNotify stream with an intermediate (leading-edge) position followed by a scrollTop=0 settle in the same throttle window, so the settle can only be delivered on the trailing edge. Asserts the grid resets to startIndex 0 / renders row 0, guarding against the dropped-trailing throttle config that left the top rows frozen out of view. --- .../grids/grid/src/grid.component.spec.ts | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) 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 f8ce0013662..33381e3ac72 100644 --- a/projects/igniteui-angular/grids/grid/src/grid.component.spec.ts +++ b/projects/igniteui-angular/grids/grid/src/grid.component.spec.ts @@ -2161,6 +2161,38 @@ describe('IgxGrid Component Tests #grid', () => { expect(grid.headerContainer.state.startIndex).toBeGreaterThan(0); }); + + it('should settle at the top row when a fast momentum scroll ends at scrollTop 0 (throttle trailing edge)', async () => { + const fix = TestBed.createComponent(ZonelessTallGridComponent); + fix.detectChanges(); + await fix.whenStable(); + const grid = fix.componentInstance.grid; + const virtDir = grid.verticalScrollContainer; + const scrollEl = virtDir.getScroll(); + const maxScroll = scrollEl.scrollHeight - scrollEl.clientHeight; + + // Move away from the top so the first rows are virtualized out of view. + grid.scrollNotify.next({ target: { scrollTop: maxScroll } }); + await wait(50); + await fix.whenStable(); + expect(virtDir.state.startIndex).toBeGreaterThan(0); + + // Simulate a fast momentum/inertia scroll back to the top: an intermediate + // position lands on the throttle's leading edge and the scrollTop = 0 settle + // arrives within the same throttle window, so it can only be delivered on the + // trailing edge. Feeding scrollNotify directly (instead of setting scrollTop) + // avoids the browser's async native scroll events - which all read the final + // scrollTop of 0 - from masking a dropped-trailing regression. + grid.scrollNotify.next({ target: { scrollTop: Math.round(maxScroll / 2) } }); + grid.scrollNotify.next({ target: { scrollTop: 0 } }); + await wait(50); + await fix.whenStable(); + + // Without the trailing edge the settle event is dropped and the grid stays + // frozen at an intermediate startIndex while the scrollbar sits at the top. + expect(virtDir.state.startIndex).toBe(0); + expect(grid.gridAPI.get_row_by_index(0)).toBeDefined(); + }); }); }); From d603aa1a527546238dde02694dd3f5df6d9ac31d Mon Sep 17 00:00:00 2001 From: Arkan Ahmedov Date: Mon, 3 Aug 2026 11:30:47 +0300 Subject: [PATCH 3/4] test(grid): move throttle-trailing test to virtualization block, add bottom case Address review feedback: - Relocate out of the zoneless regressions block into "IgxGrid - virtualization tests"; the fix is in the RxJS throttle config and is zone-independent. - Use a fixture that overflows horizontally (30 columns in a 600px grid) and assert the horizontal scrollbar is present, matching the reported reproduction conditions. - Add a bottom-boundary case so the fast momentum scroll to the bottom is also covered (issue #17464). --- .../grids/grid/src/grid.component.spec.ts | 115 +++++++++++++----- 1 file changed, 83 insertions(+), 32 deletions(-) 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 33381e3ac72..71806d417b9 100644 --- a/projects/igniteui-angular/grids/grid/src/grid.component.spec.ts +++ b/projects/igniteui-angular/grids/grid/src/grid.component.spec.ts @@ -882,6 +882,73 @@ describe('IgxGrid Component Tests #grid', () => { expect(grid.verticalScrollContainer.getScroll().scrollTop).toBe(initialScroll); expect(grid.headerContainer.getScroll().scrollLeft).toBeGreaterThanOrEqual(2 * (initialHorScroll + 50)); })); + + describe('scroll throttle trailing edge', () => { + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [NoopAnimationsModule, IgxGridScrollThrottleComponent], + providers: [{ provide: SCROLL_THROTTLE_TIME_MULTIPLIER, useValue: 0 }] + }).compileComponents(); + })); + + // Drive scrollNotify directly (not programmatic scrollTop, whose async native events would mask a dropped-trailing regression) to exercise the throttle window deterministically. + it('should settle at the top row after a fast momentum scroll back to scrollTop 0', async () => { + const fix = TestBed.createComponent(IgxGridScrollThrottleComponent); + fix.detectChanges(); + await wait(50); + fix.detectChanges(); + const grid = fix.componentInstance.grid; + const virtDir = grid.verticalScrollContainer; + const scrollEl = virtDir.getScroll(); + const hScroll = grid.headerContainer.getScroll(); + const maxScroll = scrollEl.scrollHeight - scrollEl.clientHeight; + + // Guard the reproduction condition: the fixture must overflow horizontally. + expect(hScroll.scrollWidth).toBeGreaterThan(hScroll.clientWidth); + + // Move away from the top so the first rows are virtualized out of view. + grid.scrollNotify.next({ target: { scrollTop: maxScroll } }); + await wait(50); + fix.detectChanges(); + expect(virtDir.state.startIndex).toBeGreaterThan(0); + + // Momentum scroll back to top: intermediate on the leading edge, scrollTop = 0 settle only on the trailing edge. + grid.scrollNotify.next({ target: { scrollTop: Math.round(maxScroll / 2) } }); + grid.scrollNotify.next({ target: { scrollTop: 0 } }); + await wait(50); + fix.detectChanges(); + + // Without the trailing edge the settle is dropped and startIndex stays frozen mid-list. + expect(virtDir.state.startIndex).toBe(0); + expect(grid.gridAPI.get_row_by_index(0)).toBeDefined(); + }); + + it('should settle at the last row after a fast momentum scroll to the bottom', async () => { + const fix = TestBed.createComponent(IgxGridScrollThrottleComponent); + fix.detectChanges(); + await wait(50); + fix.detectChanges(); + const grid = fix.componentInstance.grid; + const virtDir = grid.verticalScrollContainer; + const scrollEl = virtDir.getScroll(); + const hScroll = grid.headerContainer.getScroll(); + const maxScroll = scrollEl.scrollHeight - scrollEl.clientHeight; + const lastIndex = fix.componentInstance.data.length - 1; + + expect(hScroll.scrollWidth).toBeGreaterThan(hScroll.clientWidth); + expect(virtDir.state.startIndex).toBe(0); + + // Momentum scroll top to bottom: intermediate on the leading edge, max-scroll settle only on the trailing edge. + grid.scrollNotify.next({ target: { scrollTop: Math.round(maxScroll / 2) } }); + grid.scrollNotify.next({ target: { scrollTop: maxScroll } }); + await wait(50); + fix.detectChanges(); + + // Without the trailing edge the settle is dropped and the last row is never brought into view. + expect(virtDir.state.startIndex + virtDir.state.chunkSize).toBeGreaterThanOrEqual(lastIndex + 1); + expect(grid.gridAPI.get_row_by_index(lastIndex)).toBeDefined(); + }); + }); }); describe('IgxGrid - default rendering for rows and columns', () => { @@ -2161,38 +2228,6 @@ describe('IgxGrid Component Tests #grid', () => { expect(grid.headerContainer.state.startIndex).toBeGreaterThan(0); }); - - it('should settle at the top row when a fast momentum scroll ends at scrollTop 0 (throttle trailing edge)', async () => { - const fix = TestBed.createComponent(ZonelessTallGridComponent); - fix.detectChanges(); - await fix.whenStable(); - const grid = fix.componentInstance.grid; - const virtDir = grid.verticalScrollContainer; - const scrollEl = virtDir.getScroll(); - const maxScroll = scrollEl.scrollHeight - scrollEl.clientHeight; - - // Move away from the top so the first rows are virtualized out of view. - grid.scrollNotify.next({ target: { scrollTop: maxScroll } }); - await wait(50); - await fix.whenStable(); - expect(virtDir.state.startIndex).toBeGreaterThan(0); - - // Simulate a fast momentum/inertia scroll back to the top: an intermediate - // position lands on the throttle's leading edge and the scrollTop = 0 settle - // arrives within the same throttle window, so it can only be delivered on the - // trailing edge. Feeding scrollNotify directly (instead of setting scrollTop) - // avoids the browser's async native scroll events - which all read the final - // scrollTop of 0 - from masking a dropped-trailing regression. - grid.scrollNotify.next({ target: { scrollTop: Math.round(maxScroll / 2) } }); - grid.scrollNotify.next({ target: { scrollTop: 0 } }); - await wait(50); - await fix.whenStable(); - - // Without the trailing edge the settle event is dropped and the grid stays - // frozen at an intermediate startIndex while the scrollbar sits at the top. - expect(virtDir.state.startIndex).toBe(0); - expect(grid.gridAPI.get_row_by_index(0)).toBeDefined(); - }); }); }); @@ -4177,3 +4212,19 @@ export class IgxGridPerformanceComponent implements AfterViewInit, OnInit { export class IgxGridNoDataComponent { @ViewChild(IgxGridComponent, { static: true }) public grid: IgxGridComponent; } + +@Component({ + template: ``, + imports: [IgxGridComponent] +}) +class IgxGridScrollThrottleComponent { + @ViewChild(IgxGridComponent, { static: true }) public grid: IgxGridComponent; + // 30 columns in a 600px-wide grid guarantee horizontal overflow, plus 200 rows for vertical virtualization. + public data = Array.from({ length: 200 }, (_row, rowIndex) => { + const record: Record = { ID: rowIndex }; + for (let col = 0; col < 30; col++) { + record[`Col${col}`] = `r${rowIndex}c${col}`; + } + return record; + }); +} From 29de4d62afccef0253ef2dcacee1a387e58c7060 Mon Sep 17 00:00:00 2001 From: Arkan Ahmedov Date: Mon, 3 Aug 2026 12:52:33 +0300 Subject: [PATCH 4/4] test(grid): make bottom-boundary settle assertion null-safe --- projects/igniteui-angular/grids/grid/src/grid.component.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 71806d417b9..7f08fa9d1a8 100644 --- a/projects/igniteui-angular/grids/grid/src/grid.component.spec.ts +++ b/projects/igniteui-angular/grids/grid/src/grid.component.spec.ts @@ -945,7 +945,7 @@ describe('IgxGrid Component Tests #grid', () => { fix.detectChanges(); // Without the trailing edge the settle is dropped and the last row is never brought into view. - expect(virtDir.state.startIndex + virtDir.state.chunkSize).toBeGreaterThanOrEqual(lastIndex + 1); + expect((virtDir.state.startIndex ?? 0) + (virtDir.state.chunkSize ?? 0)).toBeGreaterThanOrEqual(lastIndex + 1); expect(grid.gridAPI.get_row_by_index(lastIndex)).toBeDefined(); }); });