Skip to content

Commit cb5cb7a

Browse files
committed
Import version 3 saved layouts in version 4
1 parent bf4f8c6 commit cb5cb7a

4 files changed

Lines changed: 353 additions & 8 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
# Changelog
22

3+
## 4.8.0
4+
5+
- [699](https://github.com/bvaughn/react-resizable-panels/pull/699): `useDefaultLayout` hook automatically migrates legacy layouts to version 4 format
6+
37
## 4.7.6
48

59
- [698](https://github.com/bvaughn/react-resizable-panels/pull/698): Replace `Panel` `aria-disabled` attribute with `data-disabled`
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import type { Layout, LayoutStorage } from "./types";
2+
import { getStorageKey } from "./auto-save/getStorageKey";
3+
4+
export type LegacyLayout = {
5+
[key: string]: {
6+
expandToSizes: unknown;
7+
layout: number[];
8+
};
9+
};
10+
11+
/**
12+
* Reads a legacy layout object from `localStorage` and converts it to a modern `Layout` object.
13+
* For more information see github.com/bvaughn/react-resizable-panels/issues/605
14+
*/
15+
export function readLegacyLayout({
16+
id,
17+
panelIds,
18+
storage
19+
}: {
20+
id: string;
21+
panelIds?: string[] | undefined;
22+
storage: LayoutStorage;
23+
}): Layout | undefined {
24+
const readStorageKey = getStorageKey(id, []);
25+
26+
const maybeLegacyString = storage.getItem(readStorageKey);
27+
if (!maybeLegacyString) {
28+
return;
29+
}
30+
31+
try {
32+
// Legacy format stored multiple layouts in a single storage record, each keyed by panel ids
33+
const maybeLegacyLayout = JSON.parse(maybeLegacyString);
34+
35+
if (panelIds) {
36+
// If panel ids were explicitly provided, search for a matching layout
37+
const key = panelIds.join(",");
38+
const entry = maybeLegacyLayout[key];
39+
if (
40+
entry &&
41+
Array.isArray(entry.layout) &&
42+
panelIds.length === entry.layout.length
43+
) {
44+
const layout: Layout = {};
45+
for (let index = 0; index < panelIds.length; index++) {
46+
layout[panelIds[index]] = entry.layout[index];
47+
}
48+
return layout;
49+
}
50+
} else {
51+
// If no panel ids were provided, bailout unless the legacy object only contained a single layout
52+
const keys = Object.keys(maybeLegacyLayout);
53+
if (keys.length === 1) {
54+
const entry = maybeLegacyLayout[keys[0]];
55+
if (entry && Array.isArray(entry.layout)) {
56+
const ids = keys[0].split(",");
57+
if (ids.length === entry.layout.length) {
58+
const layout: Layout = {};
59+
for (let index = 0; index < ids.length; index++) {
60+
layout[ids[index]] = entry.layout[index];
61+
}
62+
return layout;
63+
}
64+
}
65+
}
66+
}
67+
} catch {
68+
// No-op
69+
}
70+
}

lib/components/group/useDefaultLayout.test.tsx

Lines changed: 253 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,14 @@ import { createRef } from "react";
33
import { describe, expect, test, vi } from "vitest";
44
import { setDefaultElementBounds } from "../../utils/test/mockBoundingClientRect";
55
import { Panel } from "../panel/Panel";
6+
import { getStorageKey } from "./auto-save/getStorageKey";
67
import { Group } from "./Group";
7-
import { type GroupImperativeHandle, type LayoutStorage } from "./types";
8+
import type { LegacyLayout } from "./readLegacyLayout";
9+
import {
10+
type GroupImperativeHandle,
11+
type Layout,
12+
type LayoutStorage
13+
} from "./types";
814
import { useDefaultLayout } from "./useDefaultLayout";
915

1016
describe("useDefaultLayout", () => {
@@ -678,4 +684,250 @@ describe("useDefaultLayout", () => {
678684

679685
expect(storage.setItem).toHaveBeenCalledTimes(1);
680686
});
687+
688+
describe("legacy layout fallback", () => {
689+
function mockLayoutStorage({
690+
groupId,
691+
legacyLayout = {
692+
"left,right": {
693+
expandToSizes: {},
694+
layout: [50, 50]
695+
},
696+
"center,left,right": {
697+
expandToSizes: {},
698+
layout: [33, 33, 34]
699+
}
700+
},
701+
matchV3,
702+
matchV4,
703+
modernLayout = {
704+
left: 25,
705+
center: 35,
706+
right: 40
707+
},
708+
panelIds
709+
}: {
710+
groupId: string;
711+
legacyLayout?: LegacyLayout;
712+
matchV3?: boolean;
713+
matchV4?: boolean;
714+
modernLayout?: Layout;
715+
panelIds?: string[] | undefined;
716+
}) {
717+
const keyV3 = getStorageKey(groupId, []);
718+
const keyV4 = getStorageKey(groupId, panelIds ?? []);
719+
720+
const storage: LayoutStorage = {
721+
getItem: vi.fn((key) => {
722+
if (matchV4 && key === keyV4) {
723+
return JSON.stringify(modernLayout);
724+
} else if (matchV3 && key === keyV3) {
725+
return JSON.stringify(legacyLayout);
726+
} else {
727+
return null;
728+
}
729+
}),
730+
setItem: vi.fn()
731+
};
732+
733+
return {
734+
keyV3,
735+
keyV4,
736+
storage
737+
};
738+
}
739+
740+
describe("with panel ids prop", () => {
741+
test("skips mismatched legacy layout", () => {
742+
const { keyV3, keyV4, storage } = mockLayoutStorage({
743+
groupId: "test-group-id",
744+
matchV3: true,
745+
panelIds: ["left", "center"]
746+
});
747+
748+
const { result } = renderHook(() =>
749+
useDefaultLayout({
750+
id: "test-group-id",
751+
panelIds: ["left", "center"],
752+
storage
753+
})
754+
);
755+
756+
expect(result.current.defaultLayout).toMatchInlineSnapshot(`undefined`);
757+
expect(storage.getItem).toHaveBeenCalledWith(keyV4);
758+
expect(storage.getItem).toHaveBeenCalledWith(keyV3);
759+
expect(storage.setItem).not.toHaveBeenCalled();
760+
});
761+
762+
test("returns matching legacy layout", () => {
763+
const { keyV3, keyV4, storage } = mockLayoutStorage({
764+
groupId: "test-group-id",
765+
matchV3: true,
766+
panelIds: ["left", "right"]
767+
});
768+
769+
const { result } = renderHook(() =>
770+
useDefaultLayout({
771+
id: "test-group-id",
772+
panelIds: ["left", "right"],
773+
storage
774+
})
775+
);
776+
777+
expect(result.current.defaultLayout).toMatchInlineSnapshot(`
778+
{
779+
"left": 50,
780+
"right": 50,
781+
}
782+
`);
783+
expect(storage.getItem).toHaveBeenCalledWith(keyV4);
784+
expect(storage.getItem).toHaveBeenCalledWith(keyV3);
785+
expect(storage.setItem).not.toHaveBeenCalled();
786+
});
787+
788+
test("prefers matching modern layouts if both are present", () => {
789+
const { keyV3, keyV4, storage } = mockLayoutStorage({
790+
groupId: "test-group-id",
791+
matchV3: true,
792+
matchV4: true,
793+
panelIds: ["left", "center", "right"]
794+
});
795+
796+
const { result } = renderHook(() =>
797+
useDefaultLayout({
798+
id: "test-group-id",
799+
panelIds: ["left", "center", "right"],
800+
storage
801+
})
802+
);
803+
804+
expect(result.current.defaultLayout).toMatchInlineSnapshot(`
805+
{
806+
"center": 35,
807+
"left": 25,
808+
"right": 40,
809+
}
810+
`);
811+
expect(storage.getItem).toHaveBeenCalledWith(keyV4);
812+
expect(storage.getItem).not.toHaveBeenCalledWith(keyV3);
813+
expect(storage.setItem).not.toHaveBeenCalled();
814+
});
815+
});
816+
817+
describe("without panel ids prop", () => {
818+
test("skips mismatched legacy layout", () => {
819+
const { keyV3, keyV4, storage } = mockLayoutStorage({
820+
groupId: "test-group-id",
821+
matchV3: true
822+
});
823+
824+
const { result } = renderHook(() =>
825+
useDefaultLayout({
826+
id: "test-group-id",
827+
storage
828+
})
829+
);
830+
831+
expect(result.current.defaultLayout).toMatchInlineSnapshot(`undefined`);
832+
expect(storage.getItem).toHaveBeenCalledWith(keyV4);
833+
expect(storage.getItem).toHaveBeenCalledWith(keyV3);
834+
expect(storage.setItem).not.toHaveBeenCalled();
835+
});
836+
837+
test("returns matching legacy layout", () => {
838+
const { keyV3, keyV4, storage } = mockLayoutStorage({
839+
groupId: "test-group-id",
840+
legacyLayout: {
841+
"center,right": {
842+
expandToSizes: {},
843+
layout: [50, 50]
844+
}
845+
},
846+
matchV3: true
847+
});
848+
849+
const { result } = renderHook(() =>
850+
useDefaultLayout({
851+
id: "test-group-id",
852+
storage
853+
})
854+
);
855+
856+
expect(result.current.defaultLayout).toMatchInlineSnapshot(`
857+
{
858+
"center": 50,
859+
"right": 50,
860+
}
861+
`);
862+
expect(storage.getItem).toHaveBeenCalledWith(keyV4);
863+
expect(storage.getItem).toHaveBeenCalledWith(keyV3);
864+
expect(storage.setItem).not.toHaveBeenCalled();
865+
});
866+
867+
test("prefers matching modern layouts if both are present", () => {
868+
const { keyV4, storage } = mockLayoutStorage({
869+
groupId: "test-group-id",
870+
matchV3: true,
871+
matchV4: true
872+
});
873+
874+
const { result } = renderHook(() =>
875+
useDefaultLayout({
876+
id: "test-group-id",
877+
storage
878+
})
879+
);
880+
881+
expect(result.current.defaultLayout).toMatchInlineSnapshot(`
882+
{
883+
"center": 35,
884+
"left": 25,
885+
"right": 40,
886+
}
887+
`);
888+
expect(storage.getItem).toHaveBeenCalledWith(keyV4);
889+
expect(storage.setItem).not.toHaveBeenCalled();
890+
});
891+
});
892+
893+
test("updates persisted modern layout", () => {
894+
const { keyV3, keyV4, storage } = mockLayoutStorage({
895+
groupId: "test-group-id",
896+
matchV3: true,
897+
panelIds: ["left", "right"]
898+
});
899+
900+
const { result } = renderHook(() =>
901+
useDefaultLayout({
902+
id: "test-group-id",
903+
panelIds: ["left", "right"],
904+
storage
905+
})
906+
);
907+
908+
expect(result.current.defaultLayout).toMatchInlineSnapshot(`
909+
{
910+
"left": 50,
911+
"right": 50,
912+
}
913+
`);
914+
expect(storage.getItem).toHaveBeenCalledWith(keyV4);
915+
expect(storage.getItem).toHaveBeenCalledWith(keyV3);
916+
expect(storage.setItem).not.toHaveBeenCalled();
917+
918+
result.current.onLayoutChanged({
919+
left: 35,
920+
right: 65
921+
});
922+
923+
expect(storage.setItem).toHaveBeenCalledTimes(1);
924+
expect(storage.setItem).toHaveBeenCalledWith(
925+
keyV4,
926+
JSON.stringify({
927+
left: 35,
928+
right: 65
929+
})
930+
);
931+
});
932+
});
681933
});

0 commit comments

Comments
 (0)