Skip to content

Commit 3d35850

Browse files
authored
fix: prevent resize observer feedback loop in vertical mode (#875)
When using vertical direction with certain CSS layouts, the ResizeObserver could fire repeatedly with sub-pixel size variations, causing an infinite re-render loop. Changes: - Round container size to nearest integer before processing - Track last observed size via ref to skip redundant updates - Add tests verifying stability with sub-pixel variations - Confirm significant size changes still trigger proper updates Fixes #873
1 parent e4db341 commit 3d35850

File tree

2 files changed

+89
-2
lines changed

2 files changed

+89
-2
lines changed

src/components/SplitPane.test.tsx

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -519,3 +519,83 @@ describe('SplitPane divider size accounting', () => {
519519
expect(totalPaneWidth).toBeCloseTo(expectedTotal, 0);
520520
});
521521
});
522+
523+
describe('SplitPane resize stability', () => {
524+
beforeEach(() => {
525+
clearResizeObservers();
526+
});
527+
528+
it('ignores sub-pixel size changes to prevent resize loops', async () => {
529+
const onResize = vi.fn();
530+
const { container } = render(
531+
<SplitPane direction="vertical" onResize={onResize}>
532+
<Pane defaultSize="50%">Pane 1</Pane>
533+
<Pane defaultSize="50%">Pane 2</Pane>
534+
</SplitPane>
535+
);
536+
537+
await act(async () => {
538+
await vi.runAllTimersAsync();
539+
});
540+
541+
const panes = container.querySelectorAll('[data-pane="true"]');
542+
const initialHeight1 = (panes[0] as HTMLElement).style.height;
543+
544+
// Simulate multiple resize events with sub-pixel variations
545+
// This mimics the feedback loop scenario where content causes tiny size changes
546+
await act(async () => {
547+
triggerResize(1024, 768.2);
548+
await vi.runAllTimersAsync();
549+
});
550+
551+
await act(async () => {
552+
triggerResize(1024, 768.4);
553+
await vi.runAllTimersAsync();
554+
});
555+
556+
await act(async () => {
557+
triggerResize(1024, 768.1);
558+
await vi.runAllTimersAsync();
559+
});
560+
561+
// Pane sizes should remain stable - sub-pixel changes shouldn't cause updates
562+
const finalHeight1 = (panes[0] as HTMLElement).style.height;
563+
expect(finalHeight1).toBe(initialHeight1);
564+
565+
// onResize should NOT be called for container resize (only user drag)
566+
expect(onResize).not.toHaveBeenCalled();
567+
});
568+
569+
it('still responds to significant size changes', async () => {
570+
const { container } = render(
571+
<SplitPane direction="vertical">
572+
<Pane defaultSize="50%">Pane 1</Pane>
573+
<Pane defaultSize="50%">Pane 2</Pane>
574+
</SplitPane>
575+
);
576+
577+
await act(async () => {
578+
await vi.runAllTimersAsync();
579+
});
580+
581+
const panes = container.querySelectorAll('[data-pane="true"]');
582+
583+
// Initial height with 768px container (minus 1px divider = 767px available)
584+
const initialHeight1 = parseFloat(
585+
(panes[0] as HTMLElement).style.height.replace('px', '')
586+
);
587+
expect(initialHeight1).toBeCloseTo(383.5, 1); // 50% of 767
588+
589+
// Simulate significant resize (double the height)
590+
await act(async () => {
591+
triggerResize(1024, 1536);
592+
await vi.runAllTimersAsync();
593+
});
594+
595+
// Should respond to significant change (1536 - 1 = 1535 available)
596+
const newHeight1 = parseFloat(
597+
(panes[0] as HTMLElement).style.height.replace('px', '')
598+
);
599+
expect(newHeight1).toBeCloseTo(767.5, 1); // 50% of 1535
600+
});
601+
});

src/components/SplitPane.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -230,14 +230,21 @@ export function SplitPane(props: SplitPaneProps) {
230230
[paneCount, paneConfigs, calculateInitialSizes, dividerSize]
231231
);
232232

233+
// Track the last observed container size to detect meaningful changes
234+
const lastObservedSizeRef = useRef(0);
235+
233236
// Measure container size with ResizeObserver
234237
useEffect(() => {
235238
const container = containerRef.current;
236239
if (!container) return;
237240

238241
const updateSizeFromRect = (rect: { width: number; height: number }) => {
239-
const size = direction === 'horizontal' ? rect.width : rect.height;
240-
if (size > 0) {
242+
const rawSize = direction === 'horizontal' ? rect.width : rect.height;
243+
// Round to nearest integer to prevent sub-pixel variations from causing
244+
// resize feedback loops (fixes #873)
245+
const size = Math.round(rawSize);
246+
if (size > 0 && size !== lastObservedSizeRef.current) {
247+
lastObservedSizeRef.current = size;
241248
setContainerSize(size);
242249
handleContainerSizeChange(size);
243250
}

0 commit comments

Comments
 (0)