feat(infinite-scroll): adding preserveRerenderScrollPosition property (#30566)

Issue number: resolves internal

---------

<!-- Please do not submit updates to dependencies unless it fixes an
issue. -->

<!-- Please try to limit your pull request to one type (bugfix, feature,
etc). Submit multiple pull requests if needed. -->

## What is the current behavior?
<!-- Please describe the current behavior that you are modifying. -->

Currently, if you use infinite scroll and fully change out elements in
the DOM, you'll lose your scroll position. This can present as a race
condition in some frameworks, like React, but will present pretty
consistently in vanilla JavaScript. This happens because the browser is
removing the old elements from the DOM and adding the new ones, and
during that time the container holding the old elements will shrink and
the browser will adjust the top position to be the maximum of the new
container height.

## What is the new behavior?
<!-- Please describe the behavior or changes that are being added by
this PR. -->

With this new property (`preserveRerenderScrollPosition`) set, we will
loop through siblings of the infinite scroll and set their min-heights
to be their current heights before triggering the `ionInfinite` event,
then we clean up after complete is called by restoring their previous
min-heights or setting them to auto if there were none. This prevents
the container from resizing and the browser from losing the scroll
position.

## Does this introduce a breaking change?

- [ ] Yes
- [X] No

<!--
  If this introduces a breaking change:
1. Describe the impact and migration path for existing applications
below.
  2. Update the BREAKING.md file with the breaking change.
3. Add "BREAKING CHANGE: [...]" to the commit description when merging.
See
https://github.com/ionic-team/ionic-framework/blob/main/docs/CONTRIBUTING.md#footer
for more information.
-->


## Other information

<!-- Any other information that is important to this PR such as
screenshots of how the component looks before and after the change. -->

**Current dev build**: 
```
8.6.6-dev.11753719591.13a5c65f
```
This commit is contained in:
Shane
2025-07-30 10:23:00 -07:00
committed by GitHub
parent 7f904d0d6d
commit 344a43feca
8 changed files with 252 additions and 5 deletions

View File

@ -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>

View File

@ -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.
*/
@ -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.
*/

View File

@ -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
@ -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,
@ -136,7 +144,16 @@ export class InfiniteScroll implements ComponentInterface {
if (!this.didFire) {
this.isLoading = true;
this.didFire = true;
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;
}
}
@ -144,6 +161,55 @@ export class InfiniteScroll implements ComponentInterface {
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`
@ -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 {

View File

@ -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>

View File

@ -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;
}
});
});
});

View File

@ -927,7 +927,7 @@ export declare interface IonImg extends Components.IonImg {
@ProxyCmp({
inputs: ['disabled', 'mode', 'position', 'theme', 'threshold'],
inputs: ['disabled', 'mode', 'position', 'preserveRerenderScrollPosition', 'theme', 'threshold'],
methods: ['complete']
})
@Component({
@ -935,7 +935,7 @@ export declare interface IonImg extends Components.IonImg {
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;

View File

@ -952,7 +952,7 @@ 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({
@ -960,7 +960,7 @@ export declare interface IonImg extends Components.IonImg {
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 {

View File

@ -455,6 +455,7 @@ export const IonInfiniteScroll: StencilVueComponent<JSX.IonInfiniteScroll> = /*@
'threshold',
'disabled',
'position',
'preserveRerenderScrollPosition',
'ionInfinite'
], [
'ionInfinite'