From 0e4473f93dba7a7746780d38fefa1abd6f2f28c1 Mon Sep 17 00:00:00 2001 From: ShaneK Date: Wed, 23 Jul 2025 08:17:05 -0700 Subject: [PATCH 1/5] feat(infinite-scroll): adding preserveRerenderScrollPosition property that allows infinite scroll to keep the scroll position during a full re-render of its contents --- core/api.txt | 1 + core/src/components.d.ts | 10 ++ .../infinite-scroll/infinite-scroll.tsx | 52 ++++++- .../test/item-replacement/index.html | 130 ++++++++++++++++++ packages/angular/src/directives/proxies.ts | 4 +- .../standalone/src/directives/proxies.ts | 4 +- packages/vue/src/proxies.ts | 1 + 7 files changed, 195 insertions(+), 7 deletions(-) create mode 100644 core/src/components/infinite-scroll/test/item-replacement/index.html diff --git a/core/api.txt b/core/api.txt index b183a34d9cd..61396d23c7d 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 72f1d518f19..0109f7c6932 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 291cdebb022..17ba44e18ab 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 contentEl: HTMLElement | null = null; /** * 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, @@ -89,12 +97,12 @@ export class InfiniteScroll implements ComponentInterface { @Event() ionInfinite!: EventEmitter; async connectedCallback() { - const contentEl = findClosestIonContent(this.el); - if (!contentEl) { + this.contentEl = findClosestIonContent(this.el); + if (!this.contentEl) { printIonContentErrorMsg(this.el); return; } - this.scrollEl = await getScrollElement(contentEl); + this.scrollEl = await getScrollElement(this.contentEl); this.thresholdChanged(); this.disabledChanged(); if (this.position === 'top') { @@ -136,6 +144,11 @@ export class InfiniteScroll implements ComponentInterface { if (!this.didFire) { this.isLoading = true; this.didFire = true; + + // Lock the min height of the siblings of the infinite scroll + // if we are preserving the rerender scroll position + this.lockSiblingMinHeight(true); + this.ionInfinite.emit(); return 3; } @@ -144,6 +157,33 @@ export class InfiniteScroll implements ComponentInterface { return 4; }; + private lockSiblingMinHeight(lock: boolean) { + if (!this.preserveRerenderScrollPosition) { + return; + } + + // Loop through all the siblings of the infinite scroll, but ignore the infinite scroll itself + const siblings = this.el.parentElement?.children; + if (siblings) { + for (const sibling of siblings) { + if (sibling !== this.el && sibling instanceof HTMLElement) { + if (lock) { + const elementHeight = sibling.getBoundingClientRect().height; + const previousMinHeight = sibling.style.minHeight; + if (previousMinHeight) { + sibling.style.setProperty('--ion-previous-min-height', previousMinHeight); + } + sibling.style.minHeight = `${elementHeight}px`; + } else { + const previousMinHeight = sibling.style.getPropertyValue('--ion-previous-min-height'); + sibling.style.minHeight = previousMinHeight || 'auto'; + sibling.style.removeProperty('--ion-previous-min-height'); + } + } + } + } + } + /** * Call `complete()` within the `ionInfinite` output event handler when * your async operation has completed. For example, the `loading` @@ -208,6 +248,12 @@ 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 + setTimeout(() => { + this.lockSiblingMinHeight(false); + }, 100); } private canStart(): boolean { diff --git a/core/src/components/infinite-scroll/test/item-replacement/index.html b/core/src/components/infinite-scroll/test/item-replacement/index.html new file mode 100644 index 00000000000..8fdff07c0ea --- /dev/null +++ b/core/src/components/infinite-scroll/test/item-replacement/index.html @@ -0,0 +1,130 @@ + + + + + Infinite Scroll - Item Replacement + + + + + + + + + + + + + Infinite Scroll - Item Replacement + + + + + + + Title + + + +
Scroll the list to see the title collapse.
+ + + + + + + +
+
+ + + + diff --git a/packages/angular/src/directives/proxies.ts b/packages/angular/src/directives/proxies.ts index ae0bdafac3e..907390819e5 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 590555cdc8d..56b308e25b7 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 6e9dace0e21..0dd1a1f7067 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' From 76c10c92a13b1330f0f89db1f1aa9954b8a1be14 Mon Sep 17 00:00:00 2001 From: ShaneK Date: Wed, 23 Jul 2025 10:03:44 -0700 Subject: [PATCH 2/5] test(infinite-scroll): adding tests for preserve rerender scroll position --- .../infinite-scroll/infinite-scroll.tsx | 37 +++++++++++-------- .../index.html | 0 .../infinite-scroll.e2e.ts | 35 ++++++++++++++++++ 3 files changed, 57 insertions(+), 15 deletions(-) rename core/src/components/infinite-scroll/test/{item-replacement => preserve-rerender-scroll-position}/index.html (100%) create mode 100644 core/src/components/infinite-scroll/test/preserve-rerender-scroll-position/infinite-scroll.e2e.ts diff --git a/core/src/components/infinite-scroll/infinite-scroll.tsx b/core/src/components/infinite-scroll/infinite-scroll.tsx index 17ba44e18ab..1ad32c46622 100644 --- a/core/src/components/infinite-scroll/infinite-scroll.tsx +++ b/core/src/components/infinite-scroll/infinite-scroll.tsx @@ -157,28 +157,35 @@ 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) { if (!this.preserveRerenderScrollPosition) { return; } // Loop through all the siblings of the infinite scroll, but ignore the infinite scroll itself - const siblings = this.el.parentElement?.children; - if (siblings) { - for (const sibling of siblings) { - if (sibling !== this.el && sibling instanceof HTMLElement) { - if (lock) { - const elementHeight = sibling.getBoundingClientRect().height; - const previousMinHeight = sibling.style.minHeight; - if (previousMinHeight) { - sibling.style.setProperty('--ion-previous-min-height', previousMinHeight); - } - sibling.style.minHeight = `${elementHeight}px`; - } else { - const previousMinHeight = sibling.style.getPropertyValue('--ion-previous-min-height'); - sibling.style.minHeight = previousMinHeight || 'auto'; - sibling.style.removeProperty('--ion-previous-min-height'); + const siblings = this.el.parentElement?.children || []; + for (const sibling of siblings) { + if (sibling !== this.el && sibling instanceof HTMLElement) { + if (lock) { + const elementHeight = sibling.getBoundingClientRect().height; + const previousMinHeight = sibling.style.minHeight; + if (previousMinHeight) { + sibling.style.setProperty('--ion-previous-min-height', previousMinHeight); } + sibling.style.minHeight = `${elementHeight}px`; + } else { + const previousMinHeight = sibling.style.getPropertyValue('--ion-previous-min-height'); + sibling.style.minHeight = previousMinHeight || 'auto'; + sibling.style.removeProperty('--ion-previous-min-height'); } } } diff --git a/core/src/components/infinite-scroll/test/item-replacement/index.html b/core/src/components/infinite-scroll/test/preserve-rerender-scroll-position/index.html similarity index 100% rename from core/src/components/infinite-scroll/test/item-replacement/index.html rename to core/src/components/infinite-scroll/test/preserve-rerender-scroll-position/index.html 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 00000000000..ab07153470c --- /dev/null +++ b/core/src/components/infinite-scroll/test/preserve-rerender-scroll-position/infinite-scroll.e2e.ts @@ -0,0 +1,35 @@ +import { expect } from '@playwright/test'; +import { configs, test } from '@utils/test/playwright'; + +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(30); + + let previousScrollTop = 0; + for (let i = 0; i < 10; 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; + + // Timeout to allow the browser to catch up. + // For some reason, without this, the scroll top gets reset to 0. Adding this + // prevents that, which implies it's an issue with Playwright, not the feature. + await new Promise((resolve) => setTimeout(resolve, 100)); + } + }); + }); +}); From f9e7dcd3063a38f08e07bbf18aef7b8119bf1225 Mon Sep 17 00:00:00 2001 From: ShaneK Date: Wed, 23 Jul 2025 10:27:00 -0700 Subject: [PATCH 3/5] refactor(infinite-scroll): removing unneeded class property --- core/src/components/infinite-scroll/infinite-scroll.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/core/src/components/infinite-scroll/infinite-scroll.tsx b/core/src/components/infinite-scroll/infinite-scroll.tsx index 1ad32c46622..589fe09bba5 100644 --- a/core/src/components/infinite-scroll/infinite-scroll.tsx +++ b/core/src/components/infinite-scroll/infinite-scroll.tsx @@ -16,7 +16,6 @@ export class InfiniteScroll implements ComponentInterface { private thrPx = 0; private thrPc = 0; private scrollEl?: HTMLElement; - private contentEl: HTMLElement | null = null; /** * didFire exists so that ionInfinite @@ -97,12 +96,12 @@ export class InfiniteScroll implements ComponentInterface { @Event() ionInfinite!: EventEmitter; async connectedCallback() { - this.contentEl = findClosestIonContent(this.el); - if (!this.contentEl) { + const contentEl = findClosestIonContent(this.el); + if (!contentEl) { printIonContentErrorMsg(this.el); return; } - this.scrollEl = await getScrollElement(this.contentEl); + this.scrollEl = await getScrollElement(contentEl); this.thresholdChanged(); this.disabledChanged(); if (this.position === 'top') { From 76aa3fbe773d6f9da76ef22a2b001dc084500c62 Mon Sep 17 00:00:00 2001 From: ShaneK Date: Fri, 25 Jul 2025 06:23:59 -0700 Subject: [PATCH 4/5] fix(infinite-scroll): prevent locking sibling min height while it's already locked, prevent useless setTimeout from being set up while preserve rerender scroll property isn't set --- core/api.txt | 1 - .../infinite-scroll/infinite-scroll.tsx | 40 +++++++++++-------- .../infinite-scroll.e2e.ts | 8 +++- packages/angular/src/directives/proxies.ts | 4 +- .../standalone/src/directives/proxies.ts | 4 +- 5 files changed, 34 insertions(+), 23 deletions(-) diff --git a/core/api.txt b/core/api.txt index 61396d23c7d..b183a34d9cd 100644 --- a/core/api.txt +++ b/core/api.txt @@ -919,7 +919,6 @@ 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/infinite-scroll/infinite-scroll.tsx b/core/src/components/infinite-scroll/infinite-scroll.tsx index 589fe09bba5..7a9ebf1d0e2 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 @@ -84,6 +85,7 @@ export class InfiniteScroll implements ComponentInterface { * 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. + * @internal */ @Prop() preserveRerenderScrollPosition: boolean = false; @@ -141,13 +143,14 @@ export class InfiniteScroll implements ComponentInterface { if (distanceFromInfinite < 0) { if (!this.didFire) { + 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); + } + this.isLoading = true; this.didFire = true; - - // Lock the min height of the siblings of the infinite scroll - // if we are preserving the rerender scroll position - this.lockSiblingMinHeight(true); - this.ionInfinite.emit(); return 3; } @@ -166,19 +169,20 @@ export class InfiniteScroll implements ComponentInterface { * has been previously set by the user when we restore after complete is called. */ private lockSiblingMinHeight(lock: boolean) { - if (!this.preserveRerenderScrollPosition) { - return; - } - - // Loop through all the siblings of the infinite scroll, but ignore the infinite scroll itself const siblings = this.el.parentElement?.children || []; 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; - const previousMinHeight = sibling.style.minHeight; - if (previousMinHeight) { - sibling.style.setProperty('--ion-previous-min-height', previousMinHeight); + 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 { @@ -188,6 +192,8 @@ export class InfiniteScroll implements ComponentInterface { } } } + + this.minHeightLocked = lock; } /** @@ -257,9 +263,11 @@ export class InfiniteScroll implements ComponentInterface { // Unlock the min height of the siblings of the infinite scroll // if we are preserving the rerender scroll position - setTimeout(() => { - this.lockSiblingMinHeight(false); - }, 100); + if (this.preserveRerenderScrollPosition) { + setTimeout(() => { + this.lockSiblingMinHeight(false); + }, 100); + } } private canStart(): boolean { 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 index ab07153470c..6eb446fb1bd 100644 --- 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 @@ -1,6 +1,8 @@ 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 }) => { @@ -13,7 +15,7 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { expect(await items.count()).toBe(30); let previousScrollTop = 0; - for (let i = 0; i < 10; i++) { + for (let i = 0; i < 20; i++) { await content.evaluate((el: HTMLIonContentElement) => el.scrollToBottom(0)); const currentScrollTop = await innerScroll.evaluate((el: HTMLIonContentElement) => el.scrollTop); expect(currentScrollTop).toBeGreaterThan(previousScrollTop); @@ -28,7 +30,9 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { // Timeout to allow the browser to catch up. // For some reason, without this, the scroll top gets reset to 0. Adding this // prevents that, which implies it's an issue with Playwright, not the feature. - await new Promise((resolve) => setTimeout(resolve, 100)); + // For some reason, this delay needs to be longer than the time required to + // reset the minimum height in infinite scroll. + await new Promise((resolve) => setTimeout(resolve, 1001)); } }); }); diff --git a/packages/angular/src/directives/proxies.ts b/packages/angular/src/directives/proxies.ts index 907390819e5..ae0bdafac3e 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', 'preserveRerenderScrollPosition', 'theme', 'threshold'], + inputs: ['disabled', 'mode', 'position', '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', 'preserveRerenderScrollPosition', 'theme', 'threshold'], + inputs: ['disabled', 'mode', 'position', '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 56b308e25b7..590555cdc8d 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', 'preserveRerenderScrollPosition', 'theme', 'threshold'], + inputs: ['disabled', 'mode', 'position', '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', 'preserveRerenderScrollPosition', 'theme', 'threshold'], + inputs: ['disabled', 'mode', 'position', 'theme', 'threshold'], standalone: true }) export class IonInfiniteScroll { From 1c4128019aaae35a07243e2f1008decfd6460750 Mon Sep 17 00:00:00 2001 From: ShaneK Date: Mon, 28 Jul 2025 09:18:36 -0700 Subject: [PATCH 5/5] fix(infinite-scroll): awaiting for DOM writes before firing infinite scroll event --- core/api.txt | 1 + .../infinite-scroll/infinite-scroll.tsx | 74 +++++++++++-------- .../index.html | 3 +- .../infinite-scroll.e2e.ts | 11 +-- packages/angular/src/directives/proxies.ts | 4 +- .../standalone/src/directives/proxies.ts | 4 +- 6 files changed, 52 insertions(+), 45 deletions(-) diff --git a/core/api.txt b/core/api.txt index b183a34d9cd..61396d23c7d 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/infinite-scroll/infinite-scroll.tsx b/core/src/components/infinite-scroll/infinite-scroll.tsx index 7a9ebf1d0e2..e2ec9d42778 100644 --- a/core/src/components/infinite-scroll/infinite-scroll.tsx +++ b/core/src/components/infinite-scroll/infinite-scroll.tsx @@ -85,7 +85,6 @@ export class InfiniteScroll implements ComponentInterface { * 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. - * @internal */ @Prop() preserveRerenderScrollPosition: boolean = false; @@ -143,15 +142,18 @@ export class InfiniteScroll implements ComponentInterface { if (distanceFromInfinite < 0) { 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); + this.lockSiblingMinHeight(true).then(() => { + this.ionInfinite.emit(); + }); + } else { + this.ionInfinite.emit(); } - - this.isLoading = true; - this.didFire = true; - this.ionInfinite.emit(); return 3; } } @@ -168,32 +170,44 @@ export class InfiniteScroll implements ComponentInterface { * 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) { - const siblings = this.el.parentElement?.children || []; - 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; - 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); - } + 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'); + }); } - sibling.style.minHeight = `${elementHeight}px`; - } else { - const previousMinHeight = sibling.style.getPropertyValue('--ion-previous-min-height'); - sibling.style.minHeight = previousMinHeight || 'auto'; - sibling.style.removeProperty('--ion-previous-min-height'); } } - } - this.minHeightLocked = lock; + writeTask(() => { + writes.forEach((w) => w()); + this.minHeightLocked = lock; + resolve(); + }); + }); } /** @@ -264,8 +278,8 @@ export class InfiniteScroll implements ComponentInterface { // Unlock the min height of the siblings of the infinite scroll // if we are preserving the rerender scroll position if (this.preserveRerenderScrollPosition) { - setTimeout(() => { - this.lockSiblingMinHeight(false); + setTimeout(async () => { + await this.lockSiblingMinHeight(false); }, 100); } } 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 index 8fdff07c0ea..7e2768a571f 100644 --- 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 @@ -67,7 +67,6 @@ } loading = true; - await wait(300); replaceAllItems(); infiniteScroll.complete(); @@ -99,7 +98,7 @@ // Add new items with new "keys" (different content/identifiers) // Start with more items to ensure scrollable content - const totalItems = generation === 1 ? 30 : 30 + generation * 20; + const totalItems = generation === 1 ? 50 : 30 + generation * 20; itemCount = 0; for (let i = 0; i < totalItems; i++) { 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 index 6eb446fb1bd..8605b580e0b 100644 --- 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 @@ -12,10 +12,10 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { const content = page.locator('ion-content'); const items = page.locator('ion-item'); const innerScroll = page.locator('.inner-scroll'); - expect(await items.count()).toBe(30); + expect(await items.count()).toBe(50); let previousScrollTop = 0; - for (let i = 0; i < 20; i++) { + 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); @@ -26,13 +26,6 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { previousScrollTop ); previousScrollTop = currentScrollTop; - - // Timeout to allow the browser to catch up. - // For some reason, without this, the scroll top gets reset to 0. Adding this - // prevents that, which implies it's an issue with Playwright, not the feature. - // For some reason, this delay needs to be longer than the time required to - // reset the minimum height in infinite scroll. - await new Promise((resolve) => setTimeout(resolve, 1001)); } }); }); diff --git a/packages/angular/src/directives/proxies.ts b/packages/angular/src/directives/proxies.ts index ae0bdafac3e..907390819e5 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 590555cdc8d..56b308e25b7 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 {