From f722737e74b5f6b29ee3562071a03f7a1002970c Mon Sep 17 00:00:00 2001 From: ShaneK Date: Thu, 7 May 2026 11:01:26 -0700 Subject: [PATCH 1/3] fix(input): scroll assist no longer fires duplicate click events --- .../item/scroll-assist-double-click.e2e.ts | 111 ++++++++++++++++++ .../utils/input-shims/hacks/scroll-assist.ts | 8 -- 2 files changed, 111 insertions(+), 8 deletions(-) create mode 100644 core/src/components/input/test/item/scroll-assist-double-click.e2e.ts diff --git a/core/src/components/input/test/item/scroll-assist-double-click.e2e.ts b/core/src/components/input/test/item/scroll-assist-double-click.e2e.ts new file mode 100644 index 00000000000..e949ce92809 --- /dev/null +++ b/core/src/components/input/test/item/scroll-assist-double-click.e2e.ts @@ -0,0 +1,111 @@ +import { expect } from '@playwright/test'; +import type { E2EPage } from '@utils/test/playwright'; +import { configs, test } from '@utils/test/playwright'; + +configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => { + test.describe(title('input: scroll assist click events'), () => { + test.beforeEach(({}, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/30412', + }); + }); + + const setup = async (page: E2EPage) => { + const longText = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. '.repeat(40); + await page.setContent( + ` + + +

${longText}

+ + + +

${longText}

+
+
+ `, + config + ); + }; + + // Position the target near the bottom of the viewport so scroll assist's + // `scrollAmount` calculation passes its 4px threshold and the relocate path runs. + const positionTargetForScrollAssist = async (page: E2EPage) => { + await page.evaluate(async () => { + const content = document.querySelector('ion-content') as HTMLIonContentElement; + const item = document.querySelector('#target')!; + const scrollEl = await content.getScrollElement(); + const itemRect = item.getBoundingClientRect(); + const desiredTop = window.innerHeight - itemRect.height - 20; + scrollEl.scrollTop += itemRect.top - desiredTop; + await new Promise((r) => requestAnimationFrame(() => r(null))); + }); + }; + + // Two rAF cycles drain any rAF-scheduled work, including the legacy recovery + // click that scroll assist used to fire after relocating the input. + const flushAnimationFrames = (page: E2EPage) => + page.evaluate( + () => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))) + ); + + test('clicking ion-item padding when scroll is needed should fire one click event', async ({ page }) => { + await setup(page); + await positionTargetForScrollAssist(page); + + const item = page.locator('#target'); + const onClick = await page.spyOnEvent('click'); + + // `force: true` skips Playwright's auto-scroll-into-view, so the click + // lands while the item still requires scroll assist to relocate. + await item.click({ position: { x: 5, y: 5 }, force: true }); + await flushAnimationFrames(page); + + expect(onClick).toHaveReceivedEventTimes(1); + }); + + test('clicking ion-input directly when scroll is needed should fire one click event', async ({ page }) => { + await setup(page); + await positionTargetForScrollAssist(page); + + const onClick = await page.spyOnEvent('click'); + const nativeInput = page.locator('#target ion-input input'); + await nativeInput.click({ force: true }); + await flushAnimationFrames(page); + + expect(onClick).toHaveReceivedEventTimes(1); + }); + + test('programmatic setFocus when scroll is needed should not fire a click event', async ({ page }) => { + await setup(page); + await positionTargetForScrollAssist(page); + + const onClick = await page.spyOnEvent('click'); + await page.evaluate(async () => { + const input = document.querySelector('#target ion-input') as HTMLIonInputElement; + await input.setFocus(); + }); + await flushAnimationFrames(page); + + expect(onClick).toHaveReceivedEventTimes(0); + }); + + test('keyboard focus into input when scroll is needed should not fire a click event', async ({ page }) => { + await setup(page); + await positionTargetForScrollAssist(page); + + const onClick = await page.spyOnEvent('click'); + + // Focus the native input directly without dispatching a click; mimics + // tab navigation landing on an offscreen input. + await page.evaluate(() => { + const native = document.querySelector('#target ion-input input') as HTMLInputElement; + native.focus(); + }); + await flushAnimationFrames(page); + + expect(onClick).toHaveReceivedEventTimes(0); + }); + }); +}); diff --git a/core/src/utils/input-shims/hacks/scroll-assist.ts b/core/src/utils/input-shims/hacks/scroll-assist.ts index 9d4685cb8da..a5f3780741b 100644 --- a/core/src/utils/input-shims/hacks/scroll-assist.ts +++ b/core/src/utils/input-shims/hacks/scroll-assist.ts @@ -2,7 +2,6 @@ import type { KeyboardResizeOptions } from '@capacitor/keyboard'; import { win } from '@utils/browser'; import { getScrollElement, scrollByPoint } from '../../content'; -import { raf } from '../../helpers'; import { KeyboardResize } from '../../native/keyboard'; import { relocateInput, SCROLL_AMOUNT_PADDING } from './common'; @@ -253,13 +252,6 @@ const jsSetFocus = async ( relocateInput(componentEl, inputEl, true, scrollData.inputSafeY, disableClonedInput); setManualFocus(inputEl); - /** - * Relocating/Focusing input causes the - * click event to be cancelled, so - * manually fire one here. - */ - raf(() => componentEl.click()); - /** * If enabled, we can add scroll padding to * the bottom of the content so that scroll assist From 0cf3d4c885d22ec6d2ecd1af54d4be5373b55785 Mon Sep 17 00:00:00 2001 From: ShaneK Date: Thu, 7 May 2026 11:20:03 -0700 Subject: [PATCH 2/3] chore(lint): running lint --- .../input/test/item/scroll-assist-double-click.e2e.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/src/components/input/test/item/scroll-assist-double-click.e2e.ts b/core/src/components/input/test/item/scroll-assist-double-click.e2e.ts index e949ce92809..67770e3c995 100644 --- a/core/src/components/input/test/item/scroll-assist-double-click.e2e.ts +++ b/core/src/components/input/test/item/scroll-assist-double-click.e2e.ts @@ -4,7 +4,7 @@ import { configs, test } from '@utils/test/playwright'; configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => { test.describe(title('input: scroll assist click events'), () => { - test.beforeEach(({}, testInfo) => { + test.beforeEach((_, testInfo) => { testInfo.annotations.push({ type: 'issue', description: 'https://github.com/ionic-team/ionic-framework/issues/30412', From bd0cdda0fe0fd26e9344669843db4d8945650053 Mon Sep 17 00:00:00 2001 From: ShaneK Date: Thu, 7 May 2026 12:51:11 -0700 Subject: [PATCH 3/3] chore(lint): fixing up tests --- .../item/scroll-assist-double-click.e2e.ts | 35 +++++++++++++------ 1 file changed, 24 insertions(+), 11 deletions(-) diff --git a/core/src/components/input/test/item/scroll-assist-double-click.e2e.ts b/core/src/components/input/test/item/scroll-assist-double-click.e2e.ts index 67770e3c995..be590aca7e5 100644 --- a/core/src/components/input/test/item/scroll-assist-double-click.e2e.ts +++ b/core/src/components/input/test/item/scroll-assist-double-click.e2e.ts @@ -4,13 +4,6 @@ import { configs, test } from '@utils/test/playwright'; configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => { test.describe(title('input: scroll assist click events'), () => { - test.beforeEach((_, testInfo) => { - testInfo.annotations.push({ - type: 'issue', - description: 'https://github.com/ionic-team/ionic-framework/issues/30412', - }); - }); - const setup = async (page: E2EPage) => { const longText = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. '.repeat(40); await page.setContent( @@ -50,7 +43,11 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => () => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))) ); - test('clicking ion-item padding when scroll is needed should fire one click event', async ({ page }) => { + test('clicking ion-item padding when scroll is needed should fire one click event', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/30412', + }); await setup(page); await positionTargetForScrollAssist(page); @@ -65,7 +62,13 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => expect(onClick).toHaveReceivedEventTimes(1); }); - test('clicking ion-input directly when scroll is needed should fire one click event', async ({ page }) => { + test('clicking ion-input directly when scroll is needed should fire one click event', async ({ + page, + }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/30412', + }); await setup(page); await positionTargetForScrollAssist(page); @@ -77,7 +80,11 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => expect(onClick).toHaveReceivedEventTimes(1); }); - test('programmatic setFocus when scroll is needed should not fire a click event', async ({ page }) => { + test('programmatic setFocus when scroll is needed should not fire a click event', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/30412', + }); await setup(page); await positionTargetForScrollAssist(page); @@ -91,7 +98,13 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => expect(onClick).toHaveReceivedEventTimes(0); }); - test('keyboard focus into input when scroll is needed should not fire a click event', async ({ page }) => { + test('keyboard focus into input when scroll is needed should not fire a click event', async ({ + page, + }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/30412', + }); await setup(page); await positionTargetForScrollAssist(page);