Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Changelog

## 4.8.0

- [699](https://github.com/bvaughn/react-resizable-panels/pull/699): `useDefaultLayout` hook automatically migrates legacy layouts to version 4 format

## 4.7.6

- [698](https://github.com/bvaughn/react-resizable-panels/pull/698): Replace `Panel` `aria-disabled` attribute with `data-disabled`
Expand Down
70 changes: 70 additions & 0 deletions lib/components/group/readLegacyLayout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import type { Layout, LayoutStorage } from "./types";
import { getStorageKey } from "./auto-save/getStorageKey";

export type LegacyLayout = {
[key: string]: {
expandToSizes: unknown;
layout: number[];
};
};

/**
* Reads a legacy layout object from `localStorage` and converts it to a modern `Layout` object.
* For more information see github.com/bvaughn/react-resizable-panels/issues/605
*/
export function readLegacyLayout({
id,
panelIds,
storage
}: {
id: string;
panelIds?: string[] | undefined;
storage: LayoutStorage;
}): Layout | undefined {
const readStorageKey = getStorageKey(id, []);

const maybeLegacyString = storage.getItem(readStorageKey);
if (!maybeLegacyString) {
return;
}

try {
// Legacy format stored multiple layouts in a single storage record, each keyed by panel ids
const maybeLegacyLayout = JSON.parse(maybeLegacyString);

if (panelIds) {
// If panel ids were explicitly provided, search for a matching layout
const key = panelIds.join(",");
const entry = maybeLegacyLayout[key];
if (
entry &&
Array.isArray(entry.layout) &&
panelIds.length === entry.layout.length
) {
const layout: Layout = {};
for (let index = 0; index < panelIds.length; index++) {
layout[panelIds[index]] = entry.layout[index];
}
return layout;
}
} else {
// If no panel ids were provided, bailout unless the legacy object only contained a single layout
const keys = Object.keys(maybeLegacyLayout);
if (keys.length === 1) {
const entry = maybeLegacyLayout[keys[0]];
if (entry && Array.isArray(entry.layout)) {
const ids = keys[0].split(",");
if (ids.length === entry.layout.length) {
const layout: Layout = {};
for (let index = 0; index < ids.length; index++) {
layout[ids[index]] = entry.layout[index];
}
return layout;
}
}
}
}
} catch {
// No-op
}
}
254 changes: 253 additions & 1 deletion lib/components/group/useDefaultLayout.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,14 @@ import { createRef } from "react";
import { describe, expect, test, vi } from "vitest";
import { setDefaultElementBounds } from "../../utils/test/mockBoundingClientRect";
import { Panel } from "../panel/Panel";
import { getStorageKey } from "./auto-save/getStorageKey";
import { Group } from "./Group";
import { type GroupImperativeHandle, type LayoutStorage } from "./types";
import type { LegacyLayout } from "./readLegacyLayout";
import {
type GroupImperativeHandle,
type Layout,
type LayoutStorage
} from "./types";
import { useDefaultLayout } from "./useDefaultLayout";

describe("useDefaultLayout", () => {
Expand Down Expand Up @@ -678,4 +684,250 @@ describe("useDefaultLayout", () => {

expect(storage.setItem).toHaveBeenCalledTimes(1);
});

describe("legacy layout fallback", () => {
function mockLayoutStorage({
groupId,
legacyLayout = {
"left,right": {
expandToSizes: {},
layout: [50, 50]
},
"center,left,right": {
expandToSizes: {},
layout: [33, 33, 34]
}
},
matchV3,
matchV4,
modernLayout = {
left: 25,
center: 35,
right: 40
},
panelIds
}: {
groupId: string;
legacyLayout?: LegacyLayout;
matchV3?: boolean;
matchV4?: boolean;
modernLayout?: Layout;
panelIds?: string[] | undefined;
}) {
const keyV3 = getStorageKey(groupId, []);
const keyV4 = getStorageKey(groupId, panelIds ?? []);

const storage: LayoutStorage = {
getItem: vi.fn((key) => {
if (matchV4 && key === keyV4) {
return JSON.stringify(modernLayout);
} else if (matchV3 && key === keyV3) {
return JSON.stringify(legacyLayout);
} else {
return null;
}
}),
setItem: vi.fn()
};

return {
keyV3,
keyV4,
storage
};
}

describe("with panel ids prop", () => {
test("skips mismatched legacy layout", () => {
const { keyV3, keyV4, storage } = mockLayoutStorage({
groupId: "test-group-id",
matchV3: true,
panelIds: ["left", "center"]
});

const { result } = renderHook(() =>
useDefaultLayout({
id: "test-group-id",
panelIds: ["left", "center"],
storage
})
);

expect(result.current.defaultLayout).toMatchInlineSnapshot(`undefined`);
expect(storage.getItem).toHaveBeenCalledWith(keyV4);
expect(storage.getItem).toHaveBeenCalledWith(keyV3);
expect(storage.setItem).not.toHaveBeenCalled();
});

test("returns matching legacy layout", () => {
const { keyV3, keyV4, storage } = mockLayoutStorage({
groupId: "test-group-id",
matchV3: true,
panelIds: ["left", "right"]
});

const { result } = renderHook(() =>
useDefaultLayout({
id: "test-group-id",
panelIds: ["left", "right"],
storage
})
);

expect(result.current.defaultLayout).toMatchInlineSnapshot(`
{
"left": 50,
"right": 50,
}
`);
expect(storage.getItem).toHaveBeenCalledWith(keyV4);
expect(storage.getItem).toHaveBeenCalledWith(keyV3);
expect(storage.setItem).not.toHaveBeenCalled();
});

test("prefers matching modern layouts if both are present", () => {
const { keyV3, keyV4, storage } = mockLayoutStorage({
groupId: "test-group-id",
matchV3: true,
matchV4: true,
panelIds: ["left", "center", "right"]
});

const { result } = renderHook(() =>
useDefaultLayout({
id: "test-group-id",
panelIds: ["left", "center", "right"],
storage
})
);

expect(result.current.defaultLayout).toMatchInlineSnapshot(`
{
"center": 35,
"left": 25,
"right": 40,
}
`);
expect(storage.getItem).toHaveBeenCalledWith(keyV4);
expect(storage.getItem).not.toHaveBeenCalledWith(keyV3);
expect(storage.setItem).not.toHaveBeenCalled();
});
});

describe("without panel ids prop", () => {
test("skips mismatched legacy layout", () => {
const { keyV3, keyV4, storage } = mockLayoutStorage({
groupId: "test-group-id",
matchV3: true
});

const { result } = renderHook(() =>
useDefaultLayout({
id: "test-group-id",
storage
})
);

expect(result.current.defaultLayout).toMatchInlineSnapshot(`undefined`);
expect(storage.getItem).toHaveBeenCalledWith(keyV4);
expect(storage.getItem).toHaveBeenCalledWith(keyV3);
expect(storage.setItem).not.toHaveBeenCalled();
});

test("returns matching legacy layout", () => {
const { keyV3, keyV4, storage } = mockLayoutStorage({
groupId: "test-group-id",
legacyLayout: {
"center,right": {
expandToSizes: {},
layout: [50, 50]
}
},
matchV3: true
});

const { result } = renderHook(() =>
useDefaultLayout({
id: "test-group-id",
storage
})
);

expect(result.current.defaultLayout).toMatchInlineSnapshot(`
{
"center": 50,
"right": 50,
}
`);
expect(storage.getItem).toHaveBeenCalledWith(keyV4);
expect(storage.getItem).toHaveBeenCalledWith(keyV3);
expect(storage.setItem).not.toHaveBeenCalled();
});

test("prefers matching modern layouts if both are present", () => {
const { keyV4, storage } = mockLayoutStorage({
groupId: "test-group-id",
matchV3: true,
matchV4: true
});

const { result } = renderHook(() =>
useDefaultLayout({
id: "test-group-id",
storage
})
);

expect(result.current.defaultLayout).toMatchInlineSnapshot(`
{
"center": 35,
"left": 25,
"right": 40,
}
`);
expect(storage.getItem).toHaveBeenCalledWith(keyV4);
expect(storage.setItem).not.toHaveBeenCalled();
});
});

test("updates persisted modern layout", () => {
const { keyV3, keyV4, storage } = mockLayoutStorage({
groupId: "test-group-id",
matchV3: true,
panelIds: ["left", "right"]
});

const { result } = renderHook(() =>
useDefaultLayout({
id: "test-group-id",
panelIds: ["left", "right"],
storage
})
);

expect(result.current.defaultLayout).toMatchInlineSnapshot(`
{
"left": 50,
"right": 50,
}
`);
expect(storage.getItem).toHaveBeenCalledWith(keyV4);
expect(storage.getItem).toHaveBeenCalledWith(keyV3);
expect(storage.setItem).not.toHaveBeenCalled();

result.current.onLayoutChanged({
left: 35,
right: 65
});

expect(storage.setItem).toHaveBeenCalledTimes(1);
expect(storage.setItem).toHaveBeenCalledWith(
keyV4,
JSON.stringify({
left: 35,
right: 65
})
);
});
});
});
Loading
Loading