Skip to content

Commit d97de23

Browse files
committed
fix(datetime): use ResizeObserver to reliably detect visibility changes
1 parent 2ee52d7 commit d97de23

File tree

2 files changed

+56
-136
lines changed

2 files changed

+56
-136
lines changed

core/src/components/datetime/datetime.tsx

Lines changed: 49 additions & 107 deletions
Original file line numberDiff line numberDiff line change
@@ -108,8 +108,8 @@ export class Datetime implements ComponentInterface {
108108
private inputId = `ion-dt-${datetimeIds++}`;
109109
private calendarBodyRef?: HTMLElement;
110110
private popoverRef?: HTMLIonPopoverElement;
111-
private intersectionTrackerRef?: HTMLElement;
112111
private clearFocusVisible?: () => void;
112+
private resizeObserver?: ResizeObserver;
113113
private parsedMinuteValues?: number[];
114114
private parsedHourValues?: number[];
115115
private parsedMonthValues?: number[];
@@ -1077,6 +1077,10 @@ export class Datetime implements ComponentInterface {
10771077
this.clearFocusVisible();
10781078
this.clearFocusVisible = undefined;
10791079
}
1080+
if (this.resizeObserver) {
1081+
this.resizeObserver.disconnect();
1082+
this.resizeObserver = undefined;
1083+
}
10801084
}
10811085

10821086
/**
@@ -1101,114 +1105,66 @@ export class Datetime implements ComponentInterface {
11011105
this.initializeKeyboardListeners();
11021106
}
11031107

1104-
/**
1105-
* TODO(FW-6931): Remove this fallback upon solving the root cause
1106-
* Fallback to ensure the datetime becomes ready even if
1107-
* IntersectionObserver never reports it as intersecting.
1108-
*
1109-
* This is primarily used in environments where the observer
1110-
* might not fire as expected, such as when running under
1111-
* synthetic tests that stub IntersectionObserver.
1112-
*/
1113-
private ensureReadyIfVisible = () => {
1114-
if (this.el.classList.contains('datetime-ready')) {
1115-
return;
1116-
}
1117-
1118-
const rect = this.el.getBoundingClientRect();
1119-
if (rect.width === 0 || rect.height === 0) {
1120-
return;
1121-
}
1122-
1123-
this.initializeListeners();
1124-
1125-
writeTask(() => {
1126-
this.el.classList.add('datetime-ready');
1127-
});
1128-
};
1129-
11301108
componentDidLoad() {
1131-
const { el, intersectionTrackerRef } = this;
1109+
const { el } = this;
11321110

11331111
/**
11341112
* If a scrollable element is hidden using `display: none`,
11351113
* it will not have a scroll height meaning we cannot scroll elements
11361114
* into view. As a result, we will need to wait for the datetime to become
11371115
* visible if used inside of a modal or a popover otherwise the scrollable
11381116
* areas will not have the correct values snapped into place.
1117+
*
1118+
* We use ResizeObserver to detect when the element transitions
1119+
* between having dimensions (visible) and zero dimensions (hidden). This
1120+
* is more reliable than IntersectionObserver for detecting visibility
1121+
* changes, especially when the element is inside a modal or popover.
11391122
*/
1140-
const visibleCallback = (entries: IntersectionObserverEntry[]) => {
1141-
const ev = entries[0];
1142-
if (!ev.isIntersecting) {
1143-
return;
1144-
}
1123+
this.resizeObserver = new ResizeObserver((entries) => {
1124+
const entry = entries[0];
1125+
const { width, height } = entry.contentRect;
1126+
const isVisible = width > 0 && height > 0;
1127+
const isReady = el.classList.contains('datetime-ready');
11451128

1146-
this.initializeListeners();
1129+
if (isVisible && !isReady) {
1130+
this.initializeListeners();
11471131

1148-
/**
1149-
* TODO FW-2793: Datetime needs a frame to ensure that it
1150-
* can properly scroll contents into view. As a result
1151-
* we hide the scrollable content until after that frame
1152-
* so users do not see the content quickly shifting. The downside
1153-
* is that the content will pop into view a frame after. Maybe there
1154-
* is a better way to handle this?
1155-
*/
1156-
writeTask(() => {
1157-
this.el.classList.add('datetime-ready');
1158-
});
1159-
};
1160-
const visibleIO = new IntersectionObserver(visibleCallback, { threshold: 0.01, root: el });
1132+
/**
1133+
* TODO FW-2793: Datetime needs a frame to ensure that it
1134+
* can properly scroll contents into view. As a result
1135+
* we hide the scrollable content until after that frame
1136+
* so users do not see the content quickly shifting. The downside
1137+
* is that the content will pop into view a frame after. Maybe there
1138+
* is a better way to handle this?
1139+
*/
1140+
writeTask(() => {
1141+
el.classList.add('datetime-ready');
1142+
});
1143+
} else if (!isVisible && isReady) {
1144+
/**
1145+
* Clean up listeners when hidden so we can properly
1146+
* reinitialize scroll positions on re-presentation.
1147+
*/
1148+
this.destroyInteractionListeners();
11611149

1162-
/**
1163-
* Use raf to avoid a race condition between the component loading and
1164-
* its display animation starting (such as when shown in a modal). This
1165-
* could cause the datetime to start at a visibility of 0, erroneously
1166-
* triggering the `hiddenIO` observer below.
1167-
*/
1168-
raf(() => visibleIO?.observe(intersectionTrackerRef!));
1150+
/**
1151+
* Close month/year picker when hidden, otherwise
1152+
* it will be open when re-presented with a 0-height
1153+
* scroll area, showing the wrong month.
1154+
*/
1155+
this.showMonthAndYear = false;
11691156

1170-
/**
1171-
* TODO(FW-6931): Remove this fallback upon solving the root cause
1172-
* Fallback: If IntersectionObserver never reports that the
1173-
* datetime is visible but the host clearly has layout, ensure
1174-
* we still initialize listeners and mark the component as ready.
1175-
*
1176-
* We schedule this after everything has had a chance to run.
1177-
*/
1178-
setTimeout(() => {
1179-
this.ensureReadyIfVisible();
1180-
}, 100);
1157+
writeTask(() => {
1158+
el.classList.remove('datetime-ready');
1159+
});
1160+
}
1161+
});
11811162

11821163
/**
1183-
* We need to clean up listeners when the datetime is hidden
1184-
* in a popover/modal so that we can properly scroll containers
1185-
* back into view if they are re-presented. When the datetime is hidden
1186-
* the scroll areas have scroll widths/heights of 0px, so any snapping
1187-
* we did originally has been lost.
1164+
* Use raf to avoid a race condition between the component loading and
1165+
* its display animation starting (such as when shown in a modal).
11881166
*/
1189-
const hiddenCallback = (entries: IntersectionObserverEntry[]) => {
1190-
const ev = entries[0];
1191-
if (ev.isIntersecting) {
1192-
return;
1193-
}
1194-
1195-
this.destroyInteractionListeners();
1196-
1197-
/**
1198-
* When datetime is hidden, we need to make sure that
1199-
* the month/year picker is closed. Otherwise,
1200-
* it will be open when the datetime re-appears
1201-
* and the scroll area of the calendar grid will be 0.
1202-
* As a result, the wrong month will be shown.
1203-
*/
1204-
this.showMonthAndYear = false;
1205-
1206-
writeTask(() => {
1207-
this.el.classList.remove('datetime-ready');
1208-
});
1209-
};
1210-
const hiddenIO = new IntersectionObserver(hiddenCallback, { threshold: 0, root: el });
1211-
raf(() => hiddenIO?.observe(intersectionTrackerRef!));
1167+
raf(() => this.resizeObserver?.observe(el));
12121168

12131169
/**
12141170
* Datetime uses Ionic components that emit
@@ -2693,20 +2649,6 @@ export class Datetime implements ComponentInterface {
26932649
}),
26942650
}}
26952651
>
2696-
{/*
2697-
WebKit has a quirk where IntersectionObserver callbacks are delayed until after
2698-
an accelerated animation finishes if the "root" specified in the config is the
2699-
browser viewport (the default behavior if "root" is not specified). This means
2700-
that when presenting a datetime in a modal on iOS the calendar body appears
2701-
blank until the modal animation finishes.
2702-
2703-
We can work around this by observing .intersection-tracker and using the host
2704-
(ion-datetime) as the "root". This allows the IO callback to fire the moment
2705-
the datetime is visible. The .intersection-tracker element should not have
2706-
dimensions or additional styles, and it should not be positioned absolutely
2707-
otherwise the IO callback may fire at unexpected times.
2708-
*/}
2709-
<div class="intersection-tracker" ref={(el) => (this.intersectionTrackerRef = el)}></div>
27102652
{this.renderDatetime(mode)}
27112653
</Host>
27122654
);

core/src/components/datetime/test/basic/datetime.e2e.ts

Lines changed: 7 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -395,40 +395,18 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
395395
});
396396

397397
/**
398-
* Synthetic IntersectionObserver fallback behavior.
399-
*
400-
* This test stubs IntersectionObserver so that the callback
401-
* never reports an intersecting entry. The datetime should
402-
* still become ready via its internal fallback logic.
398+
* Verify that datetime becomes ready via ResizeObserver.
399+
* This tests that the datetime properly initializes when it has
400+
* dimensions, using ResizeObserver to detect visibility.
403401
*/
404402
configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => {
405-
test.describe(title('datetime: IO fallback'), () => {
406-
test('should become ready even if IntersectionObserver never reports visible', async ({ page }, testInfo) => {
403+
test.describe(title('datetime: visibility detection'), () => {
404+
test('should become ready when rendered with dimensions', async ({ page }, testInfo) => {
407405
testInfo.annotations.push({
408406
type: 'issue',
409407
description: 'https://github.com/ionic-team/ionic-framework/issues/30706',
410408
});
411409

412-
await page.addInitScript(() => {
413-
const OriginalIO = window.IntersectionObserver;
414-
(window as any).IntersectionObserver = function (callback: any, options: any) {
415-
const instance = new OriginalIO(() => {}, options);
416-
const originalObserve = instance.observe.bind(instance);
417-
418-
instance.observe = (target: Element) => {
419-
originalObserve(target);
420-
callback([
421-
{
422-
isIntersecting: false,
423-
target,
424-
} as IntersectionObserverEntry,
425-
]);
426-
};
427-
428-
return instance;
429-
} as any;
430-
});
431-
432410
await page.setContent(
433411
`
434412
<ion-datetime value="2022-05-03"></ion-datetime>
@@ -438,8 +416,8 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => {
438416

439417
const datetime = page.locator('ion-datetime');
440418

441-
// Give the fallback a short amount of time to run
442-
await page.waitForTimeout(100);
419+
// Wait for the datetime to become ready via ResizeObserver
420+
await page.locator('.datetime-ready').waitFor();
443421

444422
await expect(datetime).toHaveClass(/datetime-ready/);
445423

0 commit comments

Comments
 (0)