Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
45 changes: 27 additions & 18 deletions core/src/components/datetime/datetime.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -136,16 +136,6 @@ export class Datetime implements ComponentInterface {
private todayParts!: DatetimeParts;
private defaultParts!: DatetimeParts;
private loadTimeout: ReturnType<typeof setTimeout> | 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;

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -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();

Expand Down
101 changes: 101 additions & 0 deletions core/src/components/datetime/test/basic/datetime.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(`<ion-datetime value="2022-05-03"></ion-datetime>`, 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
Expand Down
Loading