diff --git a/packages/react-resizable-panels-website/src/routes/examples/ImperativePanelApi.tsx b/packages/react-resizable-panels-website/src/routes/examples/ImperativePanelApi.tsx index f35c2b162..409d3c023 100644 --- a/packages/react-resizable-panels-website/src/routes/examples/ImperativePanelApi.tsx +++ b/packages/react-resizable-panels-website/src/routes/examples/ImperativePanelApi.tsx @@ -221,6 +221,7 @@ function Content({
{ @@ -64,8 +58,7 @@ test.describe("Storage", () => { now: 80, }); - // Wait for localStorage write debounce - await new Promise((resolve) => setTimeout(resolve, 250)); + await waitForLocalStorageWrite(); // Values should be remembered after a page reload await page.reload(); @@ -80,6 +73,22 @@ test.describe("Storage", () => { test("should store layouts separately per panel combination", async ({ page, }) => { + const panelGroupBC = createElement( + PanelGroup, + { autoSaveId: "test-group", direction: "horizontal" }, + createElement(Panel, { minSize: 10, order: 2 }), + createElement(PanelResizeHandle), + createElement(Panel, { minSize: 10, order: 3 }) + ); + + const panelGroupAB = createElement( + PanelGroup, + { autoSaveId: "test-group", direction: "horizontal" }, + createElement(Panel, { minSize: 10, order: 1 }), + createElement(PanelResizeHandle), + createElement(Panel, { minSize: 10, order: 2 }) + ); + await goToUrl(page, panelGroupABC); const resizeHandles = page.locator("[data-panel-resize-handle-id]"); @@ -93,8 +102,7 @@ test.describe("Storage", () => { now: 33, }); - // Wait for localStorage write debounce - await new Promise((resolve) => setTimeout(resolve, 250)); + await waitForLocalStorageWrite(); // Hide the first panel and then resize things await goToUrl(page, panelGroupBC); @@ -104,8 +112,7 @@ test.describe("Storage", () => { now: 10, }); - // Wait for localStorage write debounce - await new Promise((resolve) => setTimeout(resolve, 250)); + await waitForLocalStorageWrite(); // Hide the last panel and then resize things await goToUrl(page, panelGroupAB); @@ -115,8 +122,7 @@ test.describe("Storage", () => { now: 90, }); - // Wait for localStorage write debounce - await new Promise((resolve) => setTimeout(resolve, 250)); + await waitForLocalStorageWrite(); // Reload and verify all of the different layouts are remembered individually await goToUrl(page, panelGroupABC); @@ -137,5 +143,105 @@ test.describe("Storage", () => { now: 90, }); }); + + test("should remember the most recent expanded size for collapsed panels", async ({ + page, + }) => { + const panelGroup = createElement( + PanelGroup, + { autoSaveId: "test-group", direction: "horizontal" }, + createElement(Panel, { + collapsible: true, + id: "left", + minSize: 10, + order: 1, + }), + createElement(PanelResizeHandle), + createElement(Panel, { + collapsible: true, + id: "middle", + minSize: 10, + order: 2, + }), + createElement(PanelResizeHandle), + createElement(Panel, { + collapsible: true, + id: "right", + minSize: 10, + order: 3, + }) + ); + + await goToUrl(page, panelGroup); + + const resizeHandles = page.locator("[data-panel-resize-handle-id]"); + const first = resizeHandles.first(); + const last = resizeHandles.last(); + + await verifyAriaValues(first, { + now: 33, + }); + await verifyAriaValues(last, { + now: 33, + }); + + // Change panel sizes + await first.focus(); + await page.keyboard.press("ArrowLeft"); + await last.focus(); + await page.keyboard.press("ArrowRight"); + + // Verify sizes + await verifyAriaValues(first, { + now: 23, + }); + await verifyAriaValues(last, { + now: 53, + }); + + await waitForLocalStorageWrite(); + + // Collapse panels + await imperativeCollapsePanel(page, "left"); + await imperativeCollapsePanel(page, "right"); + + // Verify sizes + await verifyAriaValues(first, { + now: 0, + }); + await verifyAriaValues(last, { + now: 100, + }); + + await waitForLocalStorageWrite(); + + // Reload page + await page.reload(); + + // Verify collapsed sizes resized + await verifyAriaValues(first, { + now: 0, + }); + await verifyAriaValues(last, { + now: 100, + }); + + // Expand panels + await imperativeExpandPanel(page, "left"); + await imperativeExpandPanel(page, "right"); + + // Verify sizes + await verifyAriaValues(first, { + now: 23, + }); + await verifyAriaValues(last, { + now: 53, + }); + }); }); }); + +// Wait for localStorage write debounce +async function waitForLocalStorageWrite() { + await new Promise((resolve) => setTimeout(resolve, 250)); +} diff --git a/packages/react-resizable-panels-website/tests/utils/panels.ts b/packages/react-resizable-panels-website/tests/utils/panels.ts index f50c4f18e..1d41e1c2d 100644 --- a/packages/react-resizable-panels-website/tests/utils/panels.ts +++ b/packages/react-resizable-panels-website/tests/utils/panels.ts @@ -149,6 +149,22 @@ export async function dragResizeTo( await page.mouse.up(); } +export async function imperativeCollapsePanel(page: Page, panelId: string) { + const panelIdSelect = page.locator("#panelIdSelect"); + await panelIdSelect.selectOption(panelId); + + const button = page.locator("#collapseButton"); + await button.click(); +} + +export async function imperativeExpandPanel(page: Page, panelId: string) { + const panelIdSelect = page.locator("#panelIdSelect"); + await panelIdSelect.selectOption(panelId); + + const button = page.locator("#expandButton"); + await button.click(); +} + export async function imperativeResizePanel( page: Page, panelId: string, @@ -161,8 +177,8 @@ export async function imperativeResizePanel( await sizeInput.focus(); await sizeInput.fill(`${size}%`); - const resizeButton = page.locator("#resizeButton"); - await resizeButton.click(); + const button = page.locator("#resizeButton"); + await button.click(); } export async function imperativeResizePanelGroup( diff --git a/packages/react-resizable-panels/CHANGELOG.md b/packages/react-resizable-panels/CHANGELOG.md index 763405abb..4875311f0 100644 --- a/packages/react-resizable-panels/CHANGELOG.md +++ b/packages/react-resizable-panels/CHANGELOG.md @@ -1,8 +1,12 @@ # Changelog +## Unreleased + +- Remember most recently expanded panel size in local storage (#234) + ## 1.0.2 -- Change local storage key for persisted sizes to avoid restoring pixel-based sizes (see #233) +- Change local storage key for persisted sizes to avoid restoring pixel-based sizes (#233) ## 1.0.1 diff --git a/packages/react-resizable-panels/src/PanelGroup.ts b/packages/react-resizable-panels/src/PanelGroup.ts index b9c03de4b..e78ec4f55 100644 --- a/packages/react-resizable-panels/src/PanelGroup.ts +++ b/packages/react-resizable-panels/src/PanelGroup.ts @@ -20,7 +20,10 @@ import { getResizeHandleElement } from "./utils/dom/getResizeHandleElement"; import { isKeyDown, isMouseEvent, isTouchEvent } from "./utils/events"; import { getResizeEventCursorPosition } from "./utils/getResizeEventCursorPosition"; import { initializeDefaultStorage } from "./utils/initializeDefaultStorage"; -import { loadPanelLayout, savePanelGroupLayout } from "./utils/serialization"; +import { + loadPanelGroupState, + savePanelGroupState, +} from "./utils/serialization"; import { validatePanelConstraints } from "./utils/validatePanelConstraints"; import { validatePanelGroupLayout } from "./utils/validatePanelGroupLayout"; import { @@ -79,7 +82,7 @@ export type PanelGroupProps = Omit, "id"> & }>; const debounceMap: { - [key: string]: typeof savePanelGroupLayout; + [key: string]: typeof savePanelGroupState; } = {}; function PanelGroupWithForwardedRef({ @@ -102,7 +105,6 @@ function PanelGroupWithForwardedRef({ const [dragState, setDragState] = useState(null); const [layout, setLayout] = useState([]); - const [panelDataArray, setPanelDataArray] = useState([]); const panelIdToLastNotifiedSizeMapRef = useRef>({}); const panelSizeBeforeCollapseRef = useRef>(new Map()); @@ -218,16 +220,26 @@ function PanelGroupWithForwardedRef({ // Limit the frequency of localStorage updates. if (debouncedSave == null) { debouncedSave = debounce( - savePanelGroupLayout, + savePanelGroupState, LOCAL_STORAGE_DEBOUNCE_INTERVAL ); debounceMap[autoSaveId] = debouncedSave; } - // Clone panel data array before saving since this array is mutated. - // If we don't clone, we run the risk of saving the wrong panel and layout pair. - debouncedSave(autoSaveId, [...panelDataArray], layout, storage); + // Clone mutable data before passing to the debounced function, + // else we run the risk of saving an incorrect combination of mutable and immutable values to state. + const clonedPanelDataArray = [...panelDataArray]; + const clonedPanelSizesBeforeCollapse = new Map( + panelSizeBeforeCollapseRef.current + ); + debouncedSave( + autoSaveId, + clonedPanelDataArray, + clonedPanelSizesBeforeCollapse, + layout, + storage + ); } }, [autoSaveId, layout, storage]); @@ -500,7 +512,13 @@ function PanelGroupWithForwardedRef({ // default size should be restored from local storage if possible. let unsafeLayout: number[] | null = null; if (autoSaveId) { - unsafeLayout = loadPanelLayout(autoSaveId, panelDataArray, storage); + const state = loadPanelGroupState(autoSaveId, panelDataArray, storage); + if (state) { + panelSizeBeforeCollapseRef.current = new Map( + Object.entries(state.expandToSizes) + ); + unsafeLayout = state.layout; + } } if (unsafeLayout == null) { diff --git a/packages/react-resizable-panels/src/utils/serialization.ts b/packages/react-resizable-panels/src/utils/serialization.ts index 86326ea7f..bbb3b8def 100644 --- a/packages/react-resizable-panels/src/utils/serialization.ts +++ b/packages/react-resizable-panels/src/utils/serialization.ts @@ -1,13 +1,26 @@ import { PanelData } from "../Panel"; import { PanelGroupStorage } from "../PanelGroup"; -type SerializedPanelGroupState = { [panelIds: string]: number[] }; +export type PanelConfigurationState = { + expandToSizes: { + [panelId: string]: number; + }; + layout: number[]; +}; + +export type SerializedPanelGroupState = { + [panelIds: string]: PanelConfigurationState; +}; + +function getPanelGroupKey(autoSaveId: string): string { + return `react-resizable-panels:${autoSaveId}`; +} // Note that Panel ids might be user-provided (stable) or useId generated (non-deterministic) // so they should not be used as part of the serialization key. // Using the min/max size attributes should work well enough as a backup. // Pre-sorting by minSize allows remembering layouts even if panels are re-ordered/dragged. -function getSerializationKey(panels: PanelData[]): string { +function getPanelKey(panels: PanelData[]): string { return panels .map((panel) => { const { constraints, id, idIsFromProps, order } = panel; @@ -28,11 +41,12 @@ function loadSerializedPanelGroupState( storage: PanelGroupStorage ): SerializedPanelGroupState | null { try { - const serialized = storage.getItem(`PanelGroup:layout:${autoSaveId}`); + const panelGroupKey = getPanelGroupKey(autoSaveId); + const serialized = storage.getItem(panelGroupKey); if (serialized) { const parsed = JSON.parse(serialized); if (typeof parsed === "object" && parsed != null) { - return parsed; + return parsed as SerializedPanelGroupState; } } } catch (error) {} @@ -40,32 +54,33 @@ function loadSerializedPanelGroupState( return null; } -export function loadPanelLayout( +export function loadPanelGroupState( autoSaveId: string, panels: PanelData[], storage: PanelGroupStorage -): number[] | null { - const state = loadSerializedPanelGroupState(autoSaveId, storage); - if (state) { - const key = getSerializationKey(panels); - return state[key] ?? null; - } - - return null; +): PanelConfigurationState | null { + const state = loadSerializedPanelGroupState(autoSaveId, storage) ?? {}; + const panelKey = getPanelKey(panels); + return state[panelKey] ?? null; } -export function savePanelGroupLayout( +export function savePanelGroupState( autoSaveId: string, panels: PanelData[], + panelSizesBeforeCollapse: Map, sizes: number[], storage: PanelGroupStorage ): void { - const key = getSerializationKey(panels); - const state = loadSerializedPanelGroupState(autoSaveId, storage) || {}; - state[key] = sizes; + const panelGroupKey = getPanelGroupKey(autoSaveId); + const panelKey = getPanelKey(panels); + const state = loadSerializedPanelGroupState(autoSaveId, storage) ?? {}; + state[panelKey] = { + expandToSizes: Object.fromEntries(panelSizesBeforeCollapse.entries()), + layout: sizes, + }; try { - storage.setItem(`PanelGroup:layout:${autoSaveId}`, JSON.stringify(state)); + storage.setItem(panelGroupKey, JSON.stringify(state)); } catch (error) { console.error(error); }