Skip to content

Commit a840671

Browse files
authored
fix: maintain controlled pane sizes on container resize (#868) (#869)
When using controlled mode with pixel sizes, panes were incorrectly being scaled proportionally when the container/window resized. This fix checks for controlled size props in handleContainerSizeChange and uses calculateInitialSizes instead of distributeSizes to maintain the fixed sizes from props. - Add hasControlledSizes check in handleContainerSizeChange - Use calculateInitialSizes for controlled panes on resize - Keep proportional distribution for uncontrolled panes - Add tests for container resize behavior - Extend test setup to support triggering resize events Fixes #868
1 parent 3f5d90c commit a840671

File tree

3 files changed

+185
-6
lines changed

3 files changed

+185
-6
lines changed

src/components/SplitPane.test.tsx

Lines changed: 120 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,18 @@
1-
import { describe, it, expect, vi } from 'vitest';
1+
import { describe, it, expect, vi, beforeEach } from 'vitest';
22
import { render, screen, act } from '@testing-library/react';
33
import { SplitPane } from './SplitPane';
44
import { Pane } from './Pane';
5+
import { triggerResize, clearResizeObservers } from '../test/setup';
56

67
// These match the mock in test/setup.ts
78
const CONTAINER_WIDTH = 1024;
89
const CONTAINER_HEIGHT = 768;
910

1011
describe('SplitPane', () => {
12+
beforeEach(() => {
13+
clearResizeObservers();
14+
});
15+
1116
it('renders children panes', async () => {
1217
render(
1318
<SplitPane>
@@ -279,3 +284,117 @@ describe('SplitPane initial size calculation', () => {
279284
expect(panes[1]).toHaveStyle({ width: '400px' });
280285
});
281286
});
287+
288+
describe('SplitPane container resize behavior', () => {
289+
beforeEach(() => {
290+
clearResizeObservers();
291+
});
292+
293+
it('maintains controlled pixel sizes when container resizes', async () => {
294+
const onResize = vi.fn();
295+
const sizesAfterResize: number[][] = [];
296+
297+
// Custom SplitPane wrapper to capture intermediate sizes
298+
const CapturingSplitPane = () => {
299+
return (
300+
<SplitPane
301+
direction="horizontal"
302+
onResize={(sizes) => {
303+
sizesAfterResize.push([...sizes]);
304+
onResize(sizes);
305+
}}
306+
>
307+
<Pane size={200}>Pane 1</Pane>
308+
<Pane size={400}>Pane 2</Pane>
309+
</SplitPane>
310+
);
311+
};
312+
313+
const { container } = render(<CapturingSplitPane />);
314+
315+
await act(async () => {
316+
await vi.runAllTimersAsync();
317+
});
318+
319+
const panes = container.querySelectorAll('[data-pane="true"]');
320+
expect(panes).toHaveLength(2);
321+
322+
// Initial sizes should be respected
323+
expect(panes[0]).toHaveStyle({ width: '200px' });
324+
expect(panes[1]).toHaveStyle({ width: '400px' });
325+
326+
// Simulate container resize (window resize)
327+
act(() => {
328+
triggerResize(1200, 768);
329+
});
330+
331+
await act(async () => {
332+
await vi.runAllTimersAsync();
333+
});
334+
335+
// After all updates, sizes should be maintained
336+
expect(panes[0]).toHaveStyle({ width: '200px' });
337+
expect(panes[1]).toHaveStyle({ width: '400px' });
338+
339+
// onResize should NOT be called for container resize (only for user drag)
340+
// If onResize was called, it means distributeSizes was incorrectly applied
341+
expect(onResize).not.toHaveBeenCalled();
342+
});
343+
344+
it('distributes uncontrolled panes proportionally on container resize', async () => {
345+
const { container } = render(
346+
<SplitPane direction="horizontal">
347+
<Pane defaultSize={200}>Pane 1</Pane>
348+
<Pane>Pane 2</Pane>
349+
</SplitPane>
350+
);
351+
352+
await act(async () => {
353+
await vi.runAllTimersAsync();
354+
});
355+
356+
const panes = container.querySelectorAll('[data-pane="true"]');
357+
358+
// Initial: 200px + 824px = 1024px
359+
expect(panes[0]).toHaveStyle({ width: '200px' });
360+
expect(panes[1]).toHaveStyle({ width: `${CONTAINER_WIDTH - 200}px` });
361+
362+
// Simulate container resize to 2048px (double)
363+
await act(async () => {
364+
triggerResize(2048, 768);
365+
await vi.runAllTimersAsync();
366+
});
367+
368+
// Uncontrolled panes should scale proportionally
369+
expect(panes[0]).toHaveStyle({ width: '400px' });
370+
expect(panes[1]).toHaveStyle({ width: `${2048 - 400}px` });
371+
});
372+
373+
it('maintains controlled sizes in vertical direction on container resize', async () => {
374+
const { container } = render(
375+
<SplitPane direction="vertical">
376+
<Pane size={200}>Pane 1</Pane>
377+
<Pane size={300}>Pane 2</Pane>
378+
</SplitPane>
379+
);
380+
381+
await act(async () => {
382+
await vi.runAllTimersAsync();
383+
});
384+
385+
const panes = container.querySelectorAll('[data-pane="true"]');
386+
387+
expect(panes[0]).toHaveStyle({ height: '200px' });
388+
expect(panes[1]).toHaveStyle({ height: '300px' });
389+
390+
// Simulate container resize
391+
await act(async () => {
392+
triggerResize(1024, 1000);
393+
await vi.runAllTimersAsync();
394+
});
395+
396+
// Controlled sizes should be maintained
397+
expect(panes[0]).toHaveStyle({ height: '200px' });
398+
expect(panes[1]).toHaveStyle({ height: '300px' });
399+
});
400+
});

src/components/SplitPane.tsx

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -176,15 +176,21 @@ export function SplitPane(props: SplitPaneProps) {
176176
});
177177
}, [containerSize, paneConfigs, calculateInitialSizes]);
178178

179-
// Handle container size changes - update sizes proportionally
180-
// Using a ref comparison to avoid effect dependency issues
179+
// Handle container size changes
180+
// For controlled panes: maintain fixed pixel sizes from props
181+
// For uncontrolled panes: distribute proportionally
181182
const handleContainerSizeChange = useCallback(
182183
(newContainerSize: number) => {
183184
const prevSize = prevContainerSizeRef.current;
184185
prevContainerSizeRef.current = newContainerSize;
185186

186187
if (newContainerSize === 0) return;
187188

189+
// Check if any pane has a controlled size prop
190+
const hasControlledSizes = paneConfigs.some(
191+
(config) => config.size !== undefined
192+
);
193+
188194
setPaneSizes((currentSizes) => {
189195
// If sizes are uninitialized or pane count changed
190196
if (
@@ -194,8 +200,13 @@ export function SplitPane(props: SplitPaneProps) {
194200
return calculateInitialSizes(newContainerSize);
195201
}
196202

197-
// If container size changed, distribute proportionally
203+
// If container size changed
198204
if (prevSize > 0 && prevSize !== newContainerSize) {
205+
// For controlled panes, recalculate from props to maintain fixed sizes
206+
if (hasControlledSizes) {
207+
return calculateInitialSizes(newContainerSize);
208+
}
209+
// For uncontrolled panes, distribute proportionally
199210
return distributeSizes(currentSizes, newContainerSize);
200211
}
201212

@@ -207,7 +218,7 @@ export function SplitPane(props: SplitPaneProps) {
207218
return currentSizes;
208219
});
209220
},
210-
[paneCount, calculateInitialSizes]
221+
[paneCount, paneConfigs, calculateInitialSizes]
211222
);
212223

213224
// Measure container size with ResizeObserver

src/test/setup.ts

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,44 @@ Element.prototype.getBoundingClientRect = vi.fn(() => ({
1717
toJSON: () => ({}),
1818
}));
1919

20+
// Store observers to allow triggering resize events in tests
21+
type ObserverEntry = {
22+
callback: ResizeObserverCallback;
23+
observer: ResizeObserver;
24+
target: Element;
25+
};
26+
const resizeObservers: ObserverEntry[] = [];
27+
28+
// Helper to trigger resize on observed elements
29+
export function triggerResize(width: number, height: number) {
30+
resizeObservers.forEach(({ callback, observer, target }) => {
31+
const mockEntry = {
32+
target,
33+
contentRect: {
34+
width,
35+
height,
36+
top: 0,
37+
left: 0,
38+
bottom: height,
39+
right: width,
40+
x: 0,
41+
y: 0,
42+
toJSON: () => ({}),
43+
},
44+
borderBoxSize: [],
45+
contentBoxSize: [],
46+
devicePixelContentBoxSize: [],
47+
} as unknown as ResizeObserverEntry;
48+
49+
callback([mockEntry], observer);
50+
});
51+
}
52+
53+
// Clear observers between tests
54+
export function clearResizeObservers() {
55+
resizeObservers.length = 0;
56+
}
57+
2058
// Mock ResizeObserver with callback support
2159
(
2260
globalThis as unknown as {
@@ -30,6 +68,13 @@ Element.prototype.getBoundingClientRect = vi.fn(() => ({
3068
}
3169

3270
observe(target: Element) {
71+
// Store for later triggering
72+
resizeObservers.push({
73+
callback: this.callback,
74+
observer: this,
75+
target,
76+
});
77+
3378
// Call callback synchronously for predictable testing
3479
const mockEntry = {
3580
target,
@@ -58,6 +103,10 @@ Element.prototype.getBoundingClientRect = vi.fn(() => ({
58103
}
59104

60105
disconnect() {
61-
// Mock implementation
106+
// Remove from tracked observers
107+
const index = resizeObservers.findIndex((o) => o.observer === this);
108+
if (index !== -1) {
109+
resizeObservers.splice(index, 1);
110+
}
62111
}
63112
};

0 commit comments

Comments
 (0)