diff --git a/projects/igniteui-angular/combo/src/combo/combo.component.spec.ts b/projects/igniteui-angular/combo/src/combo/combo.component.spec.ts index 41cc70f77e0..b1d590552c8 100644 --- a/projects/igniteui-angular/combo/src/combo/combo.component.spec.ts +++ b/projects/igniteui-angular/combo/src/combo/combo.component.spec.ts @@ -1761,6 +1761,132 @@ describe('igxCombo', () => { await settle(); }); + it('should preserve the loaded remote page without requests when closing and reopening', async () => { + await combo.virtualScrollContainer.scrollToIndex(40); + await settle(); + const request = host.requests[host.requests.length - 1]; + expect(request.startIndex).toBe(40); + host.complete(request); + await settle(); + + const state = { ...combo.virtualizationState }; + const page = combo.data; + const requestCount = host.requests.length; + + for (let cycle = 0; cycle < 3; cycle++) { + combo.close(); + // Give the detached viewport's ResizeObserver time to report the reset. + await new Promise(requestAnimationFrame); + await new Promise(requestAnimationFrame); + await settle(); + + expect(combo.collapsed).toBeTrue(); + expect(host.requests.length).toBe(requestCount); + expect(combo.virtualizationState).toEqual(state); + + combo.open(); + await settle(); + + expect(host.requests.length).toBe(requestCount); + expect(combo.data).toBe(page); + expect(rows().length).toBeGreaterThan(0); + expect(rowAt(rows()[0])).toBe(40); + rows().forEach(row => expect(row.textContent.trim()).toBe(`Product ${rowAt(row)}`)); + } + + // An actual scroll must still ask for the next page. + await combo.virtualScrollContainer.scrollToIndex(80); + await settle(); + expect(host.requests.length).toBe(requestCount + 1); + expect(host.requests[host.requests.length - 1].startIndex).toBe(80); + }); + + it('should keep a remote request valid when its response arrives while closed', async () => { + await combo.virtualScrollContainer.scrollToIndex(40); + await settle(); + const request = host.requests[host.requests.length - 1]; + expect(request.startIndex).toBe(40); + const requestCount = host.requests.length; + + combo.close(); + await new Promise(requestAnimationFrame); + await new Promise(requestAnimationFrame); + await settle(); + + expect(host.requests.length).toBe(requestCount); + expect(request.response.observed).toBeTrue(); + + host.complete(request); + await settle(); + expect(combo.data[0].id).toBe(40); + expect(combo.virtualScrollContainer.dataWindow().startIndex).toBe(40); + + combo.open(); + await settle(); + + expect(host.requests.length).toBe(requestCount); + expect(rowAt(rows()[0])).toBe(40); + rows().forEach(row => expect(row.textContent.trim()).toBe(`Product ${rowAt(row)}`)); + }); + + it('should request the first page when the list reopens at the top in the same task', async () => { + await combo.virtualScrollContainer.scrollToIndex(40); + await settle(); + host.complete(host.requests[host.requests.length - 1]); + await settle(); + + combo.close(); + await new Promise(requestAnimationFrame); + await new Promise(requestAnimationFrame); + await settle(); + + // Reopening at the top in one task, as a simple combo with an empty input does. + combo.open(); + void combo.virtualScrollContainer.scrollToIndex(0); + await new Promise(requestAnimationFrame); + await new Promise(requestAnimationFrame); + await settle(); + + const request = host.requests[host.requests.length - 1]; + expect(request.startIndex).toBe(0); + host.complete(request); + await settle(); + + expect(rows().length).toBeGreaterThan(0); + expect(rowAt(rows()[0])).toBe(0); + rows().forEach(row => expect(row.textContent.trim()).toBe(`Product ${rowAt(row)}`)); + }); + + it('should request the first page when scrolled to the top after reopening', async () => { + await combo.virtualScrollContainer.scrollToIndex(40); + await settle(); + host.complete(host.requests[host.requests.length - 1]); + await settle(); + const requestCount = host.requests.length; + + combo.close(); + await new Promise(requestAnimationFrame); + await new Promise(requestAnimationFrame); + await settle(); + combo.open(); + await new Promise(requestAnimationFrame); + await new Promise(requestAnimationFrame); + await settle(); + expect(host.requests.length).toBe(requestCount); + + // The top is the range the list computed while detached; it must still be requested. + await combo.virtualScrollContainer.scrollToIndex(0); + await settle(); + + expect(host.requests.length).toBe(requestCount + 1); + const request = host.requests[host.requests.length - 1]; + expect(request.startIndex).toBe(0); + host.complete(request); + await settle(); + expect(rowAt(rows()[0])).toBe(0); + rows().forEach(row => expect(row.textContent.trim()).toBe(`Product ${rowAt(row)}`)); + }); + it('should drop a reply to a range the list has already left', async () => { await combo.virtualScrollContainer.scrollToIndex(400); await settle(); diff --git a/projects/igniteui-angular/simple-combo/src/simple-combo/simple-combo.component.spec.ts b/projects/igniteui-angular/simple-combo/src/simple-combo/simple-combo.component.spec.ts index 65e78ef7a54..78de75146b0 100644 --- a/projects/igniteui-angular/simple-combo/src/simple-combo/simple-combo.component.spec.ts +++ b/projects/igniteui-angular/simple-combo/src/simple-combo/simple-combo.component.spec.ts @@ -3001,6 +3001,80 @@ describe('IgxSimpleCombo', () => { combo = fixture.componentInstance.instance; input = fixture.debugElement.query(By.css(`.${CSS_CLASS_COMBO_INPUTGROUP}`)); }); + it('should preserve the loaded remote page without requests when closing and reopening', async () => { + const settle = async () => { + fixture.detectChanges(); + await combo.virtualScrollContainer.layoutComplete; + await fixture.whenStable(); + fixture.detectChanges(); + }; + combo.open(); + await settle(); + const preload = spyOn(combo.dataPreLoad, 'emit').and.callThrough(); + await combo.virtualScrollContainer.scrollToIndex(40); + await settle(); + expect(preload).toHaveBeenCalled(); + expect(combo.data[0].id).toBe(40); + + const state = { ...combo.virtualizationState }; + const page = combo.data; + preload.calls.reset(); + + for (let cycle = 0; cycle < 3; cycle++) { + combo.close(); + await new Promise(requestAnimationFrame); + await new Promise(requestAnimationFrame); + await settle(); + expect(preload).not.toHaveBeenCalled(); + expect(combo.virtualizationState).toEqual(state); + + combo.open(); + await settle(); + expect(preload).not.toHaveBeenCalled(); + expect(combo.data).toBe(page); + const rows = fixture.debugElement.queryAll(By.css('igx-combo-item')); + expect(rows.length).toBeGreaterThan(0); + expect(rows[0].nativeElement.textContent.trim()).toBe('Product 40'); + } + + await combo.virtualScrollContainer.scrollToIndex(80); + await settle(); + expect(preload).toHaveBeenCalledTimes(1); + expect(combo.data[0].id).toBe(80); + }); + + it('should load the first page when the toggle button reopens a list closed by other means', async () => { + const settle = async () => { + fixture.detectChanges(); + await combo.virtualScrollContainer.layoutComplete; + await fixture.whenStable(); + fixture.detectChanges(); + }; + combo.open(); + await settle(); + await combo.virtualScrollContainer.scrollToIndex(40); + await settle(); + expect(combo.data[0].id).toBe(40); + + // Closed without the toggle button, as Escape or an outside click does. + combo.close(); + await new Promise(requestAnimationFrame); + await new Promise(requestAnimationFrame); + await settle(); + + // With an empty input, onClick reopens the list at the top. + fixture.debugElement.query(By.css(`.${CSS_CLASS_TOGGLEBUTTON}`)).nativeElement.click(); + await new Promise(requestAnimationFrame); + await new Promise(requestAnimationFrame); + await settle(); + + expect(combo.collapsed).toBeFalse(); + expect(combo.data[0].id).toBe(0); + const rows = fixture.debugElement.queryAll(By.css('igx-combo-item')); + expect(rows.length).toBeGreaterThan(0); + expect(rows[0].nativeElement.textContent.trim()).toBe('Product 0'); + }); + it('should prevent registration of remote entries when selectionChanging is cancelled', () => { spyOn(combo.selectionChanging, 'emit').and.callFake((event: IComboSelectionChangingEventArgs) => event.cancel = true); combo.open(); diff --git a/projects/igniteui-angular/virtual-scroll/README.md b/projects/igniteui-angular/virtual-scroll/README.md index 0089ef5d53d..c855c9af134 100644 --- a/projects/igniteui-angular/virtual-scroll/README.md +++ b/projects/igniteui-angular/virtual-scroll/README.md @@ -118,7 +118,7 @@ Unmeasured items then take the average measured size, so the scrollbar tracks re | Output | Payload | Description | |---|---|---| -| `stateChange` | `VirtualScrollState` | Emitted when the virtual window changes. It reports the range the viewport wants, over-scan included; with `dataWindow` bound that range can reach past the loaded page, so it is not always the set of rows in the DOM. Consecutive renders that produce an identical window are not re-emitted. | +| `stateChange` | `VirtualScrollState` | Emitted when the virtual window changes. It reports the range the viewport wants, over-scan included; with `dataWindow` bound that range can reach past the loaded page, so it is not always the set of rows in the DOM. Consecutive renders that produce an identical window are not re-emitted. Once the host has been laid out, it is not emitted while the host is detached from the document, for example inside a closed drop-down; on re-attach the current window is emitted if it differs from the last one reported. | | `dataRequest` | `VirtualScrollDataRequest` | Emitted when the rendered window comes within a few items of the end of `data`. Use this to implement infinite / remote scrolling. | --- diff --git a/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.spec.ts b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.spec.ts index 79c0f66d83a..e70c46aff6a 100644 --- a/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.spec.ts +++ b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.spec.ts @@ -1230,6 +1230,38 @@ describe('IgxVirtualScrollComponent', () => { expect(vsIndices(popup)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8]); }); + it('should not report a window while detached when revealing it changed nothing', async () => { + // The hint matches the 300px host and the rows match the 50px estimate, so revealing changes no signal. + await createPopup(300); + reveal(); + await settleUntil(() => vsItems(popup).length === 9); + const frame = () => new Promise((resolve) => requestAnimationFrame(resolve)); + // Let the shown host be laid out and reported before it is detached. + await frame(); + await frame(); + + const reattach = detachHost(); + await frame(); + await frame(); + + const reported: VirtualScrollState[] = []; + const subscription = popupScroll.stateChange.subscribe((state) => reported.push(state)); + popupHost.items.set(generateItems(3)); + popup.detectChanges(); + await popup.whenStable(); + await frame(); + await frame(); + popup.detectChanges(); + + expect(reported.length).toBe(0); + + reattach(); + await settleUntil(() => reported.length > 0); + expect(reported.length).toBe(1); + expect(reported[0].endIndex).toBe(2); + subscription.unsubscribe(); + }); + it('should render from the top once re-attached when scrollToIndex(0) ran while detached', async () => { await createPopup(300); reveal(); diff --git a/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.ts b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.ts index be7f73a4f33..2fb8915bed2 100644 --- a/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.ts +++ b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.ts @@ -188,6 +188,11 @@ export class IgxVirtualScrollComponent implements OnDestroy { private _previousItems: LoadedItems | undefined; private _lastEmittedState: VirtualScrollState | null = null; + /** Whether the host has been laid out, so a later detach is a removal rather than the pre-render state. */ + private _wasLaidOut = false; + /** Set when a report was skipped while detached, so re-attaching reports the window once. */ + private _skippedWhileDetached = false; + private readonly _attachTick = signal(0); private _hasPendingDataRequest = false; /** @@ -552,6 +557,7 @@ export class IgxVirtualScrollComponent implements OnDestroy { this._visibleRange(); this._renderedItems(); this._engine.version(); + this._attachTick(); untracked(() => { this._scheduleItemMeasurement(); this._checkDataRequest(); @@ -856,6 +862,7 @@ export class IgxVirtualScrollComponent implements OnDestroy { if (!this._isLaidOut()) { return; } + this._wasLaidOut = true; const size = this._isVertical() ? host.clientHeight : host.clientWidth; if (size !== untracked(this._viewportSize)) { @@ -872,6 +879,10 @@ export class IgxVirtualScrollComponent implements OnDestroy { // Detaching the host resets its scroll position without a scroll event. Following it // while detached renders the window the host comes back with, so it does not show blank. this._syncScrollPosition(); + if (this._skippedWhileDetached && this._isLaidOut()) { + this._skippedWhileDetached = false; + this._attachTick.update((v) => v + 1); + } }); this._viewportResizeObserver.observe(this._hostRef.nativeElement); }); @@ -1056,6 +1067,12 @@ export class IgxVirtualScrollComponent implements OnDestroy { * change. */ private _emitStateChange(): void { + // A host detached after layout follows its reset offset for rendering only; that window is not reported. + if (this._wasLaidOut && !this._hostRef.nativeElement.isConnected) { + this._skippedWhileDetached = true; + return; + } + const { startIndex, endIndex } = untracked(this._visibleRange); if (endIndex < startIndex) { return;