mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 06:52:47 +00:00
🩹 fix: Sync ControlCombobox popover width with trigger after layout changes (#12887)
* 🩹 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 <danny@librechat.ai>
This commit is contained in:
parent
65990a33e9
commit
781bfb857d
2 changed files with 222 additions and 2 deletions
199
packages/client/src/components/ControlCombobox.spec.tsx
Normal file
199
packages/client/src/components/ControlCombobox.spec.tsx
Normal file
|
|
@ -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(
|
||||
<ControlCombobox
|
||||
selectedValue="a"
|
||||
displayValue="Option A"
|
||||
items={items}
|
||||
setValue={() => 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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue