From ca60cf2bc429bf98c5cc0147c3201a28c8455f0d Mon Sep 17 00:00:00 2001 From: Gilad Feder Date: Fri, 24 Jul 2026 14:27:42 +0100 Subject: [PATCH 1/3] fix: include portal content in auto-resize --- src/app.ts | 56 +++++++++++++++++++++++++---- tests/e2e/auto-resize.spec.ts | 68 +++++++++++++++++++++++++++++++++++ 2 files changed, 118 insertions(+), 6 deletions(-) create mode 100644 tests/e2e/auto-resize.spec.ts diff --git a/src/app.ts b/src/app.ts index adfad5c77..a881379a9 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,36 @@ 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(); + document.body.querySelectorAll("*").forEach((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), + ); + } + }); + + height = Math.ceil(height); html.style.height = originalHeight; + mutationObserver.takeRecords(); const width = Math.ceil(window.innerWidth); @@ -1903,7 +1933,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..aff845960 --- /dev/null +++ b/tests/e2e/auto-resize.spec.ts @@ -0,0 +1,68 @@ +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(); + + cleanup(); + document.body.append(portal); + await waitForMeasurement(); + + return reportedSizes; + }); + + expect(sizes.map(({ height }) => height)).toEqual([100, 400, 600, 100]); +}); From de9bdbc9160134d027eece93c0bb581728703bcd Mon Sep 17 00:00:00 2001 From: Gilad Feder Date: Fri, 24 Jul 2026 14:42:21 +0100 Subject: [PATCH 2/3] fix: respect body overflow in auto-resize --- src/app.ts | 6 ++++-- tests/e2e/auto-resize.spec.ts | 6 +++++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/app.ts b/src/app.ts index a881379a9..ff95aa9aa 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1890,7 +1890,7 @@ export class App extends ProtocolWithEvents< // 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(); - document.body.querySelectorAll("*").forEach((element) => { + const measureElement = (element: Element) => { const rect = element.getBoundingClientRect(); const parentElement = element.parentElement; const inheritedClippingBottom = @@ -1909,7 +1909,9 @@ export class App extends ProtocolWithEvents< : Math.min(rect.bottom, inheritedClippingBottom), ); } - }); + }; + measureElement(document.body); + document.body.querySelectorAll("*").forEach(measureElement); height = Math.ceil(height); html.style.height = originalHeight; diff --git a/tests/e2e/auto-resize.spec.ts b/tests/e2e/auto-resize.spec.ts index aff845960..1d4f52c6b 100644 --- a/tests/e2e/auto-resize.spec.ts +++ b/tests/e2e/auto-resize.spec.ts @@ -57,6 +57,10 @@ test("auto-resize tracks out-of-flow portal content", async ({ page }) => { '
'; await waitForMeasurement(); + document.body.style.cssText = "height: 120px; overflow: auto"; + document.body.innerHTML = '
'; + await waitForMeasurement(); + cleanup(); document.body.append(portal); await waitForMeasurement(); @@ -64,5 +68,5 @@ test("auto-resize tracks out-of-flow portal content", async ({ page }) => { return reportedSizes; }); - expect(sizes.map(({ height }) => height)).toEqual([100, 400, 600, 100]); + expect(sizes.map(({ height }) => height)).toEqual([100, 400, 600, 100, 120]); }); From 2075ef7010497ce84f257d6b4b8d36f1d0b4e58e Mon Sep 17 00:00:00 2001 From: Gilad Feder Date: Fri, 24 Jul 2026 14:51:03 +0100 Subject: [PATCH 3/3] perf: avoid allocating resize traversal list --- src/app.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/app.ts b/src/app.ts index ff95aa9aa..c43135e0f 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1911,7 +1911,15 @@ export class App extends ProtocolWithEvents< } }; measureElement(document.body); - document.body.querySelectorAll("*").forEach(measureElement); + 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;