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); }