Skip to content

Commit 344a43f

Browse files
authored
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 ```
1 parent 7f904d0 commit 344a43f

File tree

8 files changed

+252
-5
lines changed

8 files changed

+252
-5
lines changed

core/api.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -919,6 +919,7 @@ ion-infinite-scroll,none
919919
ion-infinite-scroll,prop,disabled,boolean,false,false,false
920920
ion-infinite-scroll,prop,mode,"ios" | "md",undefined,false,false
921921
ion-infinite-scroll,prop,position,"bottom" | "top",'bottom',false,false
922+
ion-infinite-scroll,prop,preserveRerenderScrollPosition,boolean,false,false,false
922923
ion-infinite-scroll,prop,theme,"ios" | "md" | "ionic",undefined,false,false
923924
ion-infinite-scroll,prop,threshold,string,'15%',false,false
924925
ion-infinite-scroll,method,complete,complete() => Promise<void>

core/src/components.d.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1505,6 +1505,11 @@ export namespace Components {
15051505
* @default 'bottom'
15061506
*/
15071507
"position": 'top' | 'bottom';
1508+
/**
1509+
* 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.
1510+
* @default false
1511+
*/
1512+
"preserveRerenderScrollPosition": boolean;
15081513
/**
15091514
* The theme determines the visual appearance of the component.
15101515
*/
@@ -7436,6 +7441,11 @@ declare namespace LocalJSX {
74367441
* @default 'bottom'
74377442
*/
74387443
"position"?: 'top' | 'bottom';
7444+
/**
7445+
* 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.
7446+
* @default false
7447+
*/
7448+
"preserveRerenderScrollPosition"?: boolean;
74397449
/**
74407450
* The theme determines the visual appearance of the component.
74417451
*/

core/src/components/infinite-scroll/infinite-scroll.tsx

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export class InfiniteScroll implements ComponentInterface {
1616
private thrPx = 0;
1717
private thrPc = 0;
1818
private scrollEl?: HTMLElement;
19+
private minHeightLocked = false;
1920

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

84+
/**
85+
* If `true`, the infinite scroll will preserve the scroll position
86+
* when the content is re-rendered. This is useful when the content is
87+
* re-rendered with new keys, and the scroll position should be preserved.
88+
*/
89+
@Prop() preserveRerenderScrollPosition: boolean = false;
90+
8391
/**
8492
* Emitted when the scroll reaches
8593
* the threshold distance. From within your infinite handler,
@@ -136,14 +144,72 @@ export class InfiniteScroll implements ComponentInterface {
136144
if (!this.didFire) {
137145
this.isLoading = true;
138146
this.didFire = true;
139-
this.ionInfinite.emit();
147+
148+
if (this.preserveRerenderScrollPosition) {
149+
// Lock the min height of the siblings of the infinite scroll
150+
// if we are preserving the rerender scroll position
151+
this.lockSiblingMinHeight(true).then(() => {
152+
this.ionInfinite.emit();
153+
});
154+
} else {
155+
this.ionInfinite.emit();
156+
}
140157
return 3;
141158
}
142159
}
143160

144161
return 4;
145162
};
146163

164+
/**
165+
* Loop through our sibling elements and lock or unlock their min height.
166+
* This keeps our siblings, for example `ion-list`, the same height as their
167+
* content currently is, so when it loads new data and the DOM removes the old
168+
* data, the height of the container doesn't change and we don't lose our scroll position.
169+
*
170+
* We preserve existing min-height values, if they're set, so we don't erase what
171+
* has been previously set by the user when we restore after complete is called.
172+
*/
173+
private lockSiblingMinHeight(lock: boolean): Promise<void> {
174+
return new Promise((resolve) => {
175+
const siblings = this.el.parentElement?.children || [];
176+
const writes: (() => void)[] = [];
177+
178+
for (const sibling of siblings) {
179+
// Loop through all the siblings of the infinite scroll, but ignore ourself
180+
if (sibling !== this.el && sibling instanceof HTMLElement) {
181+
if (lock) {
182+
const elementHeight = sibling.getBoundingClientRect().height;
183+
writes.push(() => {
184+
if (this.minHeightLocked) {
185+
// The previous min height is from us locking it before, so we can disregard it
186+
// We still need to lock the min height if we're already locked, though, because
187+
// the user could have triggered a new load before we've finished the previous one.
188+
const previousMinHeight = sibling.style.minHeight;
189+
if (previousMinHeight) {
190+
sibling.style.setProperty('--ion-previous-min-height', previousMinHeight);
191+
}
192+
}
193+
sibling.style.minHeight = `${elementHeight}px`;
194+
});
195+
} else {
196+
writes.push(() => {
197+
const previousMinHeight = sibling.style.getPropertyValue('--ion-previous-min-height');
198+
sibling.style.minHeight = previousMinHeight || 'auto';
199+
sibling.style.removeProperty('--ion-previous-min-height');
200+
});
201+
}
202+
}
203+
}
204+
205+
writeTask(() => {
206+
writes.forEach((w) => w());
207+
this.minHeightLocked = lock;
208+
resolve();
209+
});
210+
});
211+
}
212+
147213
/**
148214
* Call `complete()` within the `ionInfinite` output event handler when
149215
* your async operation has completed. For example, the `loading`
@@ -208,6 +274,14 @@ export class InfiniteScroll implements ComponentInterface {
208274
} else {
209275
this.didFire = false;
210276
}
277+
278+
// Unlock the min height of the siblings of the infinite scroll
279+
// if we are preserving the rerender scroll position
280+
if (this.preserveRerenderScrollPosition) {
281+
setTimeout(async () => {
282+
await this.lockSiblingMinHeight(false);
283+
}, 100);
284+
}
211285
}
212286

213287
private canStart(): boolean {
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
<!DOCTYPE html>
2+
<html lang="en" dir="ltr">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<title>Infinite Scroll - Item Replacement</title>
6+
<meta
7+
name="viewport"
8+
content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no"
9+
/>
10+
<link href="../../../../../css/ionic.bundle.css" rel="stylesheet" />
11+
<link href="../../../../../scripts/testing/styles.css" rel="stylesheet" />
12+
<script src="../../../../../scripts/testing/scripts.js"></script>
13+
<script nomodule src="../../../../../dist/ionic/ionic.js"></script>
14+
<script type="module" src="../../../../../dist/ionic/ionic.esm.js"></script>
15+
</head>
16+
17+
<body>
18+
<ion-app>
19+
<ion-header>
20+
<ion-toolbar>
21+
<ion-title>Infinite Scroll - Item Replacement</ion-title>
22+
</ion-toolbar>
23+
</ion-header>
24+
25+
<ion-content class="ion-padding" id="content">
26+
<IonHeader collapse="condense">
27+
<IonToolbar>
28+
<IonTitle size="large">Title</IonTitle>
29+
</IonToolbar>
30+
</IonHeader>
31+
32+
<div className="ion-padding">Scroll the list to see the title collapse.</div>
33+
34+
<ion-list id="list"></ion-list>
35+
36+
<ion-infinite-scroll threshold="100px" id="infinite-scroll" preserve-rerender-scroll-position>
37+
<ion-infinite-scroll-content loading-spinner="crescent" loading-text="Loading more data...">
38+
</ion-infinite-scroll-content>
39+
</ion-infinite-scroll>
40+
</ion-content>
41+
</ion-app>
42+
43+
<script>
44+
const list = document.getElementById('list');
45+
const infiniteScroll = document.getElementById('infinite-scroll');
46+
const content = document.getElementById('content');
47+
const scrollPositionDiv = document.getElementById('scroll-position');
48+
const modeDiv = document.getElementById('mode');
49+
let loading = false;
50+
let itemCount = 0;
51+
let generationCount = 0;
52+
53+
// Track scroll position for debugging
54+
content.addEventListener('ionScroll', () => {
55+
const scrollTop = content.scrollTop;
56+
scrollPositionDiv.textContent = `Scroll Position: ${scrollTop}`;
57+
});
58+
59+
infiniteScroll.addEventListener('ionInfinite', async function () {
60+
// Save current scroll position before replacement
61+
const currentScrollTop = content.scrollTop;
62+
window.currentScrollBeforeReplace = currentScrollTop;
63+
console.log('loading', loading);
64+
if (loading) {
65+
infiniteScroll.complete();
66+
return;
67+
}
68+
loading = true;
69+
70+
replaceAllItems();
71+
infiniteScroll.complete();
72+
73+
window.dispatchEvent(
74+
new CustomEvent('ionInfiniteComplete', {
75+
detail: {
76+
scrollTopBefore: currentScrollTop,
77+
scrollTopAfter: content.scrollTop,
78+
generation: generationCount,
79+
mode: 'normal',
80+
},
81+
})
82+
);
83+
84+
setTimeout(() => {
85+
console.log('setting loading to false');
86+
loading = false;
87+
});
88+
});
89+
90+
function replaceAllItems() {
91+
console.log('replaceAllItems');
92+
// This simulates what happens in React when all items get new keys
93+
// Clear all existing items
94+
list.innerHTML = '';
95+
96+
generationCount++;
97+
const generation = generationCount;
98+
99+
// Add new items with new "keys" (different content/identifiers)
100+
// Start with more items to ensure scrollable content
101+
const totalItems = generation === 1 ? 50 : 30 + generation * 20;
102+
itemCount = 0;
103+
104+
for (let i = 0; i < totalItems; i++) {
105+
const el = document.createElement('ion-item');
106+
el.setAttribute('data-key', `gen-${generation}-item-${i}`);
107+
el.setAttribute('data-generation', generation);
108+
el.textContent = `Gen ${generation} - Item ${
109+
i + 1
110+
} - Additional content to make this item taller and ensure scrolling`;
111+
el.id = `item-gen-${generation}-${i}`;
112+
list.appendChild(el);
113+
itemCount++;
114+
}
115+
}
116+
117+
function wait(time) {
118+
return new Promise((resolve) => {
119+
setTimeout(() => {
120+
resolve();
121+
}, time);
122+
});
123+
}
124+
125+
// Initial load
126+
replaceAllItems();
127+
</script>
128+
</body>
129+
</html>
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { expect } from '@playwright/test';
2+
import { configs, test } from '@utils/test/playwright';
3+
4+
test.setTimeout(100000);
5+
6+
configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => {
7+
test.describe(title('infinite-scroll: preserve rerender scroll position'), () => {
8+
test('should load more items when scrolled to the bottom', async ({ page }) => {
9+
await page.goto('/src/components/infinite-scroll/test/preserve-rerender-scroll-position', config);
10+
11+
const ionInfiniteComplete = await page.spyOnEvent('ionInfiniteComplete');
12+
const content = page.locator('ion-content');
13+
const items = page.locator('ion-item');
14+
const innerScroll = page.locator('.inner-scroll');
15+
expect(await items.count()).toBe(50);
16+
17+
let previousScrollTop = 0;
18+
for (let i = 0; i < 30; i++) {
19+
await content.evaluate((el: HTMLIonContentElement) => el.scrollToBottom(0));
20+
const currentScrollTop = await innerScroll.evaluate((el: HTMLIonContentElement) => el.scrollTop);
21+
expect(currentScrollTop).toBeGreaterThan(previousScrollTop);
22+
await ionInfiniteComplete.next();
23+
const newScrollTop = await innerScroll.evaluate((el: HTMLIonContentElement) => el.scrollTop);
24+
console.log(`Scroll position should be preserved after ${i + 1} iterations`, newScrollTop, previousScrollTop);
25+
expect(newScrollTop, `Scroll position should be preserved after ${i + 1} iterations`).toBeGreaterThanOrEqual(
26+
previousScrollTop
27+
);
28+
previousScrollTop = currentScrollTop;
29+
}
30+
});
31+
});
32+
});

packages/angular/src/directives/proxies.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -927,15 +927,15 @@ export declare interface IonImg extends Components.IonImg {
927927

928928

929929
@ProxyCmp({
930-
inputs: ['disabled', 'mode', 'position', 'theme', 'threshold'],
930+
inputs: ['disabled', 'mode', 'position', 'preserveRerenderScrollPosition', 'theme', 'threshold'],
931931
methods: ['complete']
932932
})
933933
@Component({
934934
selector: 'ion-infinite-scroll',
935935
changeDetection: ChangeDetectionStrategy.OnPush,
936936
template: '<ng-content></ng-content>',
937937
// eslint-disable-next-line @angular-eslint/no-inputs-metadata-property
938-
inputs: ['disabled', 'mode', 'position', 'theme', 'threshold'],
938+
inputs: ['disabled', 'mode', 'position', 'preserveRerenderScrollPosition', 'theme', 'threshold'],
939939
})
940940
export class IonInfiniteScroll {
941941
protected el: HTMLIonInfiniteScrollElement;

packages/angular/standalone/src/directives/proxies.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -952,15 +952,15 @@ export declare interface IonImg extends Components.IonImg {
952952

953953
@ProxyCmp({
954954
defineCustomElementFn: defineIonInfiniteScroll,
955-
inputs: ['disabled', 'mode', 'position', 'theme', 'threshold'],
955+
inputs: ['disabled', 'mode', 'position', 'preserveRerenderScrollPosition', 'theme', 'threshold'],
956956
methods: ['complete']
957957
})
958958
@Component({
959959
selector: 'ion-infinite-scroll',
960960
changeDetection: ChangeDetectionStrategy.OnPush,
961961
template: '<ng-content></ng-content>',
962962
// eslint-disable-next-line @angular-eslint/no-inputs-metadata-property
963-
inputs: ['disabled', 'mode', 'position', 'theme', 'threshold'],
963+
inputs: ['disabled', 'mode', 'position', 'preserveRerenderScrollPosition', 'theme', 'threshold'],
964964
standalone: true
965965
})
966966
export class IonInfiniteScroll {

packages/vue/src/proxies.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -455,6 +455,7 @@ export const IonInfiniteScroll: StencilVueComponent<JSX.IonInfiniteScroll> = /*@
455455
'threshold',
456456
'disabled',
457457
'position',
458+
'preserveRerenderScrollPosition',
458459
'ionInfinite'
459460
], [
460461
'ionInfinite'

0 commit comments

Comments
 (0)