From 08cb7233c08868d908dddd8e601b50589ec362b6 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Mon, 20 Apr 2026 19:52:04 +0100 Subject: [PATCH] fix(app): autoResize collapses height:100% layouts; expose autoResize in useApp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit setupSizeChangedNotifications measured height by transiently forcing html.style.height = "max-content". During that reflow, descendants with height:100% resolve to auto (parent has no definite size), so apps that set html,body{height:100%} with a viewport-filling child reported a spinner-sized height — and never recovered once the iframe shrank. Measure body.scrollHeight + body margins instead. This still grows on overflow (#525) and shrinks for default-styled bodies (#57), without overriding author styles. Apps that explicitly set body{height:100%} no longer shrink below the host's initial size, which is the semantically correct behavior for a viewport-filling layout. Also expose `autoResize` in UseAppOptions so React apps can opt out without dropping the hook. Addresses #143, #189, #502. --- src/app.ts | 36 ++++++++++++++++++++++-------------- src/react/useApp.tsx | 17 ++++++++++++----- 2 files changed, 34 insertions(+), 19 deletions(-) diff --git a/src/app.ts b/src/app.ts index 4f429e829..acc10a372 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1391,22 +1391,30 @@ export class App extends ProtocolWithEvents< scheduled = true; requestAnimationFrame(() => { scheduled = false; - const html = document.documentElement; + const body = document.body; - // Measure actual content height by temporarily overriding html sizing. - // Height uses max-content because fit-content would clamp to the viewport - // height when content is taller than the iframe, causing internal scrolling. + // Measure content height via body.scrollHeight + vertical body margins. // - // Width uses window.innerWidth instead of measuring via fit-content. - // Setting html.style.width to fit-content forces a synchronous reflow at - // 0px width for responsive apps (whose content derives width from the - // container rather than having intrinsic width). This causes the browser - // to clamp scrollLeft on any horizontal scroll containers to 0, permanently - // destroying their scroll positions. - const originalHeight = html.style.height; - html.style.height = "max-content"; - const height = Math.ceil(html.getBoundingClientRect().height); - html.style.height = originalHeight; + // scrollHeight captures overflow (so the iframe grows when content is + // taller than the viewport — see #525) and equals the content height for + // a default-styled body (so the iframe shrinks to fit — see #57). + // + // We previously forced html.style.height = "max-content" to measure + // intrinsic height, but that makes height:100% descendants resolve to + // auto during measurement. Apps that set html,body{height:100%} with a + // viewport-filling child reported a collapsed (spinner-sized) height + // and then never recovered — see #143. Reading scrollHeight without a + // style override leaves those layouts intact. + // + // Tradeoff: an app that sets body{height:100%} won't shrink below the + // current viewport height. That's the semantically correct behavior for + // a viewport-filling layout; apps that need explicit control should + // pass {autoResize: false} and call sendSizeChanged() manually. + const bodyStyle = getComputedStyle(body); + const bodyMarginY = + (parseFloat(bodyStyle.marginTop) || 0) + + (parseFloat(bodyStyle.marginBottom) || 0); + const height = Math.ceil(body.scrollHeight + bodyMarginY); const width = Math.ceil(window.innerWidth); diff --git a/src/react/useApp.tsx b/src/react/useApp.tsx index d6dcfbb79..8ce70e53a 100644 --- a/src/react/useApp.tsx +++ b/src/react/useApp.tsx @@ -7,10 +7,6 @@ export * from "../app"; /** * Options for configuring the {@link useApp `useApp`} hook. * - * Note: This interface does NOT expose {@link App `App`} options like `autoResize`. - * The hook creates the `App` with default options (`autoResize: true`). If you - * need custom `App` options, create the `App` manually instead of using this hook. - * * @see {@link useApp `useApp`} for the hook that uses these options * @see {@link useAutoResize `useAutoResize`} for manual auto-resize control with custom `App` options */ @@ -21,6 +17,16 @@ export interface UseAppOptions { * Declares what features this app supports. */ capabilities: McpUiAppCapabilities; + /** + * Automatically report size changes to the host. + * + * Disable this for apps that manage their own height (canvases, editors, + * diagrams) or that use viewport-relative sizing like `100vh`, and call + * {@link App.sendSizeChanged `app.sendSizeChanged`} manually instead. + * + * @default true + */ + autoResize?: boolean; /** * Called after {@link App `App`} is created but before connection. * @@ -120,6 +126,7 @@ export interface AppState { export function useApp({ appInfo, capabilities, + autoResize = true, onAppCreated, }: UseAppOptions): AppState { const [app, setApp] = useState(null); @@ -135,7 +142,7 @@ export function useApp({ window.parent, window.parent, ); - const app = new App(appInfo, capabilities); + const app = new App(appInfo, capabilities, { autoResize }); // Register handlers BEFORE connecting onAppCreated?.(app);