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
1 change: 1 addition & 0 deletions core/api.txt
Original file line number Diff line number Diff line change
Expand Up @@ -919,6 +919,7 @@ ion-infinite-scroll,none
ion-infinite-scroll,prop,disabled,boolean,false,false,false
ion-infinite-scroll,prop,mode,"ios" | "md",undefined,false,false
ion-infinite-scroll,prop,position,"bottom" | "top",'bottom',false,false
ion-infinite-scroll,prop,preserveRerenderScrollPosition,boolean,false,false,false
ion-infinite-scroll,prop,theme,"ios" | "md" | "ionic",undefined,false,false
ion-infinite-scroll,prop,threshold,string,'15%',false,false
ion-infinite-scroll,method,complete,complete() => Promise<void>
Expand Down
10 changes: 10 additions & 0 deletions core/src/components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1505,6 +1505,11 @@ export namespace Components {
* @default 'bottom'
*/
"position": 'top' | 'bottom';
/**
* If `true`, the infinite scroll will preserve the scroll position when the content is re-rendered. This is useful when the content is re-rendered with new keys, and the scroll position should be preserved.
* @default false
*/
"preserveRerenderScrollPosition": boolean;
/**
* The theme determines the visual appearance of the component.
*/
Expand Down Expand Up @@ -7436,6 +7441,11 @@ declare namespace LocalJSX {
* @default 'bottom'
*/
"position"?: 'top' | 'bottom';
/**
* If `true`, the infinite scroll will preserve the scroll position when the content is re-rendered. This is useful when the content is re-rendered with new keys, and the scroll position should be preserved.
* @default false
*/
"preserveRerenderScrollPosition"?: boolean;
/**
* The theme determines the visual appearance of the component.
*/
Expand Down
76 changes: 75 additions & 1 deletion core/src/components/infinite-scroll/infinite-scroll.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export class InfiniteScroll implements ComponentInterface {
private thrPx = 0;
private thrPc = 0;
private scrollEl?: HTMLElement;
private minHeightLocked = false;

/**
* didFire exists so that ionInfinite
Expand Down Expand Up @@ -80,6 +81,13 @@ export class InfiniteScroll implements ComponentInterface {
*/
@Prop() position: 'top' | 'bottom' = 'bottom';

/**
* If `true`, the infinite scroll will preserve the scroll position
* when the content is re-rendered. This is useful when the content is
* re-rendered with new keys, and the scroll position should be preserved.
*/
@Prop() preserveRerenderScrollPosition: boolean = false;

/**
* Emitted when the scroll reaches
* the threshold distance. From within your infinite handler,
Expand Down Expand Up @@ -136,14 +144,72 @@ export class InfiniteScroll implements ComponentInterface {
if (!this.didFire) {
this.isLoading = true;
this.didFire = true;
this.ionInfinite.emit();

if (this.preserveRerenderScrollPosition) {
// Lock the min height of the siblings of the infinite scroll
// if we are preserving the rerender scroll position
this.lockSiblingMinHeight(true).then(() => {
this.ionInfinite.emit();
});
} else {
this.ionInfinite.emit();
}
return 3;
}
}

return 4;
};

/**
* Loop through our sibling elements and lock or unlock their min height.
* This keeps our siblings, for example `ion-list`, the same height as their
* content currently is, so when it loads new data and the DOM removes the old
* data, the height of the container doesn't change and we don't lose our scroll position.
*
* We preserve existing min-height values, if they're set, so we don't erase what
* has been previously set by the user when we restore after complete is called.
*/
private lockSiblingMinHeight(lock: boolean): Promise<void> {
return new Promise((resolve) => {
const siblings = this.el.parentElement?.children || [];
const writes: (() => void)[] = [];

for (const sibling of siblings) {
// Loop through all the siblings of the infinite scroll, but ignore ourself
if (sibling !== this.el && sibling instanceof HTMLElement) {
if (lock) {
const elementHeight = sibling.getBoundingClientRect().height;
writes.push(() => {
if (this.minHeightLocked) {
// The previous min height is from us locking it before, so we can disregard it
// We still need to lock the min height if we're already locked, though, because
// the user could have triggered a new load before we've finished the previous one.
const previousMinHeight = sibling.style.minHeight;
if (previousMinHeight) {
sibling.style.setProperty('--ion-previous-min-height', previousMinHeight);
}
}
sibling.style.minHeight = `${elementHeight}px`;
});
} else {
writes.push(() => {
const previousMinHeight = sibling.style.getPropertyValue('--ion-previous-min-height');
sibling.style.minHeight = previousMinHeight || 'auto';
sibling.style.removeProperty('--ion-previous-min-height');
});
}
}
}

writeTask(() => {
writes.forEach((w) => w());
this.minHeightLocked = lock;
resolve();
});
});
}

/**
* Call `complete()` within the `ionInfinite` output event handler when
* your async operation has completed. For example, the `loading`
Expand Down Expand Up @@ -208,6 +274,14 @@ export class InfiniteScroll implements ComponentInterface {
} else {
this.didFire = false;
}

// Unlock the min height of the siblings of the infinite scroll
// if we are preserving the rerender scroll position
if (this.preserveRerenderScrollPosition) {
setTimeout(async () => {
await this.lockSiblingMinHeight(false);
}, 100);
}
}

private canStart(): boolean {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<meta charset="UTF-8" />
<title>Infinite Scroll - Item Replacement</title>
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no"
/>
<link href="../../../../../css/ionic.bundle.css" rel="stylesheet" />
<link href="../../../../../scripts/testing/styles.css" rel="stylesheet" />
<script src="../../../../../scripts/testing/scripts.js"></script>
<script nomodule src="../../../../../dist/ionic/ionic.js"></script>
<script type="module" src="../../../../../dist/ionic/ionic.esm.js"></script>
</head>

<body>
<ion-app>
<ion-header>
<ion-toolbar>
<ion-title>Infinite Scroll - Item Replacement</ion-title>
</ion-toolbar>
</ion-header>

<ion-content class="ion-padding" id="content">
<IonHeader collapse="condense">
<IonToolbar>
<IonTitle size="large">Title</IonTitle>
</IonToolbar>
</IonHeader>

<div className="ion-padding">Scroll the list to see the title collapse.</div>

<ion-list id="list"></ion-list>

<ion-infinite-scroll threshold="100px" id="infinite-scroll" preserve-rerender-scroll-position>
<ion-infinite-scroll-content loading-spinner="crescent" loading-text="Loading more data...">
</ion-infinite-scroll-content>
</ion-infinite-scroll>
</ion-content>
</ion-app>

<script>
const list = document.getElementById('list');
const infiniteScroll = document.getElementById('infinite-scroll');
const content = document.getElementById('content');
const scrollPositionDiv = document.getElementById('scroll-position');
const modeDiv = document.getElementById('mode');
let loading = false;
let itemCount = 0;
let generationCount = 0;

// Track scroll position for debugging
content.addEventListener('ionScroll', () => {
const scrollTop = content.scrollTop;
scrollPositionDiv.textContent = `Scroll Position: ${scrollTop}`;
});

infiniteScroll.addEventListener('ionInfinite', async function () {
// Save current scroll position before replacement
const currentScrollTop = content.scrollTop;
window.currentScrollBeforeReplace = currentScrollTop;
console.log('loading', loading);
if (loading) {
infiniteScroll.complete();
return;
}
loading = true;

replaceAllItems();
infiniteScroll.complete();

window.dispatchEvent(
new CustomEvent('ionInfiniteComplete', {
detail: {
scrollTopBefore: currentScrollTop,
scrollTopAfter: content.scrollTop,
generation: generationCount,
mode: 'normal',
},
})
);

setTimeout(() => {
console.log('setting loading to false');
loading = false;
});
});

function replaceAllItems() {
console.log('replaceAllItems');
// This simulates what happens in React when all items get new keys
// Clear all existing items
list.innerHTML = '';

generationCount++;
const generation = generationCount;

// Add new items with new "keys" (different content/identifiers)
// Start with more items to ensure scrollable content
const totalItems = generation === 1 ? 50 : 30 + generation * 20;
itemCount = 0;

for (let i = 0; i < totalItems; i++) {
const el = document.createElement('ion-item');
el.setAttribute('data-key', `gen-${generation}-item-${i}`);
el.setAttribute('data-generation', generation);
el.textContent = `Gen ${generation} - Item ${
i + 1
} - Additional content to make this item taller and ensure scrolling`;
el.id = `item-gen-${generation}-${i}`;
list.appendChild(el);
itemCount++;
}
}

function wait(time) {
return new Promise((resolve) => {
setTimeout(() => {
resolve();
}, time);
});
}

// Initial load
replaceAllItems();
</script>
</body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { expect } from '@playwright/test';
import { configs, test } from '@utils/test/playwright';

test.setTimeout(100000);

configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => {
test.describe(title('infinite-scroll: preserve rerender scroll position'), () => {
test('should load more items when scrolled to the bottom', async ({ page }) => {
await page.goto('/src/components/infinite-scroll/test/preserve-rerender-scroll-position', config);

const ionInfiniteComplete = await page.spyOnEvent('ionInfiniteComplete');
const content = page.locator('ion-content');
const items = page.locator('ion-item');
const innerScroll = page.locator('.inner-scroll');
expect(await items.count()).toBe(50);

let previousScrollTop = 0;
for (let i = 0; i < 30; i++) {
await content.evaluate((el: HTMLIonContentElement) => el.scrollToBottom(0));
const currentScrollTop = await innerScroll.evaluate((el: HTMLIonContentElement) => el.scrollTop);
expect(currentScrollTop).toBeGreaterThan(previousScrollTop);
await ionInfiniteComplete.next();
const newScrollTop = await innerScroll.evaluate((el: HTMLIonContentElement) => el.scrollTop);
console.log(`Scroll position should be preserved after ${i + 1} iterations`, newScrollTop, previousScrollTop);
expect(newScrollTop, `Scroll position should be preserved after ${i + 1} iterations`).toBeGreaterThanOrEqual(
previousScrollTop
);
previousScrollTop = currentScrollTop;
}
});
});
});
4 changes: 2 additions & 2 deletions packages/angular/src/directives/proxies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -927,15 +927,15 @@ export declare interface IonImg extends Components.IonImg {


@ProxyCmp({
inputs: ['disabled', 'mode', 'position', 'theme', 'threshold'],
inputs: ['disabled', 'mode', 'position', 'preserveRerenderScrollPosition', 'theme', 'threshold'],
methods: ['complete']
})
@Component({
selector: 'ion-infinite-scroll',
changeDetection: ChangeDetectionStrategy.OnPush,
template: '<ng-content></ng-content>',
// eslint-disable-next-line @angular-eslint/no-inputs-metadata-property
inputs: ['disabled', 'mode', 'position', 'theme', 'threshold'],
inputs: ['disabled', 'mode', 'position', 'preserveRerenderScrollPosition', 'theme', 'threshold'],
})
export class IonInfiniteScroll {
protected el: HTMLIonInfiniteScrollElement;
Expand Down
4 changes: 2 additions & 2 deletions packages/angular/standalone/src/directives/proxies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -952,15 +952,15 @@ export declare interface IonImg extends Components.IonImg {

@ProxyCmp({
defineCustomElementFn: defineIonInfiniteScroll,
inputs: ['disabled', 'mode', 'position', 'theme', 'threshold'],
inputs: ['disabled', 'mode', 'position', 'preserveRerenderScrollPosition', 'theme', 'threshold'],
methods: ['complete']
})
@Component({
selector: 'ion-infinite-scroll',
changeDetection: ChangeDetectionStrategy.OnPush,
template: '<ng-content></ng-content>',
// eslint-disable-next-line @angular-eslint/no-inputs-metadata-property
inputs: ['disabled', 'mode', 'position', 'theme', 'threshold'],
inputs: ['disabled', 'mode', 'position', 'preserveRerenderScrollPosition', 'theme', 'threshold'],
standalone: true
})
export class IonInfiniteScroll {
Expand Down
1 change: 1 addition & 0 deletions packages/vue/src/proxies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -455,6 +455,7 @@ export const IonInfiniteScroll: StencilVueComponent<JSX.IonInfiniteScroll> = /*@
'threshold',
'disabled',
'position',
'preserveRerenderScrollPosition',
'ionInfinite'
], [
'ionInfinite'
Expand Down
Loading