From d58c37d562b6a28c4bd74e3e18267371406587df Mon Sep 17 00:00:00 2001 From: ShaneK Date: Mon, 10 Aug 2026 14:52:18 -0700 Subject: [PATCH] fix(core): update offsets when safe-area insets arrive late --- core/src/components/content/content.tsx | 72 ++++++- .../content/test/safe-area/content.e2e.ts | 194 ++++++++++++++++++ core/src/components/modal/modal.tsx | 21 +- core/src/components/modal/safe-area-utils.ts | 39 ++++ .../modal/test/safe-area/modal.e2e.ts | 56 +++++ 5 files changed, 375 insertions(+), 7 deletions(-) create mode 100644 core/src/components/content/test/safe-area/content.e2e.ts diff --git a/core/src/components/content/content.tsx b/core/src/components/content/content.tsx index b981a323dcf..b0673b414d5 100644 --- a/core/src/components/content/content.tsx +++ b/core/src/components/content/content.tsx @@ -1,5 +1,18 @@ import type { ComponentInterface, EventEmitter } from '@stencil/core'; -import { Build, Component, Element, Event, Host, Listen, Method, Prop, forceUpdate, h, readTask } from '@stencil/core'; +import { + Build, + Component, + Element, + Event, + Host, + Listen, + Method, + Prop, + Watch, + forceUpdate, + h, + readTask, +} from '@stencil/core'; import { componentOnReady, hasLazyBuild, inheritAriaAttributes } from '@utils/helpers'; import type { Attributes } from '@utils/helpers'; import { isPlatform } from '@utils/platform'; @@ -34,6 +47,7 @@ export class Content implements ComponentInterface { private backgroundContentEl?: HTMLElement; private isMainContent = true; private resizeTimeout: ReturnType | null = null; + private fullscreenResizeObserver?: ResizeObserver; private inheritedAttributes: Attributes = {}; private tabsElement: HTMLElement | null = null; @@ -77,6 +91,11 @@ export class Content implements ComponentInterface { */ @Prop() fullscreen = false; + @Watch('fullscreen') + fullscreenChanged() { + this.setupFullscreenResizeObserver(); + } + /** * Controls where the fixed content is placed relative to the main content * in the DOM. This can be used to control the order in which fixed elements @@ -168,6 +187,14 @@ export class Content implements ComponentInterface { closestTabs.addEventListener('ionTabBarLoaded', this.tabsLoadCallback); } } + + // Re-observe on reattach, since componentDidLoad only fires once. + this.setupFullscreenResizeObserver(); + } + + componentDidLoad() { + // The custom elements build assigns fullscreen after connectedCallback. + this.setupFullscreenResizeObserver(); } disconnectedCallback() { @@ -193,6 +220,49 @@ export class Content implements ComponentInterface { clearTimeout(this.resizeTimeout); this.resizeTimeout = null; } + + this.destroyFullscreenResizeObserver(); + } + + /** + * Header and footer sizes can change after load without a window resize + * firing, so the `resize` listener alone misses those changes. + * + * Popover content is excluded because `contain: none` lets its offsets drive + * the host's own height, which would feed back into the observer. + */ + private setupFullscreenResizeObserver() { + if (!Build.isBrowser || typeof ResizeObserver === 'undefined') { + return; + } + + if (!this.fullscreen || hostContext('ion-popover', this.el)) { + this.destroyFullscreenResizeObserver(); + return; + } + + if (this.fullscreenResizeObserver !== undefined) { + return; + } + + this.fullscreenResizeObserver = new ResizeObserver(() => { + // A hidden page reports a 0x0 box, which would zero the offsets. Same + // reasoning as the guard in onResize, minus its debounce so the + // correction lands in the next frame instead of 100ms later. + if (this.el.offsetParent === null) { + return; + } + + this.resize(); + }); + this.fullscreenResizeObserver.observe(this.el); + } + + private destroyFullscreenResizeObserver() { + if (this.fullscreenResizeObserver !== undefined) { + this.fullscreenResizeObserver.disconnect(); + this.fullscreenResizeObserver = undefined; + } } /** diff --git a/core/src/components/content/test/safe-area/content.e2e.ts b/core/src/components/content/test/safe-area/content.e2e.ts new file mode 100644 index 00000000000..f28a872fb1f --- /dev/null +++ b/core/src/components/content/test/safe-area/content.e2e.ts @@ -0,0 +1,194 @@ +import { expect } from '@playwright/test'; +import { configs, test } from '@utils/test/playwright'; + +/** + * ion-content does not have mode-specific styling + */ +configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { + test.describe(title('content: safe-area'), () => { + test('should keep the scroll region flush with the viewport when the safe area top changes after load', async ({ + page, + }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/31337', + }); + + await page.setContent( + ` + + + + + Header + + + + +

Content

+
+
+ `, + config + ); + + const scrollRegion = page.locator('ion-content .inner-scroll'); + const expectFlushWithTop = () => + expect(async () => { + expect(Math.abs((await scrollRegion.boundingBox())!.y)).toBeLessThanOrEqual(1); + }).toPass({ timeout: 5000 }); + + await expectFlushWithTop(); + + await page.evaluate(() => document.documentElement.style.setProperty('--ion-safe-area-top', '24px')); + + await expectFlushWithTop(); + }); + + test('should keep the scroll region flush with the viewport when the safe area bottom changes after load', async ({ + page, + }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/31337', + }); + + await page.setContent( + ` + + + +

Content

+
+ + + + Footer + + +
+ `, + config + ); + + const scrollRegion = page.locator('ion-content .inner-scroll'); + const viewportHeight = page.viewportSize()!.height; + const expectFlushWithBottom = () => + expect(async () => { + const box = (await scrollRegion.boundingBox())!; + expect(Math.abs(box.y + box.height - viewportHeight)).toBeLessThanOrEqual(1); + }).toPass({ timeout: 5000 }); + + await expectFlushWithBottom(); + + await page.evaluate(() => document.documentElement.style.setProperty('--ion-safe-area-bottom', '24px')); + + await expectFlushWithBottom(); + }); + + test('should recompute the offsets when fullscreen is enabled after load', async ({ page }) => { + await page.setContent( + ` + + + + Header + + + + +

Content

+
+
+ `, + config + ); + + const content = page.locator('ion-content'); + const scrollRegion = page.locator('ion-content .inner-scroll'); + const expectFlushWithTop = () => + expect(async () => { + expect(Math.abs((await scrollRegion.boundingBox())!.y)).toBeLessThanOrEqual(1); + }).toPass({ timeout: 5000 }); + + await content.evaluate((el: HTMLIonContentElement) => (el.fullscreen = true)); + await expectFlushWithTop(); + + // Only the observer created by the fullscreen watcher can catch this. + await page.evaluate(() => document.documentElement.style.setProperty('--ion-safe-area-top', '24px')); + + await expectFlushWithTop(); + }); + + test('should leave the offsets at zero when fullscreen is disabled after load', async ({ page }) => { + await page.setContent( + ` + + + + Header + + + + +

Content

+
+
+ `, + config + ); + + const content = page.locator('ion-content'); + const offsetTop = () => content.evaluate((el) => el.style.getPropertyValue('--offset-top')); + + await content.evaluate((el: HTMLIonContentElement) => (el.fullscreen = false)); + + await expect(async () => { + expect(await offsetTop()).toBe('0px'); + }).toPass({ timeout: 5000 }); + + await page.evaluate(() => document.documentElement.style.setProperty('--ion-safe-area-top', '24px')); + await page.waitForTimeout(300); + + expect(await offsetTop()).toBe('0px'); + }); + + test('should keep the offsets while the page is hidden', async ({ page }) => { + await page.setContent( + ` + +
+ + + Header + + + + +

Content

+
+
+
+ `, + config + ); + + const content = page.locator('ion-content'); + const scrollRegion = page.locator('ion-content .inner-scroll'); + const offsetTop = () => content.evaluate((el) => el.style.getPropertyValue('--offset-top')); + + await expect(async () => { + expect(Math.abs((await scrollRegion.boundingBox())!.y)).toBeLessThanOrEqual(1); + }).toPass({ timeout: 5000 }); + + const beforeHiding = await offsetTop(); + expect(beforeHiding).toBe(`${Math.round((await page.locator('ion-header').boundingBox())!.height)}px`); + + await page.evaluate(() => document.getElementById('page')!.classList.add('ion-page-hidden')); + // Long enough that a missed visibility guard would have committed by now. + await page.waitForTimeout(300); + + expect(await offsetTop()).toBe(beforeHiding); + }); + }); +}); diff --git a/core/src/components/modal/modal.tsx b/core/src/components/modal/modal.tsx index ea9b8140b70..0b6c1be162f 100644 --- a/core/src/components/modal/modal.tsx +++ b/core/src/components/modal/modal.tsx @@ -50,6 +50,7 @@ import { applySafeAreaOverrides, clearSafeAreaOverrides, getRootSafeAreaTop, + onRootSafeAreaTopChange, hasCustomModalDimensions, type ModalSafeAreaContext, } from './safe-area-utils'; @@ -110,6 +111,7 @@ export class Modal implements ComponentInterface, OverlayInterface { private currentViewIsPortrait?: boolean; private viewTransitionAnimation?: Animation; private resizeTimeout?: any; + private unsubscribeRootSafeAreaTop?: () => void; // Mutation observer to watch for parent removal private parentRemovalObserver?: MutationObserver; @@ -1480,17 +1482,21 @@ export class Modal implements ComponentInterface, OverlayInterface { // Set the internal offset property with the resolved root safe-area-top value if (context.isSheetModal) { this.updateSheetOffsetTop(); + this.unsubscribeRootSafeAreaTop = onRootSafeAreaTopChange((safeAreaTop) => + this.updateSheetOffsetTop(safeAreaTop) + ); } } /** - * Resolves the current root --ion-safe-area-top value and sets the - * internal --ion-modal-offset-top property on the host element. - * Called on present and on resize (e.g., device rotation changes safe-area). + * Sets the internal --ion-modal-offset-top property on the host element, + * resolving the current root --ion-safe-area-top when no value is given. + * Called on present, on resize (e.g., device rotation changes safe-area), + * and whenever the root safe-area value itself changes. */ - private updateSheetOffsetTop(): void { - const safeAreaTop = getRootSafeAreaTop(); - this.el.style.setProperty('--ion-modal-offset-top', `${safeAreaTop}px`); + private updateSheetOffsetTop(safeAreaTop?: number): void { + const value = safeAreaTop ?? getRootSafeAreaTop(); + this.el.style.setProperty('--ion-modal-offset-top', `${value}px`); } /** @@ -1593,6 +1599,9 @@ export class Modal implements ComponentInterface, OverlayInterface { private cleanupSafeAreaOverrides(): void { clearSafeAreaOverrides(this.el); + this.unsubscribeRootSafeAreaTop?.(); + this.unsubscribeRootSafeAreaTop = undefined; + // Remove internal sheet offset property this.el.style.removeProperty('--ion-modal-offset-top'); diff --git a/core/src/components/modal/safe-area-utils.ts b/core/src/components/modal/safe-area-utils.ts index 59ae3a4fdb5..b06b2315b63 100644 --- a/core/src/components/modal/safe-area-utils.ts +++ b/core/src/components/modal/safe-area-utils.ts @@ -104,6 +104,45 @@ export const getRootSafeAreaTop = (): number => { return value; }; +/** + * Calls back when the resolved root `--ion-safe-area-top` changes, which no + * event and no window resize covers. The probe's height tracks the variable, so + * a change to it becomes a size change the observer can see. + */ +export const onRootSafeAreaTopChange = (callback: (safeAreaTop: number) => void): (() => void) => { + const doc = win?.document; + if (!doc?.body || typeof ResizeObserver === 'undefined') { + return () => undefined; + } + + const probe = doc.createElement('div'); + probe.style.cssText = + 'position:fixed;visibility:hidden;pointer-events:none;top:0;left:0;width:0;' + + 'height:var(--ion-safe-area-top,0px);'; + doc.body.appendChild(probe); + + /** + * Seeded with the value the caller has already applied, so a change that + * lands before the observer's first delivery still gets reported. Comparing + * against an unset value instead would consume that first delivery and treat + * the new inset as the baseline. + */ + let lastHeight = getRootSafeAreaTop(); + const observer = new ResizeObserver((entries) => { + const { height } = entries[0].contentRect; + if (height !== lastHeight) { + lastHeight = height; + callback(height); + } + }); + observer.observe(probe); + + return () => { + observer.disconnect(); + probe.remove(); + }; +}; + /** * True when the modal host declares BOTH a non-fullscreen `--width` AND a * non-fullscreen `--height` (i.e. a centered-dialog-like modal that doesn't diff --git a/core/src/components/modal/test/safe-area/modal.e2e.ts b/core/src/components/modal/test/safe-area/modal.e2e.ts index 8f13349fe95..e247b174956 100644 --- a/core/src/components/modal/test/safe-area/modal.e2e.ts +++ b/core/src/components/modal/test/safe-area/modal.e2e.ts @@ -257,6 +257,62 @@ configs({ modes: ['ios', 'md'], directions: ['ltr'] }).forEach(({ title, config expect(offsetTop).toBe(`${TEST_SAFE_AREA_TOP}px`); }); + test('sheet modal should update --ion-modal-offset-top when the safe area top changes after present', async ({ + page, + }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/31337', + }); + + const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent'); + + await page.click('#sheet-modal'); + await ionModalDidPresent.next(); + + const modal = page.locator('ion-modal'); + const offsetTop = () => + modal.evaluate((el: HTMLIonModalElement) => el.style.getPropertyValue('--ion-modal-offset-top')); + + expect(await offsetTop()).toBe(`${TEST_SAFE_AREA_TOP}px`); + + await page.evaluate(() => document.documentElement.style.setProperty('--ion-safe-area-top', '24px')); + + await expect(async () => { + expect(await offsetTop()).toBe('24px'); + }).toPass({ timeout: 5000 }); + }); + + /** + * Covers a sheet presented before the inset is known, which is the Android + * edge-to-edge startup order rather than the 47px the test page declares. + */ + test('sheet modal should pick up the safe area top when it starts at zero', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/31337', + }); + + await page.evaluate(() => document.documentElement.style.setProperty('--ion-safe-area-top', '0px')); + + const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent'); + + await page.click('#sheet-modal'); + await ionModalDidPresent.next(); + + const modal = page.locator('ion-modal'); + const offsetTop = () => + modal.evaluate((el: HTMLIonModalElement) => el.style.getPropertyValue('--ion-modal-offset-top')); + + expect(await offsetTop()).toBe('0px'); + + await page.evaluate(() => document.documentElement.style.setProperty('--ion-safe-area-top', '24px')); + + await expect(async () => { + expect(await offsetTop()).toBe('24px'); + }).toPass({ timeout: 5000 }); + }); + test('fullscreen modal safe-area should update on resize from phone to tablet', async ({ page }, testInfo) => { testInfo.annotations.push({ type: 'issue',