From 781bfb857d18d2e1fd2589e6c88e873b74b35047 Mon Sep 17 00:00:00 2001 From: ethanlaj Date: Thu, 30 Apr 2026 02:02:16 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=A9=B9=20fix:=20Sync=20ControlCombobox=20?= =?UTF-8?q?popover=20width=20with=20trigger=20after=20layout=20changes=20(?= =?UTF-8?q?#12887)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🩹 fix: Sync ControlCombobox popover width with trigger after layout changes The popover width was measured once on mount via offsetWidth. When the agent builder side panel opens after a page reload with the sidebar collapsed, the trigger button is initially measured during the layout transition (~26px) and never re-measured, leaving the agent select dropdown rendered at the far left with no options fully visible. Use a ResizeObserver to keep buttonWidth in sync with the trigger's actual width whenever it resizes, then disconnect on unmount. * test: cover ControlCombobox isCollapsed, no-ResizeObserver, and zero-width branches Address review feedback: - Use button.offsetWidth as the ResizeObserver fallback instead of entry.contentRect.width to avoid a content-box vs border-box mismatch in pre-2022 browsers that ship ResizeObserver without borderBoxSize. - Add tests for the three previously-untested branches: isCollapsed=true (no observation of the trigger), ResizeObserver unavailable (sync-only measurement), and zero-width entries (state unchanged). * test: lock the button.offsetWidth fallback against revert Add a test that drives the ResizeObserver callback with borderBoxSize absent and divergent contentRect.width vs offsetWidth (251 vs 275). The fix would silently revert to entry.contentRect.width without this test failing, so this pins the chosen fallback semantics. --------- Co-authored-by: Danny Avila --- .../src/components/ControlCombobox.spec.tsx | 199 ++++++++++++++++++ .../client/src/components/ControlCombobox.tsx | 25 ++- 2 files changed, 222 insertions(+), 2 deletions(-) create mode 100644 packages/client/src/components/ControlCombobox.spec.tsx diff --git a/packages/client/src/components/ControlCombobox.spec.tsx b/packages/client/src/components/ControlCombobox.spec.tsx new file mode 100644 index 0000000000..1a659f0cd8 --- /dev/null +++ b/packages/client/src/components/ControlCombobox.spec.tsx @@ -0,0 +1,199 @@ +import { act, render, screen } from '@testing-library/react'; +import ControlCombobox from './ControlCombobox'; + +type CapturedObserver = { + callback: ResizeObserverCallback; + target: Element | null; + disconnect: jest.Mock; +}; + +const observers: CapturedObserver[] = []; + +class CapturingResizeObserver { + callback: ResizeObserverCallback; + target: Element | null = null; + disconnect = jest.fn(); + + constructor(callback: ResizeObserverCallback) { + this.callback = callback; + observers.push(this); + } + + observe(target: Element) { + this.target = target; + } + + unobserve = jest.fn(); +} + +const originalResizeObserver = window.ResizeObserver; + +beforeEach(() => { + observers.length = 0; + (window as unknown as { ResizeObserver: typeof CapturingResizeObserver }).ResizeObserver = + CapturingResizeObserver; +}); + +afterEach(() => { + (window as unknown as { ResizeObserver: typeof ResizeObserver }).ResizeObserver = + originalResizeObserver; +}); + +const items = [ + { label: 'Option A', value: 'a' }, + { label: 'Option B', value: 'b' }, +]; + +const renderCombobox = (initialButtonWidth: number, isCollapsed = false) => { + const offsetWidthSpy = jest + .spyOn(HTMLElement.prototype, 'offsetWidth', 'get') + .mockReturnValue(initialButtonWidth); + + const utils = render( + undefined} + ariaLabel="Test combobox" + isCollapsed={isCollapsed} + showCarat + />, + ); + + return { ...utils, offsetWidthSpy }; +}; + +const getPopoverWidth = () => { + const popover = document.querySelector('.animate-popover') as HTMLElement | null; + return popover?.style.width ?? null; +}; + +const openPopover = () => { + const trigger = screen.getByRole('combobox'); + act(() => { + trigger.click(); + }); +}; + +describe('ControlCombobox popover sizing', () => { + it('uses the button width measured on mount when layout is stable', () => { + renderCombobox(275); + openPopover(); + expect(getPopoverWidth()).toBe('275px'); + }); + + it('updates the popover width when the trigger resizes after mount (regression: agent select dropdown rendering at narrow width)', () => { + const { offsetWidthSpy } = renderCombobox(26); + openPopover(); + expect(getPopoverWidth()).toBe('26px'); + + const observer = observers[0]; + expect(observer).toBeDefined(); + expect(observer.target).not.toBeNull(); + + offsetWidthSpy.mockReturnValue(275); + + act(() => { + observer.callback( + [ + { + target: observer.target as Element, + contentRect: { width: 275 } as DOMRectReadOnly, + borderBoxSize: [{ inlineSize: 275, blockSize: 36 }], + contentBoxSize: [{ inlineSize: 275, blockSize: 36 }], + devicePixelContentBoxSize: [{ inlineSize: 275, blockSize: 36 }], + } as unknown as ResizeObserverEntry, + ], + observer as unknown as ResizeObserver, + ); + }); + + expect(getPopoverWidth()).toBe('275px'); + }); + + it('disconnects the ResizeObserver on unmount', () => { + const { unmount } = renderCombobox(275); + openPopover(); + const observer = observers[0]; + expect(observer).toBeDefined(); + unmount(); + expect(observer.disconnect).toHaveBeenCalledTimes(1); + }); + + it('does not observe the trigger button when isCollapsed is true', () => { + renderCombobox(275, true); + const triggerObservers = observers.filter( + (o) => (o.target as HTMLElement | null)?.tagName === 'BUTTON', + ); + expect(triggerObservers).toHaveLength(0); + }); + + it('falls back to synchronous offsetWidth when ResizeObserver is unavailable', () => { + (window as unknown as { ResizeObserver: typeof ResizeObserver | undefined }).ResizeObserver = + undefined; + + renderCombobox(275); + openPopover(); + + expect(getPopoverWidth()).toBe('275px'); + const triggerObservers = observers.filter( + (o) => (o.target as HTMLElement | null)?.tagName === 'BUTTON', + ); + expect(triggerObservers).toHaveLength(0); + }); + + it('uses button.offsetWidth when borderBoxSize is unavailable', () => { + const { offsetWidthSpy } = renderCombobox(26); + openPopover(); + expect(getPopoverWidth()).toBe('26px'); + + const observer = observers[0]; + expect(observer).toBeDefined(); + + offsetWidthSpy.mockReturnValue(275); + + act(() => { + observer.callback( + [ + { + target: observer.target as Element, + contentRect: { width: 251 } as DOMRectReadOnly, + borderBoxSize: undefined, + contentBoxSize: undefined, + devicePixelContentBoxSize: undefined, + } as unknown as ResizeObserverEntry, + ], + observer as unknown as ResizeObserver, + ); + }); + + expect(getPopoverWidth()).toBe('275px'); + }); + + it('ignores zero-width resize entries', () => { + renderCombobox(275); + openPopover(); + expect(getPopoverWidth()).toBe('275px'); + + const observer = observers[0]; + expect(observer).toBeDefined(); + + act(() => { + observer.callback( + [ + { + target: observer.target as Element, + contentRect: { width: 0 } as DOMRectReadOnly, + borderBoxSize: [{ inlineSize: 0, blockSize: 0 }], + contentBoxSize: [{ inlineSize: 0, blockSize: 0 }], + devicePixelContentBoxSize: [{ inlineSize: 0, blockSize: 0 }], + } as unknown as ResizeObserverEntry, + ], + observer as unknown as ResizeObserver, + ); + }); + + expect(getPopoverWidth()).toBe('275px'); + }); +}); diff --git a/packages/client/src/components/ControlCombobox.tsx b/packages/client/src/components/ControlCombobox.tsx index f53fc0504e..8c6ca3d8bf 100644 --- a/packages/client/src/components/ControlCombobox.tsx +++ b/packages/client/src/components/ControlCombobox.tsx @@ -80,9 +80,30 @@ function ControlCombobox({ }, [searchValue, items]); useEffect(() => { - if (buttonRef.current && !isCollapsed) { - setButtonWidth(buttonRef.current.offsetWidth); + const button = buttonRef.current; + if (!button || isCollapsed) { + return; } + + setButtonWidth(button.offsetWidth); + + if (typeof ResizeObserver === 'undefined') { + return; + } + + const observer = new ResizeObserver((entries) => { + const entry = entries[0]; + if (!entry) { + return; + } + const width = entry.borderBoxSize?.[0]?.inlineSize ?? button.offsetWidth; + if (width > 0) { + setButtonWidth(width); + } + }); + + observer.observe(button); + return () => observer.disconnect(); }, [isCollapsed]); const selectIconClassName = cn(