From 344a43fecab6d8add8c2a47355e2252e3a57ad41 Mon Sep 17 00:00:00 2001 From: Shane Date: Wed, 30 Jul 2025 10:23:00 -0700 Subject: [PATCH] feat(infinite-scroll): adding preserveRerenderScrollPosition property (#30566) Issue number: resolves internal --------- ## What is the current behavior? 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? 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 ## Other information **Current dev build**: ``` 8.6.6-dev.11753719591.13a5c65f ``` --- core/api.txt | 1 + core/src/components.d.ts | 10 ++ .../infinite-scroll/infinite-scroll.tsx | 76 ++++++++++- .../index.html | 129 ++++++++++++++++++ .../infinite-scroll.e2e.ts | 32 +++++ packages/angular/src/directives/proxies.ts | 4 +- .../standalone/src/directives/proxies.ts | 4 +- packages/vue/src/proxies.ts | 1 + 8 files changed, 252 insertions(+), 5 deletions(-) create mode 100644 core/src/components/infinite-scroll/test/preserve-rerender-scroll-position/index.html create mode 100644 core/src/components/infinite-scroll/test/preserve-rerender-scroll-position/infinite-scroll.e2e.ts diff --git a/core/api.txt b/core/api.txt index b183a34d9c..61396d23c7 100644 --- a/core/api.txt +++ b/core/api.txt @@ -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 diff --git a/core/src/components.d.ts b/core/src/components.d.ts index 72f1d518f1..0109f7c693 100644 --- a/core/src/components.d.ts +++ b/core/src/components.d.ts @@ -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. */ diff --git a/core/src/components/infinite-scroll/infinite-scroll.tsx b/core/src/components/infinite-scroll/infinite-scroll.tsx index 291cdebb02..e2ec9d4277 100644 --- a/core/src/components/infinite-scroll/infinite-scroll.tsx +++ b/core/src/components/infinite-scroll/infinite-scroll.tsx @@ -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; - 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; } } @@ -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 { + 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 { diff --git a/core/src/components/infinite-scroll/test/preserve-rerender-scroll-position/index.html b/core/src/components/infinite-scroll/test/preserve-rerender-scroll-position/index.html new file mode 100644 index 0000000000..7e2768a571 --- /dev/null +++ b/core/src/components/infinite-scroll/test/preserve-rerender-scroll-position/index.html @@ -0,0 +1,129 @@ + + + + + Infinite Scroll - Item Replacement + + + + + + + + + + + + + Infinite Scroll - Item Replacement + + + + + + + Title + + + +
Scroll the list to see the title collapse.
+ + + + + + + +
+
+ + + + diff --git a/core/src/components/infinite-scroll/test/preserve-rerender-scroll-position/infinite-scroll.e2e.ts b/core/src/components/infinite-scroll/test/preserve-rerender-scroll-position/infinite-scroll.e2e.ts new file mode 100644 index 0000000000..8605b580e0 --- /dev/null +++ b/core/src/components/infinite-scroll/test/preserve-rerender-scroll-position/infinite-scroll.e2e.ts @@ -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; + } + }); + }); +}); diff --git a/packages/angular/src/directives/proxies.ts b/packages/angular/src/directives/proxies.ts index ae0bdafac3..907390819e 100644 --- a/packages/angular/src/directives/proxies.ts +++ b/packages/angular/src/directives/proxies.ts @@ -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: '', // 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; diff --git a/packages/angular/standalone/src/directives/proxies.ts b/packages/angular/standalone/src/directives/proxies.ts index 590555cdc8..56b308e25b 100644 --- a/packages/angular/standalone/src/directives/proxies.ts +++ b/packages/angular/standalone/src/directives/proxies.ts @@ -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: '', // 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 { diff --git a/packages/vue/src/proxies.ts b/packages/vue/src/proxies.ts index 6e9dace0e2..0dd1a1f706 100644 --- a/packages/vue/src/proxies.ts +++ b/packages/vue/src/proxies.ts @@ -455,6 +455,7 @@ export const IonInfiniteScroll: StencilVueComponent = /*@ 'threshold', 'disabled', 'position', + 'preserveRerenderScrollPosition', 'ionInfinite' ], [ 'ionInfinite'