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 16050dbbf1b..ab8a0c2057b 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 @@ -267,6 +267,24 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { await expect(selectedDay).toBeInViewport(); }); + test('should keep the calendar visible across repeated open and dismiss cycles', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/30933', + }); + + const calendarBody = datetime.locator('.calendar-body'); + + for (let cycle = 0; cycle < 10; cycle++) { + await openModal(page); + + await expect(calendarBody).toHaveCSS('opacity', '1'); + await expect(monthYear).toHaveText('March 2022'); + + await dismissModal(); + } + }); + test('should navigate to the previous month when reopened', async ({ page }, testInfo) => { testInfo.annotations.push({ type: 'issue', diff --git a/core/src/components/datetime/datetime.tsx b/core/src/components/datetime/datetime.tsx index 3de359a494a..e9bb5d47086 100644 --- a/core/src/components/datetime/datetime.tsx +++ b/core/src/components/datetime/datetime.tsx @@ -136,16 +136,6 @@ export class Datetime implements ComponentInterface { private todayParts!: DatetimeParts; private defaultParts!: DatetimeParts; private loadTimeout: ReturnType | undefined; - /** - * Set true only by `visibleCallback`. Lets `hiddenCallback` ignore the - * synthetic "not intersecting" entry IntersectionObserver fires on - * `observe()` when the host mounts offscreen. - * - * Don't reset this in `disconnectedCallback`. Overlays disconnect and - * reconnect the host without re-creating the observers, so a reset there - * makes `hiddenCallback` miss the dismissal. - */ - private hasBeenIntersecting = false; private prevPresentation: string | null = null; @@ -1158,14 +1148,23 @@ export class Datetime implements ComponentInterface { return; } - const rect = this.el.getBoundingClientRect(); - if (rect.width === 0 || rect.height === 0) { + if (!this.hasLayoutBox()) { return; } this.markReady(); }; + /** + * Whether the datetime is on screen. A modal or popover hides its contents + * with `display: none`, which leaves the host without a layout box. + */ + private hasLayoutBox = () => { + const { width, height } = this.el.getBoundingClientRect(); + + return width > 0 && height > 0; + }; + private markReady = () => { if (this.el.classList.contains('datetime-ready')) { return; @@ -1203,12 +1202,15 @@ export class Datetime implements ComponentInterface { * areas will not have the correct values snapped into place. */ const visibleCallback = (entries: IntersectionObserverEntry[]) => { - const ev = entries[0]; + /** + * The browser can batch several observations into one callback, so + * only the last entry describes the datetime now. + */ + const ev = entries[entries.length - 1]; if (!ev.isIntersecting) { return; } - this.hasBeenIntersecting = true; this.markReady(); }; const visibleIO = new IntersectionObserver(visibleCallback, { threshold: 0.01, root: el }); @@ -1244,16 +1246,23 @@ export class Datetime implements ComponentInterface { * we did originally has been lost. */ const hiddenCallback = (entries: IntersectionObserverEntry[]) => { - const ev = entries[0]; + const ev = entries[entries.length - 1]; if (ev.isIntersecting) { return; } - // Ignore the initial "not intersecting" entry IntersectionObserver fires on observe(). - if (!this.hasBeenIntersecting) { + /** + * WebKit reports a datetime that is still on screen as not + * intersecting, and it doesn't deliver that entry to every observer on + * the same root and target, so `visibleCallback` may never hear about + * it and add `datetime-ready` back. That is what left the calendar + * blank in #30933. Checking the host instead of trusting the entry + * also covers the synthetic "not intersecting" entry `observe()` fires + * when the datetime mounts offscreen. + */ + if (this.hasLayoutBox()) { return; } - this.hasBeenIntersecting = false; this.destroyInteractionListeners(); diff --git a/core/src/components/datetime/test/basic/datetime.e2e.ts b/core/src/components/datetime/test/basic/datetime.e2e.ts index 3d8c8f111f3..e2ac80a6c9e 100644 --- a/core/src/components/datetime/test/basic/datetime.e2e.ts +++ b/core/src/components/datetime/test/basic/datetime.e2e.ts @@ -488,6 +488,107 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { }); }); +/** + * WebKit can report a datetime that is still on screen as not intersecting, + * so the datetime must not tear down its ready state on that alone. + */ +configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { + test.describe(title('datetime: spurious hidden report'), () => { + test('should stay ready when the observer reports it hidden while it is on screen', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/30933', + }); + + await page.addInitScript(() => { + const OriginalIO = window.IntersectionObserver; + const datetimeObservers: { + callback: IntersectionObserverCallback; + targets: Element[]; + sawVisible: boolean; + }[] = []; + let reportedHidden = false; + + /** + * The datetime only tears down once its observers have reported it + * visible, so the test waits for that first. + */ + (window as any).datetimeObserversSawVisible = () => + datetimeObservers.length > 0 && datetimeObservers.every(({ sawVisible }) => sawVisible); + + /** + * Reports the datetime as not intersecting and then goes quiet, + * since WebKit never sends a recovery entry to undo it. + */ + (window as any).reportHiddenToDatetimeObservers = () => { + reportedHidden = true; + datetimeObservers.forEach(({ callback, targets }) => { + targets.forEach((target) => { + callback([{ isIntersecting: false, target } as IntersectionObserverEntry], null as any); + }); + }); + }; + + (window as any).IntersectionObserver = function ( + callback: IntersectionObserverCallback, + options?: IntersectionObserverInit + ) { + const root = options?.root as Element | null; + + if (root?.tagName !== 'ION-DATETIME') { + return new OriginalIO(callback, options); + } + + const record = { callback, targets: [] as Element[], sawVisible: false }; + datetimeObservers.push(record); + + const instance = new OriginalIO((entries, observer) => { + if (reportedHidden) { + return; + } + + if (entries.some((entry) => entry.isIntersecting)) { + record.sawVisible = true; + } + + callback(entries, observer); + }, options); + + const originalObserve = instance.observe.bind(instance); + instance.observe = (target: Element) => { + record.targets.push(target); + originalObserve(target); + }; + + return instance; + } as any; + }); + + await page.setContent(``, config); + + const datetime = page.locator('ion-datetime'); + const calendarBody = datetime.locator('.calendar-body'); + + await expect(datetime).toHaveClass(/datetime-ready/); + await expect(calendarBody).toHaveCSS('opacity', '1'); + await page.waitForFunction(() => (window as any).datetimeObserversSawVisible()); + + /** + * A one-shot layout fallback runs 100ms after the datetime loads and + * would add the class back. Real reports arrive long after that, so + * wait it out. + */ + await page.waitForTimeout(300); + + await page.evaluate(() => (window as any).reportHiddenToDatetimeObservers()); + await page.waitForChanges(); + + await expect(datetime).toHaveClass(/datetime-ready/); + await expect(calendarBody).toHaveCSS('opacity', '1'); + }); + }); +}); + /** * We are setting RTL on the component * instead, so we don't need to test