From 9469718ee14d954eecdde7284342ba801a9e83d6 Mon Sep 17 00:00:00 2001 From: ShaneK Date: Sun, 16 Nov 2025 17:12:56 -0800 Subject: [PATCH 1/9] fix(datetime): harden visibility logic --- .../test/overlays/datetime-button.e2e.ts | 21 ++++++++ core/src/components/datetime/datetime.scss | 17 ++++++ core/src/components/datetime/datetime.tsx | 46 ++++++++++++++-- .../datetime/test/basic/datetime.e2e.ts | 54 +++++++++++++++++++ 4 files changed, 135 insertions(+), 3 deletions(-) diff --git a/core/src/components/datetime-button/test/overlays/datetime-button.e2e.ts b/core/src/components/datetime-button/test/overlays/datetime-button.e2e.ts index 76d6cd2d8aa..6f2cd3852eb 100644 --- a/core/src/components/datetime-button/test/overlays/datetime-button.e2e.ts +++ b/core/src/components/datetime-button/test/overlays/datetime-button.e2e.ts @@ -176,5 +176,26 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { await ionModalDidPresent.next(); await expect(datetime).toBeVisible(); }); + test('should set datetime ready state and keep calendar interactive when reopening modal', async ({ page }) => { + const openAndInteract = async () => { + await page.click('#date-button'); + await ionModalDidPresent.next(); + + await page.locator('ion-datetime.datetime-ready').waitFor(); + + const calendarBody = datetime.locator('.calendar-body'); + await expect(calendarBody).toBeVisible(); + + const firstEnabledDay = datetime.locator('.calendar-day:not([disabled])').first(); + await firstEnabledDay.click(); + await page.waitForChanges(); + + await modal.evaluate((el: HTMLIonModalElement) => el.dismiss()); + await ionModalDidDismiss.next(); + }; + + await openAndInteract(); + await openAndInteract(); + }); }); }); diff --git a/core/src/components/datetime/datetime.scss b/core/src/components/datetime/datetime.scss index f3053d0ed16..fb6873e522f 100644 --- a/core/src/components/datetime/datetime.scss +++ b/core/src/components/datetime/datetime.scss @@ -49,6 +49,23 @@ width: 100%; } +/** + * The intersection tracker participates in layout so + * IntersectionObserver has a non-zero rect to observe. + * A negative margin keeps the effective host height + * unchanged so pages embedding ion-datetime (such as + * item input demos) do not grow by 1px. + */ +:host .intersection-tracker { + width: 100%; + height: 1px; + + margin-bottom: -1px; + + opacity: 0; + pointer-events: none; +} + :host .calendar-body, :host .datetime-year { opacity: 0; diff --git a/core/src/components/datetime/datetime.tsx b/core/src/components/datetime/datetime.tsx index 6a7d2440a8a..514ebe54ae6 100644 --- a/core/src/components/datetime/datetime.tsx +++ b/core/src/components/datetime/datetime.tsx @@ -1101,6 +1101,31 @@ export class Datetime implements ComponentInterface { this.initializeKeyboardListeners(); } + /** + * Fallback to ensure the datetime becomes ready even if + * IntersectionObserver never reports it as intersecting. + * + * This is primarily used in environments where the observer + * might not fire as expected, such as when running under + * synthetic tests that stub IntersectionObserver. + */ + private ensureReadyIfVisible = () => { + if (this.el.classList.contains('datetime-ready')) { + return; + } + + const rect = this.el.getBoundingClientRect(); + if (rect.width === 0 || rect.height === 0) { + return; + } + + this.initializeListeners(); + + writeTask(() => { + this.el.classList.add('datetime-ready'); + }); + }; + componentDidLoad() { const { el, intersectionTrackerRef } = this; @@ -1141,6 +1166,21 @@ export class Datetime implements ComponentInterface { */ raf(() => visibleIO?.observe(intersectionTrackerRef!)); + /** + * Fallback: If IntersectionObserver never reports that the + * datetime is visible but the host clearly has layout, ensure + * we still initialize listeners and mark the component as ready. + * + * We schedule this a couple of frames after load so that any + * initial layout/animations (such as a parent modal presenting) + * have had a chance to run. + */ + raf(() => { + raf(() => { + this.ensureReadyIfVisible(); + }); + }); + /** * We need to clean up listeners when the datetime is hidden * in a popover/modal so that we can properly scroll containers @@ -2664,9 +2704,9 @@ export class Datetime implements ComponentInterface { We can work around this by observing .intersection-tracker and using the host (ion-datetime) as the "root". This allows the IO callback to fire the moment - the datetime is visible. The .intersection-tracker element should not have - dimensions or additional styles, and it should not be positioned absolutely - otherwise the IO callback may fire at unexpected times. + the datetime is visible. The .intersection-tracker element uses a minimal, + invisible block size so it participates in layout, and it should not be + positioned absolutely otherwise the IO callback may fire at unexpected times. */}
(this.intersectionTrackerRef = el)}>
{this.renderDatetime(mode)} diff --git a/core/src/components/datetime/test/basic/datetime.e2e.ts b/core/src/components/datetime/test/basic/datetime.e2e.ts index 67812b6d020..4704fa2d29c 100644 --- a/core/src/components/datetime/test/basic/datetime.e2e.ts +++ b/core/src/components/datetime/test/basic/datetime.e2e.ts @@ -394,6 +394,60 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => }); }); +/** + * Synthetic IntersectionObserver fallback behavior. + * + * This test stubs IntersectionObserver so that the callback + * never reports an intersecting entry. The datetime should + * still become ready via its internal fallback logic. + */ +configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { + test.describe(title('datetime: IO fallback'), () => { + test('should become ready even if IntersectionObserver never reports visible', async ({ page }) => { + await page.addInitScript(() => { + const OriginalIO = window.IntersectionObserver; + (window as any).IntersectionObserver = function (callback: any, options: any) { + const instance = new OriginalIO(() => {}, options); + const originalObserve = instance.observe.bind(instance); + + instance.observe = (target: Element) => { + originalObserve(target); + callback([ + { + isIntersecting: false, + target, + } as IntersectionObserverEntry, + ]); + }; + + return instance; + } as any; + }); + + await page.setContent( + ` + + `, + config + ); + + const datetime = page.locator('ion-datetime'); + + // Give the fallback a short amount of time to run + await page.waitForTimeout(100); + + await expect(datetime).toHaveClass(/datetime-ready/); + + const calendarBody = datetime.locator('.calendar-body'); + await expect(calendarBody).toBeVisible(); + + const firstEnabledDay = datetime.locator('.calendar-day:not([disabled])').first(); + await firstEnabledDay.click(); + await page.waitForChanges(); + }); + }); +}); + /** * We are setting RTL on the component * instead, so we don't need to test From d9c4cd8e89d9f90275e998919b71a81e9b164dde Mon Sep 17 00:00:00 2001 From: ShaneK Date: Tue, 18 Nov 2025 06:14:07 -0800 Subject: [PATCH 2/9] fix(datetime): improving detection logic --- core/src/components/datetime/datetime.scss | 17 ----------------- core/src/components/datetime/datetime.tsx | 18 +++++++----------- 2 files changed, 7 insertions(+), 28 deletions(-) diff --git a/core/src/components/datetime/datetime.scss b/core/src/components/datetime/datetime.scss index fb6873e522f..f3053d0ed16 100644 --- a/core/src/components/datetime/datetime.scss +++ b/core/src/components/datetime/datetime.scss @@ -49,23 +49,6 @@ width: 100%; } -/** - * The intersection tracker participates in layout so - * IntersectionObserver has a non-zero rect to observe. - * A negative margin keeps the effective host height - * unchanged so pages embedding ion-datetime (such as - * item input demos) do not grow by 1px. - */ -:host .intersection-tracker { - width: 100%; - height: 1px; - - margin-bottom: -1px; - - opacity: 0; - pointer-events: none; -} - :host .calendar-body, :host .datetime-year { opacity: 0; diff --git a/core/src/components/datetime/datetime.tsx b/core/src/components/datetime/datetime.tsx index 514ebe54ae6..fa2f68ee2ab 100644 --- a/core/src/components/datetime/datetime.tsx +++ b/core/src/components/datetime/datetime.tsx @@ -1171,15 +1171,11 @@ export class Datetime implements ComponentInterface { * datetime is visible but the host clearly has layout, ensure * we still initialize listeners and mark the component as ready. * - * We schedule this a couple of frames after load so that any - * initial layout/animations (such as a parent modal presenting) - * have had a chance to run. + * We schedule this after everything has had a chance to run. */ - raf(() => { - raf(() => { - this.ensureReadyIfVisible(); - }); - }); + setTimeout(() => { + this.ensureReadyIfVisible(); + }, 100); /** * We need to clean up listeners when the datetime is hidden @@ -2704,9 +2700,9 @@ export class Datetime implements ComponentInterface { We can work around this by observing .intersection-tracker and using the host (ion-datetime) as the "root". This allows the IO callback to fire the moment - the datetime is visible. The .intersection-tracker element uses a minimal, - invisible block size so it participates in layout, and it should not be - positioned absolutely otherwise the IO callback may fire at unexpected times. + the datetime is visible. The .intersection-tracker element should not have + dimensions or additional styles, and it should not be positioned absolutely + otherwise the IO callback may fire at unexpected times. */}
(this.intersectionTrackerRef = el)}>
{this.renderDatetime(mode)} From a6f0eb31a463f90d29fbc2835bf73ea3706711f9 Mon Sep 17 00:00:00 2001 From: ShaneK Date: Wed, 19 Nov 2025 06:28:48 -0800 Subject: [PATCH 3/9] chore(date-time): adding issue attributions to tests --- .../test/overlays/datetime-button.e2e.ts | 11 ++++++++--- .../components/datetime/test/basic/datetime.e2e.ts | 7 ++++++- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/core/src/components/datetime-button/test/overlays/datetime-button.e2e.ts b/core/src/components/datetime-button/test/overlays/datetime-button.e2e.ts index 6f2cd3852eb..5519c46dbeb 100644 --- a/core/src/components/datetime-button/test/overlays/datetime-button.e2e.ts +++ b/core/src/components/datetime-button/test/overlays/datetime-button.e2e.ts @@ -1,7 +1,7 @@ -import { expect } from '@playwright/test'; import type { Locator } from '@playwright/test'; -import { configs, test } from '@utils/test/playwright'; +import { expect } from '@playwright/test'; import type { EventSpy } from '@utils/test/playwright'; +import { configs, test } from '@utils/test/playwright'; /** * This behavior does not vary across directions. @@ -176,7 +176,12 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { await ionModalDidPresent.next(); await expect(datetime).toBeVisible(); }); - test('should set datetime ready state and keep calendar interactive when reopening modal', async ({ page }) => { + test('should set datetime ready state and keep calendar interactive when reopening modal', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/30706', + }); + const openAndInteract = async () => { await page.click('#date-button'); await ionModalDidPresent.next(); diff --git a/core/src/components/datetime/test/basic/datetime.e2e.ts b/core/src/components/datetime/test/basic/datetime.e2e.ts index 4704fa2d29c..397bd4ac339 100644 --- a/core/src/components/datetime/test/basic/datetime.e2e.ts +++ b/core/src/components/datetime/test/basic/datetime.e2e.ts @@ -403,7 +403,12 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => */ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { test.describe(title('datetime: IO fallback'), () => { - test('should become ready even if IntersectionObserver never reports visible', async ({ page }) => { + test('should become ready even if IntersectionObserver never reports visible', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/30706', + }); + await page.addInitScript(() => { const OriginalIO = window.IntersectionObserver; (window as any).IntersectionObserver = function (callback: any, options: any) { From c07b0e959ebcf5ff17d42030b1591596fd287fb1 Mon Sep 17 00:00:00 2001 From: ShaneK Date: Wed, 19 Nov 2025 06:31:21 -0800 Subject: [PATCH 4/9] chore(date-time): e2e test cleanup --- .../datetime-button/test/overlays/datetime-button.e2e.ts | 9 +++++---- core/src/components/datetime/test/basic/datetime.e2e.ts | 4 ---- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/core/src/components/datetime-button/test/overlays/datetime-button.e2e.ts b/core/src/components/datetime-button/test/overlays/datetime-button.e2e.ts index 5519c46dbeb..325a7c3810c 100644 --- a/core/src/components/datetime-button/test/overlays/datetime-button.e2e.ts +++ b/core/src/components/datetime-button/test/overlays/datetime-button.e2e.ts @@ -191,15 +191,16 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { const calendarBody = datetime.locator('.calendar-body'); await expect(calendarBody).toBeVisible(); - const firstEnabledDay = datetime.locator('.calendar-day:not([disabled])').first(); - await firstEnabledDay.click(); - await page.waitForChanges(); - await modal.evaluate((el: HTMLIonModalElement) => el.dismiss()); await ionModalDidDismiss.next(); }; await openAndInteract(); + + const firstEnabledDay = datetime.locator('.calendar-day:not([disabled])').first(); + await firstEnabledDay.click(); + await page.waitForChanges(); + await openAndInteract(); }); }); diff --git a/core/src/components/datetime/test/basic/datetime.e2e.ts b/core/src/components/datetime/test/basic/datetime.e2e.ts index 397bd4ac339..6104d0014cf 100644 --- a/core/src/components/datetime/test/basic/datetime.e2e.ts +++ b/core/src/components/datetime/test/basic/datetime.e2e.ts @@ -445,10 +445,6 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { const calendarBody = datetime.locator('.calendar-body'); await expect(calendarBody).toBeVisible(); - - const firstEnabledDay = datetime.locator('.calendar-day:not([disabled])').first(); - await firstEnabledDay.click(); - await page.waitForChanges(); }); }); }); From 77fe5ee9a0f64d14487d9fd99ac7365e19cd6c64 Mon Sep 17 00:00:00 2001 From: ShaneK Date: Wed, 19 Nov 2025 06:38:05 -0800 Subject: [PATCH 5/9] chore(lint): ran lint fix --- .../datetime-button/test/overlays/datetime-button.e2e.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/core/src/components/datetime-button/test/overlays/datetime-button.e2e.ts b/core/src/components/datetime-button/test/overlays/datetime-button.e2e.ts index 325a7c3810c..7231e87a431 100644 --- a/core/src/components/datetime-button/test/overlays/datetime-button.e2e.ts +++ b/core/src/components/datetime-button/test/overlays/datetime-button.e2e.ts @@ -176,7 +176,9 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { await ionModalDidPresent.next(); await expect(datetime).toBeVisible(); }); - test('should set datetime ready state and keep calendar interactive when reopening modal', async ({ page }, testInfo) => { + test('should set datetime ready state and keep calendar interactive when reopening modal', async ({ + page, + }, testInfo) => { testInfo.annotations.push({ type: 'issue', description: 'https://github.com/ionic-team/ionic-framework/issues/30706', From 2371946a62044ce825b407c82e92397015e0d8a2 Mon Sep 17 00:00:00 2001 From: ShaneK Date: Wed, 19 Nov 2025 06:51:40 -0800 Subject: [PATCH 6/9] chore(e2e): fix e2e --- .../datetime-button/test/overlays/datetime-button.e2e.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/core/src/components/datetime-button/test/overlays/datetime-button.e2e.ts b/core/src/components/datetime-button/test/overlays/datetime-button.e2e.ts index 7231e87a431..694f523dff6 100644 --- a/core/src/components/datetime-button/test/overlays/datetime-button.e2e.ts +++ b/core/src/components/datetime-button/test/overlays/datetime-button.e2e.ts @@ -193,16 +193,15 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { const calendarBody = datetime.locator('.calendar-body'); await expect(calendarBody).toBeVisible(); + const firstEnabledDay = datetime.locator('.calendar-day:not([disabled])').first(); + await firstEnabledDay.click(); + await page.waitForChanges(); + await modal.evaluate((el: HTMLIonModalElement) => el.dismiss()); await ionModalDidDismiss.next(); }; await openAndInteract(); - - const firstEnabledDay = datetime.locator('.calendar-day:not([disabled])').first(); - await firstEnabledDay.click(); - await page.waitForChanges(); - await openAndInteract(); }); }); From 0e59e06f6189df803851448f52ce92ca489c01e1 Mon Sep 17 00:00:00 2001 From: ShaneK Date: Wed, 19 Nov 2025 08:01:08 -0800 Subject: [PATCH 7/9] chore(e2e): fix e2e --- .../test/overlays/datetime-button.e2e.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/core/src/components/datetime-button/test/overlays/datetime-button.e2e.ts b/core/src/components/datetime-button/test/overlays/datetime-button.e2e.ts index 694f523dff6..a626dd90855 100644 --- a/core/src/components/datetime-button/test/overlays/datetime-button.e2e.ts +++ b/core/src/components/datetime-button/test/overlays/datetime-button.e2e.ts @@ -192,16 +192,17 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { const calendarBody = datetime.locator('.calendar-body'); await expect(calendarBody).toBeVisible(); - - const firstEnabledDay = datetime.locator('.calendar-day:not([disabled])').first(); - await firstEnabledDay.click(); - await page.waitForChanges(); - - await modal.evaluate((el: HTMLIonModalElement) => el.dismiss()); - await ionModalDidDismiss.next(); }; await openAndInteract(); + + const firstEnabledDay = datetime.locator('.calendar-day:not([disabled])').first(); + await firstEnabledDay.click(); + await page.waitForChanges(); + + await modal.evaluate((el: HTMLIonModalElement) => el.dismiss()); + await ionModalDidDismiss.next(); + await openAndInteract(); }); }); From 11a8c2b94725de603316e97c53dbc7a1c6a3e107 Mon Sep 17 00:00:00 2001 From: Shane Date: Thu, 20 Nov 2025 11:38:49 -0800 Subject: [PATCH 8/9] chore(datetime-e2e): linking follow up ticket Co-authored-by: Brandy Smith --- core/src/components/datetime/datetime.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/core/src/components/datetime/datetime.tsx b/core/src/components/datetime/datetime.tsx index fa2f68ee2ab..b6e5de72718 100644 --- a/core/src/components/datetime/datetime.tsx +++ b/core/src/components/datetime/datetime.tsx @@ -1102,6 +1102,7 @@ export class Datetime implements ComponentInterface { } /** + * TODO(FW-6931): Remove this fallback upon solving the root cause * Fallback to ensure the datetime becomes ready even if * IntersectionObserver never reports it as intersecting. * @@ -1167,7 +1168,8 @@ export class Datetime implements ComponentInterface { raf(() => visibleIO?.observe(intersectionTrackerRef!)); /** - * Fallback: If IntersectionObserver never reports that the + * TODO(FW-6931): Remove this fallback upon solving the root cause + * Fallback: If IntersectionObserver never reports that the * datetime is visible but the host clearly has layout, ensure * we still initialize listeners and mark the component as ready. * From 2699f54b85b86847c823323fb715b5b4ad424dd8 Mon Sep 17 00:00:00 2001 From: ShaneK Date: Thu, 20 Nov 2025 11:43:56 -0800 Subject: [PATCH 9/9] chore(lint): fix lint --- core/src/components/datetime/datetime.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/core/src/components/datetime/datetime.tsx b/core/src/components/datetime/datetime.tsx index b6e5de72718..3411f28eb57 100644 --- a/core/src/components/datetime/datetime.tsx +++ b/core/src/components/datetime/datetime.tsx @@ -1168,8 +1168,8 @@ export class Datetime implements ComponentInterface { raf(() => visibleIO?.observe(intersectionTrackerRef!)); /** - * TODO(FW-6931): Remove this fallback upon solving the root cause - * Fallback: If IntersectionObserver never reports that the + * TODO(FW-6931): Remove this fallback upon solving the root cause + * Fallback: If IntersectionObserver never reports that the * datetime is visible but the host clearly has layout, ensure * we still initialize listeners and mark the component as ready. *