Skip to content

Commit 47cf4b3

Browse files
authored
Improve Group imperative API when in hidden sub-tree (#549)
Relates to #536
1 parent edf7f61 commit 47cf4b3

File tree

12 files changed

+167
-30
lines changed

12 files changed

+167
-30
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
- [#542](https://github.com/bvaughn/react-resizable-panels/pull/542): Clicks on higher `z-index` elements (e.g. modals) should not trigger separators behind them
66
- [#547](https://github.com/bvaughn/react-resizable-panels/pull/547): Don't re-mount Group when `defaultLayout` or `disableCursor` props change
77
- [#548](https://github.com/bvaughn/react-resizable-panels/pull/548): Bugfix: Gracefully handle `Panel` id changes
8+
- [#549](https://github.com/bvaughn/react-resizable-panels/pull/549): Improve DevX when Group within hidden DOM subtree; defer layout-change events
89

910
## 4.0.8
1011

integrations/vite/tests/visibility.spec.tsx

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -54,19 +54,18 @@ test.describe("visibility", () => {
5454
"/e2e/visibility/display/hidden/"
5555
);
5656

57-
await expect(page.getByText('"onLayoutCount": 1')).toBeVisible();
58-
// Actual layout values are not meaningful since the Group is not visible
57+
await expect(page.getByText('"onLayoutCount": 0')).toBeVisible();
5958

6059
await page.getByText("toggle display hidden → visible").click();
6160

62-
await expect(page.getByText('"onLayoutCount": 2')).toBeVisible();
61+
await expect(page.getByText('"onLayoutCount": 1')).toBeVisible();
6362
await expect(page.getByText("id: left")).toContainText("25%");
6463
await expect(page.getByRole("separator")).toBeVisible();
6564
await expect(page.getByText("id: right")).toContainText("75%");
6665

6766
await page.getByText("toggle display visible → hidden").click();
6867

69-
await expect(page.getByText('"onLayoutCount": 2')).toBeVisible();
68+
await expect(page.getByText('"onLayoutCount": 1')).toBeVisible();
7069
// Actual layout values are not meaningful since the Group is not visible
7170

7271
await page.setViewportSize({
@@ -75,7 +74,7 @@ test.describe("visibility", () => {
7574
});
7675
await page.getByText("toggle display hidden → visible").click();
7776

78-
await expect(page.getByText('"onLayoutCount": 3')).toBeVisible();
77+
await expect(page.getByText('"onLayoutCount": 2')).toBeVisible();
7978
await expect(page.getByText("id: left")).toContainText("33%");
8079
await expect(page.getByRole("separator")).toBeVisible();
8180
await expect(page.getByText("id: right")).toContainText("67%");

lib/components/group/Group.test.tsx

Lines changed: 81 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import { setElementBoundsFunction } from "../../utils/test/mockBoundingClientRec
77
import { Panel } from "../panel/Panel";
88
import { Separator } from "../separator/Separator";
99
import { Group } from "./Group";
10+
import { createRef } from "react";
11+
import type { GroupImperativeHandle } from "./types";
1012

1113
describe("Group", () => {
1214
test("changes to defaultProps or disableCursor should not cause Group to remount", () => {
@@ -28,7 +30,9 @@ describe("Group", () => {
2830
<Panel id="b" />
2931
</Group>
3032
);
31-
expect(onMountedGroupsChange).toHaveBeenCalledTimes(1);
33+
expect(onMountedGroupsChange).toHaveBeenCalled();
34+
35+
onMountedGroupsChange.mockReset();
3236

3337
rerender(
3438
<Group
@@ -42,7 +46,7 @@ describe("Group", () => {
4246
<Panel id="b" />
4347
</Group>
4448
);
45-
expect(onMountedGroupsChange).toHaveBeenCalledTimes(1);
49+
expect(onMountedGroupsChange).not.toHaveBeenCalled();
4650

4751
removeListener();
4852
});
@@ -77,6 +81,81 @@ describe("Group", () => {
7781
);
7882
});
7983

84+
describe("groupRef", () => {
85+
test("should work with an empty Group", () => {
86+
const onLayoutChange = vi.fn();
87+
const groupRef = createRef<GroupImperativeHandle>();
88+
89+
render(<Group groupRef={groupRef} onLayoutChange={onLayoutChange} />);
90+
91+
const group = groupRef.current;
92+
93+
assert(group !== null);
94+
expect(group.getLayout()).toEqual({});
95+
expect(onLayoutChange).not.toHaveBeenCalled();
96+
97+
// This is meaningless but technically valid
98+
group.setLayout({});
99+
100+
// This is still invalid
101+
expect(() =>
102+
group.setLayout({
103+
foo: 50,
104+
bar: 50
105+
})
106+
).toThrow("Invalid 0 panel layout: 50%, 50%");
107+
});
108+
109+
test("should work within a hidden subtree", () => {
110+
// Note this test mimics the hidden subtree scenario by using a groupSize of 0
111+
setElementBoundsFunction(() => new DOMRect(0, 0, 0, 0));
112+
113+
const onLayoutChange = vi.fn();
114+
const groupRef = createRef<GroupImperativeHandle>();
115+
116+
render(
117+
<Group groupRef={groupRef} onLayoutChange={onLayoutChange}>
118+
<Panel defaultSize="35%" id="left" maxSize="45%">
119+
left
120+
</Panel>
121+
<Panel id="right">right</Panel>
122+
</Group>
123+
);
124+
125+
const group = groupRef.current;
126+
127+
assert(group !== null);
128+
expect(onLayoutChange).not.toHaveBeenCalled();
129+
130+
// Size constraints can't really be validated while the Group is hidden
131+
expect(group.getLayout()).toEqual({});
132+
133+
// This is essentially a no-op as well
134+
// Any values set at this point can't be validated and so they'll be overridden when the Group becomes visible
135+
group.setLayout({
136+
left: 45,
137+
right: 55
138+
});
139+
140+
// Simulate the Group becoming visible; this should trigger default layout calculation
141+
setElementBoundsFunction((element) => {
142+
if (element.hasAttribute("data-panel")) {
143+
return new DOMRect(0, 0, 50, 50);
144+
} else {
145+
return new DOMRect(0, 0, 100, 50);
146+
}
147+
});
148+
expect(group.getLayout()).toEqual({
149+
left: 35,
150+
right: 65
151+
});
152+
expect(onLayoutChange).toHaveBeenCalledWith({
153+
left: 35,
154+
right: 65
155+
});
156+
});
157+
});
158+
80159
describe("onLayoutChange", () => {
81160
beforeEach(() => {
82161
setElementBoundsFunction((element) => {

lib/components/group/Group.tsx

Lines changed: 34 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { useId } from "../../hooks/useId";
77
import { useIsomorphicLayoutEffect } from "../../hooks/useIsomorphicLayoutEffect";
88
import { useMergedRefs } from "../../hooks/useMergedRefs";
99
import { useStableCallback } from "../../hooks/useStableCallback";
10+
import { useStableObject } from "../../hooks/useStableObject";
1011
import { POINTER_EVENTS_CSS_PROPERTY_NAME } from "../panel/constants";
1112
import type { RegisteredPanel } from "../panel/types";
1213
import type { RegisteredSeparator } from "../separator/types";
@@ -15,7 +16,6 @@ import { GroupContext } from "./GroupContext";
1516
import { sortByElementOffset } from "./sortByElementOffset";
1617
import type { GroupProps, Layout, RegisteredGroup } from "./types";
1718
import { useGroupImperativeHandle } from "./useGroupImperativeHandle";
18-
import { useStableObject } from "../../hooks/useStableObject";
1919

2020
/**
2121
* A Group wraps a set of resizable Panel components.
@@ -63,12 +63,13 @@ export function Group({
6363
const [panels, setPanels] = useState<RegisteredPanel[]>([]);
6464
const [separators, setSeparators] = useState<RegisteredSeparator[]>([]);
6565

66-
const inMemoryLastExpandedPanelSizesRef = useRef<{
67-
[panelIds: string]: number;
68-
}>({});
69-
const inMemoryLayoutsRef = useRef<{
70-
[panelIds: string]: Layout;
71-
}>({});
66+
const inMemoryValuesRef = useRef<{
67+
lastExpandedPanelSizes: { [panelIds: string]: number };
68+
layouts: { [panelIds: string]: Layout };
69+
}>({
70+
lastExpandedPanelSizes: {},
71+
layouts: {}
72+
});
7273

7374
const mergedRef = useMergedRefs(setElement, elementRef);
7475

@@ -108,7 +109,7 @@ export function Group({
108109
// Register Group and child Panels/Separators with global state
109110
// Listen to global state for drag state related to this Group
110111
useIsomorphicLayoutEffect(() => {
111-
if (element === null || panels.length === 0) {
112+
if (element === null) {
112113
return;
113114
}
114115

@@ -118,8 +119,9 @@ export function Group({
118119
disabled: !!disabled,
119120
element,
120121
id,
121-
inMemoryLastExpandedPanelSizes: inMemoryLastExpandedPanelSizesRef.current,
122-
inMemoryLayouts: inMemoryLayoutsRef.current,
122+
inMemoryLastExpandedPanelSizes:
123+
inMemoryValuesRef.current.lastExpandedPanelSizes,
124+
inMemoryLayouts: inMemoryValuesRef.current.layouts,
123125
orientation,
124126
panels,
125127
separators
@@ -132,8 +134,15 @@ export function Group({
132134
const globalState = read();
133135
const match = globalState.mountedGroups.get(group);
134136
if (match) {
135-
setLayout(match.layout);
136-
onLayoutChangeStable?.(match.layout);
137+
const { defaultLayoutDeferred, derivedPanelConstraints, layout } = match;
138+
139+
if (!defaultLayoutDeferred && derivedPanelConstraints.length > 0) {
140+
// This indicates that the Group has not finished mounting yet
141+
// Likely because it has been rendered inside of a hidden DOM subtree
142+
// Ignore layouts in this case because they will not have been validated
143+
setLayout(layout);
144+
onLayoutChangeStable?.(layout);
145+
}
137146
}
138147

139148
const removeInteractionStateChangeListener = eventEmitter.addListener(
@@ -160,9 +169,19 @@ export function Group({
160169
"mountedGroupsChange",
161170
(mountedGroups) => {
162171
const match = mountedGroups.get(group);
163-
if (match && match.derivedPanelConstraints.length > 0) {
164-
setLayout(match.layout);
165-
onLayoutChangeStable?.(match.layout);
172+
if (match) {
173+
const { defaultLayoutDeferred, derivedPanelConstraints, layout } =
174+
match;
175+
176+
if (defaultLayoutDeferred || derivedPanelConstraints.length === 0) {
177+
// This indicates that the Group has not finished mounting yet
178+
// Likely because it has been rendered inside of a hidden DOM subtree
179+
// Ignore layouts in this case because they will not have been validated
180+
return;
181+
}
182+
183+
setLayout(layout);
184+
onLayoutChangeStable?.(layout);
166185
}
167186
}
168187
);

lib/components/group/useDefaultLayout.test.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { Panel } from "../panel/Panel";
55
import { Group } from "./Group";
66
import type { GroupImperativeHandle, LayoutStorage } from "./types";
77
import { useDefaultLayout } from "./useDefaultLayout";
8+
import { setDefaultElementBounds } from "../../utils/test/mockBoundingClientRect";
89

910
describe("useDefaultLayout", () => {
1011
test("should read/write from the provided Storage API", () => {
@@ -45,6 +46,8 @@ describe("useDefaultLayout", () => {
4546
test("should not break when coupled with dynamic layouts", () => {
4647
const groupRef = createRef<GroupImperativeHandle>();
4748

49+
setDefaultElementBounds(new DOMRect(0, 0, 100, 50));
50+
4851
function Test({ hideMiddlePanel }: { hideMiddlePanel?: boolean }) {
4952
const { defaultLayout, onLayoutChange } = useDefaultLayout({
5053
groupId: "test-group-id",

lib/global/mountGroup.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,12 +66,14 @@ export function mountGroup(group: RegisteredGroup) {
6666

6767
return {
6868
mountedGroups: new Map(prevState.mountedGroups).set(group, {
69+
defaultLayoutDeferred: false,
6970
derivedPanelConstraints: nextDerivedPanelConstraints,
7071
layout: nextLayout,
7172
separatorToPanels: match.separatorToPanels
7273
})
7374
};
7475
}
76+
7577
return prevState;
7678
});
7779
}

lib/global/mutableState.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ export type SeparatorToPanelsMap = Map<
1818
export type MountedGroupMap = Map<
1919
RegisteredGroup,
2020
{
21-
defaultLayoutDeferred?: boolean | undefined;
21+
defaultLayoutDeferred: boolean;
2222
derivedPanelConstraints: PanelConstraints[];
2323
layout: Layout;
2424
separatorToPanels: SeparatorToPanelsMap;

lib/global/utils/adjustLayoutForSeparator.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ export function adjustLayoutForSeparator(
4343
if (!layoutsEqual(prevLayout, nextLayout)) {
4444
update((prevState) => ({
4545
mountedGroups: new Map(prevState.mountedGroups).set(group, {
46+
defaultLayoutDeferred: mountedGroup.defaultLayoutDeferred,
4647
derivedPanelConstraints: mountedGroup.derivedPanelConstraints,
4748
layout: nextLayout,
4849
separatorToPanels: mountedGroup.separatorToPanels

lib/global/utils/getImperativeGroupMethods.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ describe("getImperativeGroupMethods", () => {
6767
getImperativeGroupMethods({
6868
groupId: "A"
6969
}).getLayout()
70-
).toThrow("Group A not found");
70+
).toThrowError('Could not find Group with id "A"');
7171
});
7272

7373
test("returns the current group layout", () => {
@@ -93,7 +93,7 @@ describe("getImperativeGroupMethods", () => {
9393
getImperativeGroupMethods({
9494
groupId: "A"
9595
}).setLayout({})
96-
).toThrow("Group A not found");
96+
).toThrowError('Could not find Group with id "A"');
9797
});
9898

9999
test("ignores a no-op layout update", () => {

lib/global/utils/getImperativeGroupMethods.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,17 +19,25 @@ export function getImperativeGroupMethods({
1919
}
2020
}
2121

22-
throw Error(`Group ${groupId} not found`);
22+
throw Error(`Could not find Group with id "${groupId}"`);
2323
};
2424

2525
return {
2626
getLayout() {
27-
const { layout } = find();
27+
const { defaultLayoutDeferred, layout } = find();
28+
29+
if (defaultLayoutDeferred) {
30+
// This indicates that the Group has not finished mounting yet
31+
// Likely because it has been rendered inside of a hidden DOM subtree
32+
// Any layout value will not have been validated and so it should not be returned
33+
return {};
34+
}
2835

2936
return layout;
3037
},
3138
setLayout(unsafeLayout: Layout) {
3239
const {
40+
defaultLayoutDeferred,
3341
derivedPanelConstraints,
3442
group,
3543
layout: prevLayout,
@@ -41,9 +49,19 @@ export function getImperativeGroupMethods({
4149
panelConstraints: derivedPanelConstraints
4250
});
4351

52+
if (defaultLayoutDeferred) {
53+
// This indicates that the Group has not finished mounting yet
54+
// Likely because it has been rendered inside of a hidden DOM subtree
55+
// In this case we cannot fully validate the layout, so we shouldn't apply it
56+
// It's okay to run the validate function above though,
57+
// it will still warn about certain types of errors (e.g. wrong number of panels)
58+
return prevLayout;
59+
}
60+
4461
if (!layoutsEqual(prevLayout, nextLayout)) {
4562
update((prevState) => ({
4663
mountedGroups: new Map(prevState.mountedGroups).set(group, {
64+
defaultLayoutDeferred,
4765
derivedPanelConstraints,
4866
layout: nextLayout,
4967
separatorToPanels

0 commit comments

Comments
 (0)