diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e4ef3c69..cbb136545 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 4.0.15 + +- [556](https://github.com/bvaughn/react-resizable-panels/pull/556): Ignore `defaultLayout` when keys don't match Panel ids + ## 4.0.14 - [555](https://github.com/bvaughn/react-resizable-panels/pull/555): Allow resizable panels to be rendered into a different Window (e.g. popup or frame) by accessing globals through `element.ownerDocument.defaultView` diff --git a/lib/components/group/Group.test.tsx b/lib/components/group/Group.test.tsx index 33c625ba6..c72d97a26 100644 --- a/lib/components/group/Group.test.tsx +++ b/lib/components/group/Group.test.tsx @@ -3,7 +3,10 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; import { eventEmitter } from "../../global/mutableState"; import { moveSeparator } from "../../global/test/moveSeparator"; import { assert } from "../../utils/assert"; -import { setElementBoundsFunction } from "../../utils/test/mockBoundingClientRect"; +import { + setDefaultElementBounds, + setElementBoundsFunction +} from "../../utils/test/mockBoundingClientRect"; import { Panel } from "../panel/Panel"; import { Separator } from "../separator/Separator"; import { Group } from "./Group"; @@ -81,6 +84,60 @@ describe("Group", () => { ); }); + describe("defaultLayout", () => { + test("should be ignored if it does not match Panel ids", () => { + const onLayoutChange = vi.fn(); + + setDefaultElementBounds(new DOMRect(0, 0, 100, 50)); + + render( + + + + + ); + + expect(onLayoutChange).toHaveBeenCalledTimes(1); + expect(onLayoutChange).toHaveBeenCalledWith({ + bar: 50, + baz: 50 + }); + }); + + test("should be ignored if it does not match Panel ids (mounted within hidden subtree)", () => { + const onLayoutChange = vi.fn(); + + render( + + + + + ); + + expect(onLayoutChange).not.toHaveBeenCalled(); + + setDefaultElementBounds(new DOMRect(0, 0, 100, 50)); + + expect(onLayoutChange).toHaveBeenCalledTimes(1); + expect(onLayoutChange).toHaveBeenCalledWith({ + bar: 50, + baz: 50 + }); + }); + }); + describe("groupRef", () => { test("should work with an empty Group", () => { const onLayoutChange = vi.fn(); diff --git a/lib/components/group/useDefaultLayout.test.tsx b/lib/components/group/useDefaultLayout.test.tsx index 70df9239b..f05318b57 100644 --- a/lib/components/group/useDefaultLayout.test.tsx +++ b/lib/components/group/useDefaultLayout.test.tsx @@ -1,11 +1,11 @@ import { render, renderHook } from "@testing-library/react"; import { createRef } from "react"; import { describe, expect, test, vi } from "vitest"; +import { setDefaultElementBounds } from "../../utils/test/mockBoundingClientRect"; import { Panel } from "../panel/Panel"; import { Group } from "./Group"; -import type { GroupImperativeHandle, LayoutStorage } from "./types"; +import { type GroupImperativeHandle, type LayoutStorage } from "./types"; import { useDefaultLayout } from "./useDefaultLayout"; -import { setDefaultElementBounds } from "../../utils/test/mockBoundingClientRect"; describe("useDefaultLayout", () => { test("should read/write from the provided Storage API", () => { @@ -83,7 +83,7 @@ describe("useDefaultLayout", () => { }); // See github.com/bvaughn/react-resizable-panels/pull/540 - test("should not break when coupled with dynamic layouts", () => { + test("should ignore invalid layouts (num panels mismatch)", () => { const groupRef = createRef(); setDefaultElementBounds(new DOMRect(0, 0, 100, 50)); @@ -134,4 +134,30 @@ describe("useDefaultLayout", () => { } `); }); + + test("should ignore invalid layouts (panel ids mismatch)", () => { + setDefaultElementBounds(new DOMRect(0, 0, 100, 50)); + + const groupRef = createRef(); + + render( + + + + + ); + + expect(groupRef.current?.getLayout()).toMatchInlineSnapshot(` + { + "bar": 30, + "baz": 70, + } + `); + }); }); diff --git a/lib/global/mountGroup.ts b/lib/global/mountGroup.ts index 674a88d1e..02ab63a97 100644 --- a/lib/global/mountGroup.ts +++ b/lib/global/mountGroup.ts @@ -13,6 +13,7 @@ import { calculateDefaultLayout } from "./utils/calculateDefaultLayout"; import { layoutsEqual } from "./utils/layoutsEqual"; import { notifyPanelOnResize } from "./utils/notifyPanelOnResize"; import { objectsEqual } from "./utils/objectsEqual"; +import { validateLayoutKeys } from "./utils/validateLayoutKeys"; import { validatePanelGroupLayout } from "./utils/validatePanelGroupLayout"; const ownerDocumentReferenceCounts = new Map(); @@ -114,7 +115,7 @@ export function mountGroup(group: RegisteredGroup) { // In this case the best we can do is ignore the incoming layout let defaultLayout: Layout | undefined = group.defaultLayout; if (defaultLayout) { - if (group.panels.length !== Object.keys(defaultLayout).length) { + if (!validateLayoutKeys(group.panels, defaultLayout)) { defaultLayout = undefined; } } diff --git a/lib/global/utils/validateLayoutKeys.ts b/lib/global/utils/validateLayoutKeys.ts new file mode 100644 index 000000000..88af2fd7b --- /dev/null +++ b/lib/global/utils/validateLayoutKeys.ts @@ -0,0 +1,19 @@ +import type { Layout } from "../../components/group/types"; +import type { RegisteredPanel } from "../../components/panel/types"; + +export function validateLayoutKeys(panels: RegisteredPanel[], layout: Layout) { + const panelIds = panels.map((panel) => panel.id); + const layoutKeys = Object.keys(layout); + + if (panelIds.length !== layoutKeys.length) { + return false; + } + + for (const panelId of panelIds) { + if (!layoutKeys.includes(panelId)) { + return false; + } + } + + return true; +}