diff --git a/src/components/SplitPane.test.tsx b/src/components/SplitPane.test.tsx
index 39f1c9b8..5f0958e7 100644
--- a/src/components/SplitPane.test.tsx
+++ b/src/components/SplitPane.test.tsx
@@ -519,3 +519,83 @@ describe('SplitPane divider size accounting', () => {
expect(totalPaneWidth).toBeCloseTo(expectedTotal, 0);
});
});
+
+describe('SplitPane resize stability', () => {
+ beforeEach(() => {
+ clearResizeObservers();
+ });
+
+ it('ignores sub-pixel size changes to prevent resize loops', async () => {
+ const onResize = vi.fn();
+ const { container } = render(
+
+ Pane 1
+ Pane 2
+
+ );
+
+ await act(async () => {
+ await vi.runAllTimersAsync();
+ });
+
+ const panes = container.querySelectorAll('[data-pane="true"]');
+ const initialHeight1 = (panes[0] as HTMLElement).style.height;
+
+ // Simulate multiple resize events with sub-pixel variations
+ // This mimics the feedback loop scenario where content causes tiny size changes
+ await act(async () => {
+ triggerResize(1024, 768.2);
+ await vi.runAllTimersAsync();
+ });
+
+ await act(async () => {
+ triggerResize(1024, 768.4);
+ await vi.runAllTimersAsync();
+ });
+
+ await act(async () => {
+ triggerResize(1024, 768.1);
+ await vi.runAllTimersAsync();
+ });
+
+ // Pane sizes should remain stable - sub-pixel changes shouldn't cause updates
+ const finalHeight1 = (panes[0] as HTMLElement).style.height;
+ expect(finalHeight1).toBe(initialHeight1);
+
+ // onResize should NOT be called for container resize (only user drag)
+ expect(onResize).not.toHaveBeenCalled();
+ });
+
+ it('still responds to significant size changes', async () => {
+ const { container } = render(
+
+ Pane 1
+ Pane 2
+
+ );
+
+ await act(async () => {
+ await vi.runAllTimersAsync();
+ });
+
+ const panes = container.querySelectorAll('[data-pane="true"]');
+
+ // Initial height with 768px container (minus 1px divider = 767px available)
+ const initialHeight1 = parseFloat(
+ (panes[0] as HTMLElement).style.height.replace('px', '')
+ );
+ expect(initialHeight1).toBeCloseTo(383.5, 1); // 50% of 767
+
+ // Simulate significant resize (double the height)
+ await act(async () => {
+ triggerResize(1024, 1536);
+ await vi.runAllTimersAsync();
+ });
+
+ // Should respond to significant change (1536 - 1 = 1535 available)
+ const newHeight1 = parseFloat(
+ (panes[0] as HTMLElement).style.height.replace('px', '')
+ );
+ expect(newHeight1).toBeCloseTo(767.5, 1); // 50% of 1535
+ });
+});
diff --git a/src/components/SplitPane.tsx b/src/components/SplitPane.tsx
index c11dd57a..56bafcb4 100644
--- a/src/components/SplitPane.tsx
+++ b/src/components/SplitPane.tsx
@@ -230,14 +230,21 @@ export function SplitPane(props: SplitPaneProps) {
[paneCount, paneConfigs, calculateInitialSizes, dividerSize]
);
+ // Track the last observed container size to detect meaningful changes
+ const lastObservedSizeRef = useRef(0);
+
// Measure container size with ResizeObserver
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const updateSizeFromRect = (rect: { width: number; height: number }) => {
- const size = direction === 'horizontal' ? rect.width : rect.height;
- if (size > 0) {
+ const rawSize = direction === 'horizontal' ? rect.width : rect.height;
+ // Round to nearest integer to prevent sub-pixel variations from causing
+ // resize feedback loops (fixes #873)
+ const size = Math.round(rawSize);
+ if (size > 0 && size !== lastObservedSizeRef.current) {
+ lastObservedSizeRef.current = size;
setContainerSize(size);
handleContainerSizeChange(size);
}