From 94f6135703e36676ae3fb18369a47b9547b2c6dc Mon Sep 17 00:00:00 2001 From: Hugo Burton Date: Mon, 6 Apr 2026 23:35:14 +1000 Subject: [PATCH 1/8] fix: edge case collapse last panel --- lib/global/utils/getImperativePanelMethods.ts | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/lib/global/utils/getImperativePanelMethods.ts b/lib/global/utils/getImperativePanelMethods.ts index edecec587..c32e8892b 100644 --- a/lib/global/utils/getImperativePanelMethods.ts +++ b/lib/global/utils/getImperativePanelMethods.ts @@ -88,6 +88,53 @@ export function getImperativePanelMethods({ const index = group.panels.findIndex((current) => current.id === panelId); const isLastPanel = index === group.panels.length - 1; + const isCollapsing = nextSize < prevSize; + + // Edge case: collapsing the last panel when all previous panels are already + // collapsed. The normal last-panel logic reverses delta/pivot so the panel + // before absorbs freed space, but when every prior panel is collapsed the + // space cascades all the way to the first panel. Instead, keep the last + // panel as the remainder recipient. + if (isLastPanel && isCollapsing && index > 0) { + const allPreviousCollapsed = group.panels + .slice(0, index) + .every((_panel, panelIndex) => { + const pc = derivedPanelConstraints[panelIndex]; + return ( + pc?.collapsible === true && + layoutNumbersEqual(pc.collapsedSize, prevLayout[pc.panelId]) + ); + }); + + if (allPreviousCollapsed) { + // Build layout keeping every prior panel as-is; the last panel gets + // whatever remains so the total stays at 100%. + const occupiedByPrevious = group.panels + .slice(0, index) + .reduce((total, panel) => total + prevLayout[panel.id], 0); + + const fallbackLayout: Record = {}; + for (const key of Object.keys(prevLayout)) { + fallbackLayout[key] = prevLayout[key]; + } + fallbackLayout[panelId] = formatLayoutNumber(100 - occupiedByPrevious); + + const nextLayout = validatePanelGroupLayout({ + layout: fallbackLayout, + panelConstraints: derivedPanelConstraints + }); + if (!layoutsEqual(prevLayout, nextLayout)) { + updateMountedGroup(group, { + defaultLayoutDeferred, + derivedPanelConstraints, + groupSize, + layout: nextLayout, + separatorToPanels + }); + } + return; + } + } const unsafeLayout = adjustLayoutByDelta({ delta: isLastPanel ? prevSize - nextSize : nextSize - prevSize, From 79f250ebc8dc6ea5b64568021dc575b9b0f3df2b Mon Sep 17 00:00:00 2001 From: Hugo Burton Date: Mon, 6 Apr 2026 23:41:14 +1000 Subject: [PATCH 2/8] refactor: rename panel constraints var --- lib/global/utils/getImperativePanelMethods.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/lib/global/utils/getImperativePanelMethods.ts b/lib/global/utils/getImperativePanelMethods.ts index c32e8892b..f5fcb03b4 100644 --- a/lib/global/utils/getImperativePanelMethods.ts +++ b/lib/global/utils/getImperativePanelMethods.ts @@ -99,10 +99,13 @@ export function getImperativePanelMethods({ const allPreviousCollapsed = group.panels .slice(0, index) .every((_panel, panelIndex) => { - const pc = derivedPanelConstraints[panelIndex]; + const panelConstraints = derivedPanelConstraints[panelIndex]; return ( - pc?.collapsible === true && - layoutNumbersEqual(pc.collapsedSize, prevLayout[pc.panelId]) + panelConstraints?.collapsible && + layoutNumbersEqual( + panelConstraints.collapsedSize, + prevLayout[panelConstraints.panelId] + ) ); }); From 80d3e1add0e00862aa29a4d4be455c2cdfc98b35 Mon Sep 17 00:00:00 2001 From: Hugo Burton Date: Mon, 6 Apr 2026 23:42:05 +1000 Subject: [PATCH 3/8] refactor: shorthand object copy --- lib/global/utils/getImperativePanelMethods.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/lib/global/utils/getImperativePanelMethods.ts b/lib/global/utils/getImperativePanelMethods.ts index f5fcb03b4..ae6e1fafa 100644 --- a/lib/global/utils/getImperativePanelMethods.ts +++ b/lib/global/utils/getImperativePanelMethods.ts @@ -116,10 +116,7 @@ export function getImperativePanelMethods({ .slice(0, index) .reduce((total, panel) => total + prevLayout[panel.id], 0); - const fallbackLayout: Record = {}; - for (const key of Object.keys(prevLayout)) { - fallbackLayout[key] = prevLayout[key]; - } + const fallbackLayout = { ...prevLayout }; fallbackLayout[panelId] = formatLayoutNumber(100 - occupiedByPrevious); const nextLayout = validatePanelGroupLayout({ From 6b7412de04130c940416f98b9e2a55ed0063e9c3 Mon Sep 17 00:00:00 2001 From: Hugo Burton Date: Mon, 6 Apr 2026 23:44:54 +1000 Subject: [PATCH 4/8] refactor: cleanup duplicated code paths --- lib/global/utils/getImperativePanelMethods.ts | 76 +++++++------------ 1 file changed, 27 insertions(+), 49 deletions(-) diff --git a/lib/global/utils/getImperativePanelMethods.ts b/lib/global/utils/getImperativePanelMethods.ts index ae6e1fafa..a10343e8e 100644 --- a/lib/global/utils/getImperativePanelMethods.ts +++ b/lib/global/utils/getImperativePanelMethods.ts @@ -88,63 +88,41 @@ export function getImperativePanelMethods({ const index = group.panels.findIndex((current) => current.id === panelId); const isLastPanel = index === group.panels.length - 1; - const isCollapsing = nextSize < prevSize; // Edge case: collapsing the last panel when all previous panels are already // collapsed. The normal last-panel logic reverses delta/pivot so the panel // before absorbs freed space, but when every prior panel is collapsed the // space cascades all the way to the first panel. Instead, keep the last - // panel as the remainder recipient. - if (isLastPanel && isCollapsing && index > 0) { - const allPreviousCollapsed = group.panels + // panel as the remainder recipient by computing the layout directly. + let unsafeLayout; + if ( + isLastPanel && + nextSize < prevSize && + index > 0 && + group.panels.slice(0, index).every((_panel, panelIndex) => { + const pc = derivedPanelConstraints[panelIndex]; + return ( + pc?.collapsible && + layoutNumbersEqual(pc.collapsedSize, prevLayout[pc.panelId]) + ); + }) + ) { + const occupiedByPrevious = group.panels .slice(0, index) - .every((_panel, panelIndex) => { - const panelConstraints = derivedPanelConstraints[panelIndex]; - return ( - panelConstraints?.collapsible && - layoutNumbersEqual( - panelConstraints.collapsedSize, - prevLayout[panelConstraints.panelId] - ) - ); - }); - - if (allPreviousCollapsed) { - // Build layout keeping every prior panel as-is; the last panel gets - // whatever remains so the total stays at 100%. - const occupiedByPrevious = group.panels - .slice(0, index) - .reduce((total, panel) => total + prevLayout[panel.id], 0); - - const fallbackLayout = { ...prevLayout }; - fallbackLayout[panelId] = formatLayoutNumber(100 - occupiedByPrevious); - - const nextLayout = validatePanelGroupLayout({ - layout: fallbackLayout, - panelConstraints: derivedPanelConstraints - }); - if (!layoutsEqual(prevLayout, nextLayout)) { - updateMountedGroup(group, { - defaultLayoutDeferred, - derivedPanelConstraints, - groupSize, - layout: nextLayout, - separatorToPanels - }); - } - return; - } + .reduce((total, panel) => total + prevLayout[panel.id], 0); + unsafeLayout = { ...prevLayout }; + unsafeLayout[panelId] = formatLayoutNumber(100 - occupiedByPrevious); + } else { + unsafeLayout = adjustLayoutByDelta({ + delta: isLastPanel ? prevSize - nextSize : nextSize - prevSize, + initialLayout: prevLayout, + panelConstraints: derivedPanelConstraints, + pivotIndices: isLastPanel ? [index - 1, index] : [index, index + 1], + prevLayout, + trigger: "imperative-api" + }); } - const unsafeLayout = adjustLayoutByDelta({ - delta: isLastPanel ? prevSize - nextSize : nextSize - prevSize, - initialLayout: prevLayout, - panelConstraints: derivedPanelConstraints, - pivotIndices: isLastPanel ? [index - 1, index] : [index, index + 1], - prevLayout, - trigger: "imperative-api" - }); - const nextLayout = validatePanelGroupLayout({ layout: unsafeLayout, panelConstraints: derivedPanelConstraints From c13b757d3cc8db777d21ef2577f5d025488cdfcb Mon Sep 17 00:00:00 2001 From: Hugo Burton Date: Mon, 6 Apr 2026 23:51:28 +1000 Subject: [PATCH 5/8] refactor: functional approach for computeLayout code paths --- lib/global/utils/getImperativePanelMethods.ts | 99 ++++++++++++------- 1 file changed, 64 insertions(+), 35 deletions(-) diff --git a/lib/global/utils/getImperativePanelMethods.ts b/lib/global/utils/getImperativePanelMethods.ts index a10343e8e..ac566dfdd 100644 --- a/lib/global/utils/getImperativePanelMethods.ts +++ b/lib/global/utils/getImperativePanelMethods.ts @@ -1,4 +1,8 @@ -import type { PanelImperativeHandle } from "../../components/panel/types"; +import type { Layout } from "../../components/group/types"; +import type { + PanelConstraints, + PanelImperativeHandle +} from "../../components/panel/types"; import { calculateAvailableGroupSize } from "../dom/calculateAvailableGroupSize"; import { getMountedGroups, updateMountedGroup } from "../mutable-state/groups"; import { sizeStyleToPixels } from "../styles/sizeStyleToPixels"; @@ -71,35 +75,35 @@ export function getImperativePanelMethods({ throw Error(`Layout not found for Panel ${panelId}`); }; - const setPanelSize = (nextSize: number) => { + /** + * Compute the next (unvalidated) layout when resizing a panel imperatively. + * + * Handles the edge case where the last panel is being collapsed but all + * preceding panels are already collapsed — the normal reversed-delta logic + * would cascade the freed space to the first panel. Instead the last panel + * keeps the remainder so it stays the largest. + */ + const computeLayout = ({ + nextSize, + panels, + prevLayout, + derivedPanelConstraints + }: { + nextSize: number; + panels: { id: string }[]; + prevLayout: Layout; + derivedPanelConstraints: PanelConstraints[]; + }): Layout => { const prevSize = getPanelSize(); - if (nextSize === prevSize) { - return; - } - - const { - defaultLayoutDeferred, - derivedPanelConstraints, - group, - groupSize, - layout: prevLayout, - separatorToPanels - } = find(); - const index = group.panels.findIndex((current) => current.id === panelId); - const isLastPanel = index === group.panels.length - 1; + const index = panels.findIndex((current) => current.id === panelId); + const isLastPanel = index === panels.length - 1; - // Edge case: collapsing the last panel when all previous panels are already - // collapsed. The normal last-panel logic reverses delta/pivot so the panel - // before absorbs freed space, but when every prior panel is collapsed the - // space cascades all the way to the first panel. Instead, keep the last - // panel as the remainder recipient by computing the layout directly. - let unsafeLayout; if ( isLastPanel && nextSize < prevSize && index > 0 && - group.panels.slice(0, index).every((_panel, panelIndex) => { + panels.slice(0, index).every((_panel, panelIndex) => { const pc = derivedPanelConstraints[panelIndex]; return ( pc?.collapsible && @@ -107,22 +111,47 @@ export function getImperativePanelMethods({ ); }) ) { - const occupiedByPrevious = group.panels + const occupiedByPrevious = panels .slice(0, index) .reduce((total, panel) => total + prevLayout[panel.id], 0); - unsafeLayout = { ...prevLayout }; - unsafeLayout[panelId] = formatLayoutNumber(100 - occupiedByPrevious); - } else { - unsafeLayout = adjustLayoutByDelta({ - delta: isLastPanel ? prevSize - nextSize : nextSize - prevSize, - initialLayout: prevLayout, - panelConstraints: derivedPanelConstraints, - pivotIndices: isLastPanel ? [index - 1, index] : [index, index + 1], - prevLayout, - trigger: "imperative-api" - }); + return { + ...prevLayout, + [panelId]: formatLayoutNumber(100 - occupiedByPrevious) + }; + } + + return adjustLayoutByDelta({ + delta: isLastPanel ? prevSize - nextSize : nextSize - prevSize, + initialLayout: prevLayout, + panelConstraints: derivedPanelConstraints, + pivotIndices: isLastPanel ? [index - 1, index] : [index, index + 1], + prevLayout, + trigger: "imperative-api" + }); + }; + + const setPanelSize = (nextSize: number) => { + const prevSize = getPanelSize(); + if (nextSize === prevSize) { + return; } + const { + defaultLayoutDeferred, + derivedPanelConstraints, + group, + groupSize, + layout: prevLayout, + separatorToPanels + } = find(); + + const unsafeLayout = computeLayout({ + nextSize, + panels: group.panels, + prevLayout, + derivedPanelConstraints + }); + const nextLayout = validatePanelGroupLayout({ layout: unsafeLayout, panelConstraints: derivedPanelConstraints From b5ee11fe4bc83692a2c0be4b5ea3ddf4599a1ede Mon Sep 17 00:00:00 2001 From: Hugo Burton Date: Mon, 6 Apr 2026 23:54:49 +1000 Subject: [PATCH 6/8] fix: use proper type for panels --- lib/global/utils/getImperativePanelMethods.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/global/utils/getImperativePanelMethods.ts b/lib/global/utils/getImperativePanelMethods.ts index ac566dfdd..a2fe25f9c 100644 --- a/lib/global/utils/getImperativePanelMethods.ts +++ b/lib/global/utils/getImperativePanelMethods.ts @@ -1,7 +1,8 @@ import type { Layout } from "../../components/group/types"; import type { PanelConstraints, - PanelImperativeHandle + PanelImperativeHandle, + RegisteredPanel } from "../../components/panel/types"; import { calculateAvailableGroupSize } from "../dom/calculateAvailableGroupSize"; import { getMountedGroups, updateMountedGroup } from "../mutable-state/groups"; @@ -90,7 +91,7 @@ export function getImperativePanelMethods({ derivedPanelConstraints }: { nextSize: number; - panels: { id: string }[]; + panels: RegisteredPanel[]; prevLayout: Layout; derivedPanelConstraints: PanelConstraints[]; }): Layout => { From cb8015f3f7a3f40e51c0df315c27de4e79a4ae23 Mon Sep 17 00:00:00 2001 From: Hugo Burton Date: Tue, 7 Apr 2026 00:05:10 +1000 Subject: [PATCH 7/8] fix: handle assertion error when only one panel in group --- lib/global/utils/getImperativePanelMethods.ts | 31 ++++++++++--------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/lib/global/utils/getImperativePanelMethods.ts b/lib/global/utils/getImperativePanelMethods.ts index a2fe25f9c..7b9eca04d 100644 --- a/lib/global/utils/getImperativePanelMethods.ts +++ b/lib/global/utils/getImperativePanelMethods.ts @@ -79,10 +79,11 @@ export function getImperativePanelMethods({ /** * Compute the next (unvalidated) layout when resizing a panel imperatively. * - * Handles the edge case where the last panel is being collapsed but all - * preceding panels are already collapsed — the normal reversed-delta logic - * would cascade the freed space to the first panel. Instead the last panel - * keeps the remainder so it stays the largest. + * Handles two edge cases for the last panel: + * 1. Single panel in the group — no sibling exists to form valid pivot indices. + * 2. All preceding panels are already collapsed — the normal reversed-delta + * logic would cascade the freed space to the first panel. Instead the last + * panel keeps the remainder so it stays the largest. */ const computeLayout = ({ nextSize, @@ -98,20 +99,22 @@ export function getImperativePanelMethods({ const prevSize = getPanelSize(); const index = panels.findIndex((current) => current.id === panelId); + const isFirstPanel = index === 0; const isLastPanel = index === panels.length - 1; - if ( + const allPreviousCollapsed = isLastPanel && nextSize < prevSize && - index > 0 && - panels.slice(0, index).every((_panel, panelIndex) => { - const pc = derivedPanelConstraints[panelIndex]; - return ( - pc?.collapsible && - layoutNumbersEqual(pc.collapsedSize, prevLayout[pc.panelId]) - ); - }) - ) { + (isFirstPanel || + panels.slice(0, index).every((_panel, panelIndex) => { + const pc = derivedPanelConstraints[panelIndex]; + return ( + pc?.collapsible && + layoutNumbersEqual(pc.collapsedSize, prevLayout[pc.panelId]) + ); + })); + + if (allPreviousCollapsed) { const occupiedByPrevious = panels .slice(0, index) .reduce((total, panel) => total + prevLayout[panel.id], 0); From ce39dc9cbe787bdfe568923a730a6b368bba240c Mon Sep 17 00:00:00 2001 From: Hugo Burton Date: Sun, 26 Apr 2026 16:24:09 +1000 Subject: [PATCH 8/8] test: edge cases resize one panel in group --- .../utils/getImperativePanelMethods.test.ts | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/lib/global/utils/getImperativePanelMethods.test.ts b/lib/global/utils/getImperativePanelMethods.test.ts index 9efe9eb26..f14837753 100644 --- a/lib/global/utils/getImperativePanelMethods.test.ts +++ b/lib/global/utils/getImperativePanelMethods.test.ts @@ -375,6 +375,42 @@ describe("getImperativePanelMethods", () => { expect(onLayoutChange).toHaveBeenCalledTimes(1); expect(onLayoutChange).toHaveBeenCalledWith([10, 90]); }); + + describe("edge cases", () => { + test("does not throw when resizing the only panel in the group", () => { + const { panelApis } = init([{ defaultSize: 100 }]); + + expect(() => panelApis[0].resize("50%")).not.toThrow(); + expect(onLayoutChange).not.toHaveBeenCalled(); + }); + + test("last panel keeps the remainder when all preceding panels are collapsed and it is resized smaller", () => { + const { panelApis } = init([ + { collapsible: true, defaultSize: 0, minSize: 20 }, + { collapsible: true, defaultSize: 0, minSize: 20 }, + { defaultSize: 100 } + ]); + + panelApis[2].resize("50%"); + + // The last panel should remain at 100% (the remainder) rather than + // cascading the freed space to the first panel. + expect(onLayoutChange).not.toHaveBeenCalled(); + }); + + test("last panel can still be resized normally when preceding panels are not all collapsed", () => { + const { panelApis } = init([ + { defaultSize: 30 }, + { defaultSize: 30 }, + { defaultSize: 40 } + ]); + + panelApis[2].resize("20%"); + + expect(onLayoutChange).toHaveBeenCalledTimes(1); + expect(onLayoutChange).toHaveBeenCalledWith([30, 50, 20]); + }); + }); }); }); });