Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 126 additions & 0 deletions projects/igniteui-angular/combo/src/combo/combo.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
2 changes: 1 addition & 1 deletion projects/igniteui-angular/virtual-scroll/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |

---
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,11 @@ export class IgxVirtualScrollComponent<T> implements OnDestroy {
private _previousItems: LoadedItems<T> | 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;

/**
Expand Down Expand Up @@ -552,6 +557,7 @@ export class IgxVirtualScrollComponent<T> implements OnDestroy {
this._visibleRange();
this._renderedItems();
this._engine.version();
this._attachTick();
untracked(() => {
this._scheduleItemMeasurement();
this._checkDataRequest();
Expand Down Expand Up @@ -856,6 +862,7 @@ export class IgxVirtualScrollComponent<T> implements OnDestroy {
if (!this._isLaidOut()) {
return;
}
this._wasLaidOut = true;

const size = this._isVertical() ? host.clientHeight : host.clientWidth;
if (size !== untracked(this._viewportSize)) {
Expand All @@ -872,6 +879,10 @@ export class IgxVirtualScrollComponent<T> 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);
});
Expand Down Expand Up @@ -1056,6 +1067,12 @@ export class IgxVirtualScrollComponent<T> 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;
Expand Down
Loading