From 1dc97401d4ec9066c4f6a4caf7ce0320a0e8b6a7 Mon Sep 17 00:00:00 2001 From: Lenz Weber-Tronic Date: Fri, 22 May 2026 18:29:27 +0200 Subject: [PATCH] fix: preserve total width when snapping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the dragged divider snapped, the leading pane was moved to the left or right without an opposite adjustment to the trailing pane — so the total width would change and the layout drifted. Snap the leading pane and subtract the same delta from the trailing pane, so the two together preserve the total. --- src/hooks/useResizer.test.ts | 63 ++++++++++++++++++++++++++++++++++++ src/hooks/useResizer.ts | 16 ++++++--- 2 files changed, 74 insertions(+), 5 deletions(-) diff --git a/src/hooks/useResizer.test.ts b/src/hooks/useResizer.test.ts index 79ad8310..ee904c67 100644 --- a/src/hooks/useResizer.test.ts +++ b/src/hooks/useResizer.test.ts @@ -451,6 +451,69 @@ describe('useResizer', () => { expect(result.current.currentSizes[0]).toBe(400); // Snapped }); + it('preserves the sum invariant when the trailing pane is outside any snap window', () => { + // Regression: previously each pane size was snapped independently via + // `newSizes.map(snapToPoint)`. When the leading pane snapped but the + // trailing pane was nowhere near any snap point (the common case for an + // outer SplitPane snapping the docs panel closed), the trailing pane + // was not adjusted to compensate, leaving sum(newSizes) !== container. + const mockElement = createMockElement(); + const containerWidth = 1500; + const { result } = renderHook(() => + useResizer({ + direction: 'horizontal', + sizes: [0, containerWidth], + minSizes: [0, 0], + maxSizes: [432, Infinity], + snapPoints: [0, 240], + snapTolerance: 120, + }) + ); + + act(() => { + const pointerDown = result.current.handlePointerDown(0); + const event = createPointerEvent('pointerdown', { + clientX: 0, + clientY: 0, + pointerId: 1, + }); + Object.defineProperty(event, 'currentTarget', { value: mockElement }); + pointerDown(event as unknown as React.PointerEvent); + }); + + // Drag right by 50px — the leading pane (size 50) is within snap + // tolerance of 0 and snaps back to 0. The trailing pane (size 1450) + // is far outside any snap window. After snapping, the sum must still + // equal the container width. + act(() => { + document.dispatchEvent( + createPointerEvent('pointermove', { + clientX: 50, + clientY: 0, + pointerId: 1, + }) + ); + vi.runAllTimers(); + }); + + expect(result.current.currentSizes).toEqual([0, containerWidth]); + + // And drag right by 200px — the leading pane snaps to 240. The trailing + // pane should absorb the full delta, not stay at startRight - 200. + act(() => { + document.dispatchEvent( + createPointerEvent('pointermove', { + clientX: 200, + clientY: 0, + pointerId: 1, + }) + ); + vi.runAllTimers(); + }); + + expect(result.current.currentSizes).toEqual([240, containerWidth - 240]); + }); + it('does not snap when outside tolerance', () => { const mockElement = createMockElement(); const { result } = renderHook(() => diff --git a/src/hooks/useResizer.ts b/src/hooks/useResizer.ts index 348fc613..0ff220a4 100644 --- a/src/hooks/useResizer.ts +++ b/src/hooks/useResizer.ts @@ -123,7 +123,7 @@ export function useResizer(options: UseResizerOptions): UseResizerResult { delta = applyStep(delta, step); } - let newSizes = calculateDraggedSizes( + const newSizes = calculateDraggedSizes( startSizes, dividerIndex, delta, @@ -131,11 +131,17 @@ export function useResizer(options: UseResizerOptions): UseResizerResult { maxSizes ); - // Apply snap points + // Apply snap points to the dragged divider. The trailing pane absorbs + // the opposite delta so the total width is preserved. if (snapPoints.length > 0) { - newSizes = newSizes.map((size) => - snapToPoint(size, snapPoints, snapTolerance) - ); + const target = newSizes[dividerIndex] ?? 0; + const snapped = snapToPoint(target, snapPoints, snapTolerance); + if (snapped !== target) { + const delta = snapped - target; + newSizes[dividerIndex] = snapped; + newSizes[dividerIndex + 1] = + (newSizes[dividerIndex + 1] ?? 0) - delta; + } } setCurrentSizes(newSizes);