Skip to content
Open
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
66 changes: 60 additions & 6 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand All @@ -1858,6 +1859,7 @@ export class App extends ProtocolWithEvents<
*/
setupSizeChangedNotifications() {
let scheduled = false;
let animationFrameId: number | undefined;
let lastWidth = 0;
let lastHeight = 0;

Expand All @@ -1866,7 +1868,7 @@ export class App extends ProtocolWithEvents<
return;
}
scheduled = true;
requestAnimationFrame(() => {
animationFrameId = requestAnimationFrame(() => {
scheduled = false;
const html = document.documentElement;

Expand All @@ -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<Element, number>();
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);

Expand All @@ -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);
}
};
}

/**
Expand Down
72 changes: 72 additions & 0 deletions tests/e2e/auto-resize.spec.ts
Original file line number Diff line number Diff line change
@@ -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(`<!doctype html>
<style>
html, body { margin: 0; }
#content { height: 100px; }
#portal { position: absolute; top: 300px; height: 100px; }
.scroll { height: 100px; overflow: auto; }
.scroll-content { height: 1000px; }
</style>
<div id="content"></div>`);
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 =
'<div class="scroll"><div class="scroll-content"></div></div>';
await waitForMeasurement();

document.body.style.cssText = "height: 120px; overflow: auto";
document.body.innerHTML = '<div class="scroll-content"></div>';
await waitForMeasurement();

cleanup();
document.body.append(portal);
await waitForMeasurement();

return reportedSizes;
});

expect(sizes.map(({ height }) => height)).toEqual([100, 400, 600, 100, 120]);
});