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;
+}