diff --git a/src/app.ts b/src/app.ts index adfad5c77..c43135e0f 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1828,10 +1828,11 @@ export class App extends ProtocolWithEvents< } /** - * Set up automatic size change notifications using ResizeObserver. + * Set up automatic size change notifications using ResizeObserver and MutationObserver. * - * Observes both `document.documentElement` and `document.body` for size changes - * and automatically sends `ui/notifications/size-changed` notifications to the host. + * Observes both `document.documentElement` and `document.body` for size changes, + * and the document for mutations that can affect out-of-flow content such as portals. + * Automatically sends `ui/notifications/size-changed` notifications to the host. * The notifications are debounced using requestAnimationFrame to avoid duplicates. * * Note: This method is automatically called by `connect()` if the `autoResize` @@ -1858,6 +1859,7 @@ export class App extends ProtocolWithEvents< */ setupSizeChangedNotifications() { let scheduled = false; + let animationFrameId: number | undefined; let lastWidth = 0; let lastHeight = 0; @@ -1866,7 +1868,7 @@ export class App extends ProtocolWithEvents< return; } scheduled = true; - requestAnimationFrame(() => { + animationFrameId = requestAnimationFrame(() => { scheduled = false; const html = document.documentElement; @@ -1882,8 +1884,46 @@ export class App extends ProtocolWithEvents< // destroying their scroll positions. const originalHeight = html.style.height; html.style.height = "max-content"; - const height = Math.ceil(html.getBoundingClientRect().height); + const htmlRect = html.getBoundingClientRect(); + let height = htmlRect.height; + + // Out-of-flow descendants (for example, menus rendered in a portal) + // do not contribute to the html element's intrinsic height. + const clippingBottoms = new WeakMap(); + const measureElement = (element: Element) => { + const rect = element.getBoundingClientRect(); + const parentElement = element.parentElement; + const inheritedClippingBottom = + (parentElement && clippingBottoms.get(parentElement)) ?? Infinity; + height = Math.max( + height, + Math.min(rect.bottom, inheritedClippingBottom) - htmlRect.top, + ); + + if (element.childElementCount > 0) { + const overflowY = getComputedStyle(element).overflowY; + clippingBottoms.set( + element, + overflowY === "visible" + ? inheritedClippingBottom + : Math.min(rect.bottom, inheritedClippingBottom), + ); + } + }; + measureElement(document.body); + const treeWalker = document.createTreeWalker( + document.body, + NodeFilter.SHOW_ELEMENT, + ); + let element = treeWalker.nextNode() as Element | null; + while (element) { + measureElement(element); + element = treeWalker.nextNode() as Element | null; + } + + height = Math.ceil(height); html.style.height = originalHeight; + mutationObserver.takeRecords(); const width = Math.ceil(window.innerWidth); @@ -1903,7 +1943,21 @@ export class App extends ProtocolWithEvents< resizeObserver.observe(document.documentElement); resizeObserver.observe(document.body); - return () => resizeObserver.disconnect(); + const mutationObserver = new MutationObserver(sendBodySizeChanged); + mutationObserver.observe(document.documentElement, { + attributes: true, + childList: true, + characterData: true, + subtree: true, + }); + + return () => { + resizeObserver.disconnect(); + mutationObserver.disconnect(); + if (animationFrameId !== undefined) { + cancelAnimationFrame(animationFrameId); + } + }; } /** diff --git a/tests/e2e/auto-resize.spec.ts b/tests/e2e/auto-resize.spec.ts new file mode 100644 index 000000000..1d4f52c6b --- /dev/null +++ b/tests/e2e/auto-resize.spec.ts @@ -0,0 +1,72 @@ +import { test, expect } from "@playwright/test"; +import { resolve } from "node:path"; + +test("auto-resize tracks out-of-flow portal content", async ({ page }) => { + await page.route("**/app-with-deps.js", (route) => + route.fulfill({ + contentType: "text/javascript", + path: resolve("dist/src/app-with-deps.js"), + }), + ); + await page.goto("/"); + + const sizes = await page.evaluate(async () => { + document.open(); + document.write(` + +
`); + document.close(); + + const { App } = await import("/app-with-deps.js"); + const app = new App( + { name: "auto-resize-test", version: "1.0.0" }, + {}, + { autoResize: false }, + ); + const reportedSizes: Array<{ width?: number; height?: number }> = []; + app.sendSizeChanged = async (size) => { + reportedSizes.push(size); + }; + + const waitForMeasurement = async () => { + await new Promise((resolve) => requestAnimationFrame(resolve)); + await new Promise((resolve) => requestAnimationFrame(resolve)); + }; + + const cleanup = app.setupSizeChangedNotifications(); + await waitForMeasurement(); + + const portal = document.createElement("div"); + portal.id = "portal"; + document.body.append(portal); + await waitForMeasurement(); + + portal.style.top = "500px"; + await waitForMeasurement(); + + portal.remove(); + await waitForMeasurement(); + + document.body.innerHTML = + '
'; + await waitForMeasurement(); + + document.body.style.cssText = "height: 120px; overflow: auto"; + document.body.innerHTML = '
'; + await waitForMeasurement(); + + cleanup(); + document.body.append(portal); + await waitForMeasurement(); + + return reportedSizes; + }); + + expect(sizes.map(({ height }) => height)).toEqual([100, 400, 600, 100, 120]); +});