fix(datetime): ensure datetime is shown when intersection observer fails to report visibility (#30793)

Issue number: resolves #30706

---------

<!-- 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?

Due to some recent unknown changes, the intersection observer for date
time no longer reliably fires, especially in mobile views.

## What is the new behavior?

In this PR, we're adding a visibility check after everything has had a
chance to render to make sure we're setting up properly even if the
intersection observer has failed to trigger for some reason.

## Does this introduce a breaking change?

- [ ] Yes
- [X] No

## Other information

Since the intersection observer is being set up after a `raf`, it's
possible something got introduced to make the initial setup slower for
some reason, causing timing issues. I think we should do a more thorough
investigation into the cause of this problem when we have more time.

This PR also adds tests to verify the new fallback works properly.

Current dev build:
```
8.7.10-dev.11763478209.1d9c4cd8
```

---------

Co-authored-by: Brandy Smith <brandyscarney@users.noreply.github.com>
This commit is contained in:
Shane
2025-11-20 12:16:09 -08:00
committed by GitHub
parent 9ae41efddb
commit 9d781db662
3 changed files with 124 additions and 2 deletions

View File

@@ -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,5 +176,34 @@ 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) => {
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();
await page.locator('ion-datetime.datetime-ready').waitFor();
const calendarBody = datetime.locator('.calendar-body');
await expect(calendarBody).toBeVisible();
};
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();
});
});
});

View File

@@ -1101,6 +1101,32 @@ export class Datetime implements ComponentInterface {
this.initializeKeyboardListeners();
}
/**
* 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.
*
* 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 +1167,18 @@ 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
* datetime is visible but the host clearly has layout, ensure
* we still initialize listeners and mark the component as ready.
*
* We schedule this after everything has had a chance to run.
*/
setTimeout(() => {
this.ensureReadyIfVisible();
}, 100);
/**
* We need to clean up listeners when the datetime is hidden
* in a popover/modal so that we can properly scroll containers

View File

@@ -394,6 +394,61 @@ 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 }, 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) {
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(
`
<ion-datetime value="2022-05-03"></ion-datetime>
`,
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();
});
});
});
/**
* We are setting RTL on the component
* instead, so we don't need to test