From 7d64d1f937e91cb10465e3900d0ffda7105fbaef Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Mon, 10 Aug 2026 15:50:10 +0530 Subject: [PATCH] Fix split composer measurement loop --- .../promptbox/FollowUpPromptBox.test.tsx | 57 +++++++++++++++++-- .../promptbox/FollowUpPromptBox.tsx | 24 ++++---- 2 files changed, 64 insertions(+), 17 deletions(-) diff --git a/apps/app/src/components/promptbox/FollowUpPromptBox.test.tsx b/apps/app/src/components/promptbox/FollowUpPromptBox.test.tsx index 640807acce..62381b67e8 100644 --- a/apps/app/src/components/promptbox/FollowUpPromptBox.test.tsx +++ b/apps/app/src/components/promptbox/FollowUpPromptBox.test.tsx @@ -7,7 +7,8 @@ import { render, screen, } from "@testing-library/react"; -import type { ReactNode } from "react"; +import { Profiler, startTransition, type ReactNode } from "react"; +import { flushSync } from "react-dom"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { resetPluginSlotStoreForTest, @@ -258,6 +259,44 @@ beforeEach(() => { }); describe("FollowUpPromptBox", () => { + it("does not commit an unchanged measurement while a height update is pending", () => { + const onRender = vi.fn(); + render( + + Stack} + /> + , + ); + const stackElement = screen.getByTestId("measured-stack").parentElement; + if (!stackElement) throw new Error("Expected measured composer stack"); + Object.defineProperty(stackElement, "offsetHeight", { + configurable: true, + value: 24, + }); + let commitsAfterSynchronousSignal = -1; + const resizeEntries = [ + { contentRect: { height: 24 } } as ResizeObserverEntry, + ]; + + act(() => { + startTransition(() => { + resizeObserverCallback?.(resizeEntries, {} as ResizeObserver); + }); + flushSync(() => { + resizeObserverCallback?.(resizeEntries, {} as ResizeObserver); + }); + commitsAfterSynchronousSignal = onRender.mock.calls.length; + }); + + expect(commitsAfterSynchronousSignal).toBe(1); + expect(onRender).toHaveBeenCalledTimes(2); + expect(onRender.mock.calls[0]?.[1]).toBe("mount"); + expect(onRender.mock.calls[1]?.[1]).toBe("update"); + expect(screen.getByTestId("prompt-box").dataset.minHeight).toBe("76"); + }); + it("includes expanding plugin banners in measured stack compensation", () => { setPluginSlotRegistrations("measured-banner", { homepageSections: [], @@ -300,17 +339,25 @@ describe("FollowUpPromptBox", () => { expect(screen.getByText("Expandable plugin banner")).toBeTruthy(); const promptBox = screen.getByTestId("prompt-box"); const initialMinHeight = Number(promptBox.getAttribute("data-min-height")); + const stackElement = screen + .getByText("Expandable plugin banner") + .closest("[data-bb-plugin-root]")?.parentElement; + if (!stackElement) throw new Error("Expected measured composer stack"); + Object.defineProperty(stackElement, "offsetHeight", { + configurable: true, + value: 24, + }); act(() => { resizeObserverCallback?.( - [{ contentRect: { height: 24 } } as ResizeObserverEntry], + [{ contentRect: { height: 999 } } as ResizeObserverEntry], {} as ResizeObserver, ); + resizeObserverCallback?.([], {} as ResizeObserver); }); - expect(Number(promptBox.getAttribute("data-min-height"))).toBeLessThan( - initialMinHeight, - ); + expect(initialMinHeight).toBe(100); + expect(promptBox.getAttribute("data-min-height")).toBe("76"); }); it("renders plugin banners above native stack content", () => { diff --git a/apps/app/src/components/promptbox/FollowUpPromptBox.tsx b/apps/app/src/components/promptbox/FollowUpPromptBox.tsx index b081072c9e..97cad681b0 100644 --- a/apps/app/src/components/promptbox/FollowUpPromptBox.tsx +++ b/apps/app/src/components/promptbox/FollowUpPromptBox.tsx @@ -538,7 +538,16 @@ function FollowUpPromptBoxWithComposer({ ], ); const stackRef = useRef(null); + const lastStackHeightRef = useRef(0); const [stackHeight, setStackHeight] = useState(0); + const measureStackHeight = useCallback(() => { + const element = stackRef.current; + if (!element) return; + const measured = element.offsetHeight; + if (lastStackHeightRef.current === measured) return; + lastStackHeightRef.current = measured; + setStackHeight(measured); + }, []); // Measure the stack synchronously after every render. useLayoutEffect runs // post-DOM-commit and pre-paint, so when a React commit adds the banner // (e.g. workspace status arrives and the git section becomes non-empty), @@ -547,26 +556,17 @@ function FollowUpPromptBoxWithComposer({ // browser paints. Without this, the banner appears at 32px while the // textarea is still 100px for one frame — the timeline visibly shifts up // then back down as the elastic compensation catches up. - useLayoutEffect(() => { - const element = stackRef.current; - if (!element) return; - const measured = element.offsetHeight; - setStackHeight((prev) => (prev === measured ? prev : measured)); - }, [stack]); + useLayoutEffect(measureStackHeight, [measureStackHeight, stack]); // ResizeObserver catches changes that happen outside a React render — // banner sections expanding via CSS animation, window resize affecting // markdown line-wrapping inside the stack, etc. useEffect(() => { const element = stackRef.current; if (!element || typeof ResizeObserver === "undefined") return; - const observer = new ResizeObserver((entries) => { - const entry = entries[0]; - if (!entry) return; - setStackHeight(entry.contentRect.height); - }); + const observer = new ResizeObserver(measureStackHeight); observer.observe(element); return () => observer.disconnect(); - }, []); + }, [measureStackHeight]); // The elastic pre-size keeps the prompt area's total height constant as the // stack (context banner + queued messages) mounts/unmounts so the timeline // doesn't shift. Callers that need the main-thread prompt height should pass