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
17 changes: 8 additions & 9 deletions lib/components/group/Group.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
type RefObject
} from "react";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { eventEmitter } from "../../global/mutableState";
import { subscribeToMountedGroup } from "../../global/mutable-state/groups";
import { moveSeparator } from "../../global/test/moveSeparator";
import { assert } from "../../utils/assert";
import {
Expand All @@ -25,11 +25,8 @@ import { useGroupRef } from "./useGroupRef";

describe("Group", () => {
test("changes to defaultProps or disableCursor should not cause Group to remount", () => {
const onMountedGroupsChange = vi.fn();
const removeListener = eventEmitter.addListener(
"mountedGroupsChange",
onMountedGroupsChange
);
const onChange = vi.fn();
const removeListener = subscribeToMountedGroup("group", onChange);

const { rerender } = render(
<Group
Expand All @@ -38,14 +35,15 @@ describe("Group", () => {
b: 50
}}
disableCursor={false}
id="group"
>
<Panel id="a" />
<Panel id="b" />
</Group>
);
expect(onMountedGroupsChange).toHaveBeenCalled();
expect(onChange).toHaveBeenCalled();

onMountedGroupsChange.mockReset();
onChange.mockReset();

rerender(
<Group
Expand All @@ -54,12 +52,13 @@ describe("Group", () => {
b: 65
}}
disableCursor={true}
id="group"
>
<Panel id="a" />
<Panel id="b" />
</Group>
);
expect(onMountedGroupsChange).not.toHaveBeenCalled();
expect(onChange).not.toHaveBeenCalled();

removeListener();
});
Expand Down
167 changes: 85 additions & 82 deletions lib/components/group/Group.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,14 @@
import { useEffect, useMemo, useRef, type CSSProperties } from "react";
import { calculatePanelConstraints } from "../../global/dom/calculatePanelConstraints";
import { mountGroup } from "../../global/mountGroup";
import { eventEmitter, read } from "../../global/mutableState";
import {
getMountedGroupState,
getRegisteredGroup,
subscribeToMountedGroup,
updateMountedGroup
} from "../../global/mutable-state/groups";
import { getInteractionState } from "../../global/mutable-state/interactions";
import { layoutNumbersEqual } from "../../global/utils/layoutNumbersEqual";
import { layoutsEqual } from "../../global/utils/layoutsEqual";
import { useForceUpdate } from "../../hooks/useForceUpdate";
import { useId } from "../../hooks/useId";
Expand Down Expand Up @@ -109,28 +116,24 @@ export function Group({
// TRICKY Don't read for state; it will always lag behind by one tick
const getPanelStyles = useStableCallback(
(groupId: string, panelId: string) => {
const { interactionState, mountedGroups } = read();

for (const group of mountedGroups.keys()) {
if (group.id === groupId) {
const match = mountedGroups.get(group);
if (match) {
let dragActive = false;
switch (interactionState.state) {
case "active": {
dragActive = interactionState.hitRegions.some(
(current) => current.group === group
);
break;
}
}

return {
flexGrow: match.layout[panelId] ?? 1,
pointerEvents: dragActive ? "none" : undefined
} satisfies CSSProperties;
const interactionState = getInteractionState();
const group = getRegisteredGroup(groupId);
const groupState = getMountedGroupState(groupId);
if (groupState) {
let dragActive = false;
switch (interactionState.state) {
case "active": {
dragActive = interactionState.hitRegions.some(
(current) => current.group === group
);
break;
}
}

return {
flexGrow: groupState.layout[panelId] ?? 1,
pointerEvents: dragActive ? "none" : undefined
} satisfies CSSProperties;
}

// This is unexpected except for the initial mount (before the group has registered with the global store)
Expand Down Expand Up @@ -196,14 +199,13 @@ export function Group({
panel.panelConstraints.disabled = disabled;
}

const { mountedGroups } = read();
for (const group of mountedGroups.keys()) {
if (group.id === id) {
const match = mountedGroups.get(group);
if (match) {
match.derivedPanelConstraints = calculatePanelConstraints(group);
}
}
const group = getRegisteredGroup(id);
const groupState = getMountedGroupState(id);
if (group && groupState) {
updateMountedGroup(group, {
...groupState,
derivedPanelConstraints: calculatePanelConstraints(group)
});
}
},
toggleSeparatorDisabled: (separatorId: string, disabled: boolean) => {
Expand Down Expand Up @@ -249,14 +251,15 @@ export function Group({
}

const group: RegisteredGroup = {
defaultLayout: preSortedDefaultLayout,
disableCursor: !!stableProps.disableCursor,
disabled: !!disabled,
element,
id,
inMemoryLastExpandedPanelSizes:
inMemoryValuesRef.current.lastExpandedPanelSizes,
inMemoryLayouts: inMemoryValuesRef.current.layouts,
mutableState: {
defaultLayout: preSortedDefaultLayout,
disableCursor: !!stableProps.disableCursor,
expandedPanelSizes: inMemoryValuesRef.current.lastExpandedPanelSizes,
layouts: inMemoryValuesRef.current.layouts
},
orientation,
panels: inMemoryValues.panels,
resizeTargetMinimumSize: inMemoryValues.resizeTargetMinimumSize,
Expand All @@ -267,66 +270,66 @@ export function Group({

const unmountGroup = mountGroup(group);

const globalState = read();
const match = globalState.mountedGroups.get(group);
if (match) {
const { defaultLayoutDeferred, derivedPanelConstraints, layout } = match;
const { defaultLayoutDeferred, derivedPanelConstraints, layout } =
getMountedGroupState(group.id, true);

if (!defaultLayoutDeferred && derivedPanelConstraints.length > 0) {
onLayoutChangeStable(layout);
onLayoutChangedStable(layout);
}
if (!defaultLayoutDeferred && derivedPanelConstraints.length > 0) {
onLayoutChangeStable(layout);
onLayoutChangedStable(layout);
}

let prevInteractionStateActive = false;
const removeChangeEventListener = subscribeToMountedGroup(id, (event) => {
const { defaultLayoutDeferred, derivedPanelConstraints, layout } =
event.next;

const removeInteractionStateChangeListener = eventEmitter.addListener(
"interactionStateChange",
(interactionState) => {
const nextInteractionStateActive = interactionState.state === "active";
if (prevInteractionStateActive !== nextInteractionStateActive) {
prevInteractionStateActive = nextInteractionStateActive;
}
if (defaultLayoutDeferred || derivedPanelConstraints.length === 0) {
// This indicates that the Group has not finished mounting yet
// Likely because it has been rendered inside of a hidden DOM subtree
// Ignore layouts in this case because they will not have been validated
return;
}
);

const removeMountedGroupsChangeEventListener = eventEmitter.addListener(
"mountedGroupsChange",
(mountedGroups) => {
const match = mountedGroups.get(group);
if (match) {
const { defaultLayoutDeferred, derivedPanelConstraints, layout } =
match;

if (defaultLayoutDeferred || derivedPanelConstraints.length === 0) {
// This indicates that the Group has not finished mounting yet
// Likely because it has been rendered inside of a hidden DOM subtree
// Ignore layouts in this case because they will not have been validated
return;
}

// Save the layout to in-memory cache so it persists when panel configuration changes
// This improves UX for conditionally rendered panels without requiring defaultLayout
const panelIdsKey = group.panels.map(({ id }) => id).join(",");
group.inMemoryLayouts[panelIdsKey] = layout;

const { interactionState } = read();
const isCompleted = interactionState.state !== "active";

onLayoutChangeStable(layout);
if (isCompleted) {
onLayoutChangedStable(layout);
// Save the layout to in-memory cache so it persists when panel configuration changes
// This improves UX for conditionally rendered panels without requiring defaultLayout
const panelIdsKey = group.panels.map(({ id }) => id).join(",");
group.mutableState.layouts[panelIdsKey] = layout;

// Also check if any collapsible Panels were collapsed in this update,
// and record their previous sizes so we can restore them on expand
derivedPanelConstraints.forEach((constraints) => {
if (constraints.collapsible) {
const { layout: prevLayout } = event.prev ?? {};
if (prevLayout) {
const isCollapsed = layoutNumbersEqual(
constraints.collapsedSize,
layout[constraints.panelId]
);
const wasCollapsed = layoutNumbersEqual(
constraints.collapsedSize,
prevLayout[constraints.panelId]
);
if (isCollapsed && !wasCollapsed) {
group.mutableState.expandedPanelSizes[constraints.panelId] =
prevLayout[constraints.panelId];
}
}
}
});

// Lastly notify layout-change(d) handlers of the update
const interactionState = getInteractionState();
const isCompleted = interactionState.state !== "active";
onLayoutChangeStable(layout);
if (isCompleted) {
onLayoutChangedStable(layout);
}
);
});

return () => {
registeredGroupRef.current = null;

unmountGroup();
removeInteractionStateChangeListener();
removeMountedGroupsChangeEventListener();
removeChangeEventListener();
};
}, [
disabled,
Expand All @@ -343,8 +346,8 @@ export function Group({
useEffect(() => {
const registeredGroup = registeredGroupRef.current;
if (registeredGroup) {
registeredGroup.defaultLayout = defaultLayout;
registeredGroup.disableCursor = !!disableCursor;
registeredGroup.mutableState.defaultLayout = defaultLayout;
registeredGroup.mutableState.disableCursor = !!disableCursor;
}
});

Expand Down
22 changes: 11 additions & 11 deletions lib/components/group/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,25 +27,25 @@ export type ResizeTargetMinimumSize = {
fine: number;
};

export type RegisteredGroup = {
defaultLayout: Layout | undefined;
disableCursor: boolean;
export type RegisteredGroup = Readonly<{
disabled: boolean;
element: HTMLElement;
id: string;
// TODO Move to mutable state
inMemoryLastExpandedPanelSizes: {
[panelId: string]: number;
};
// TODO Move to mutable state
inMemoryLayouts: {
[panelIds: string]: Layout;
mutableState: {
defaultLayout: Readonly<Layout> | undefined;
disableCursor: boolean;
expandedPanelSizes: {
[panelId: string]: number;
};
layouts: {
[panelIds: string]: Layout;
};
};
orientation: Orientation;
panels: RegisteredPanel[];
resizeTargetMinimumSize: ResizeTargetMinimumSize;
separators: RegisteredSeparator[];
};
}>;

export type GroupContextType = {
disableCursor: boolean;
Expand Down
22 changes: 8 additions & 14 deletions lib/components/panel/Panel.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { act, render } from "@testing-library/react";
import { createRef, Profiler } from "react";
import { describe, expect, test, vi } from "vitest";
import { eventEmitter } from "../../global/mutableState";
import { getRegisteredGroup } from "../../global/mutable-state/groups";
import { moveSeparator } from "../../global/test/moveSeparator";
import { assert } from "../../utils/assert";
import {
setDefaultElementBounds,
Expand All @@ -11,36 +12,29 @@ import { Group } from "../group/Group";
import type { GroupImperativeHandle } from "../group/types";
import { Separator } from "../separator/Separator";
import { Panel } from "./Panel";
import { moveSeparator } from "../../global/test/moveSeparator";

describe("Panel", () => {
describe("disabled prop", () => {
test("changes to disabled prop should not cause the Panel to remount", () => {
const onMountedGroupsChange = vi.fn();
const removeListener = eventEmitter.addListener(
"mountedGroupsChange",
onMountedGroupsChange
);

const { rerender } = render(
<Group>
<Group id="group">
<Panel disabled id="a" />
<Panel id="b" />
</Group>
);
expect(onMountedGroupsChange).toHaveBeenCalled();

onMountedGroupsChange.mockReset();
const { panels: panelsMounted } = getRegisteredGroup("group", true);

rerender(
<Group>
<Group id="group">
<Panel id="a" />
<Panel disabled id="b" />
</Group>
);
expect(onMountedGroupsChange).not.toHaveBeenCalled();

removeListener();
const { panels: panelsUpdated } = getRegisteredGroup("group", true);

expect(panelsMounted).toBe(panelsUpdated);
});

test("changes to this prop should update Panel behavior", async () => {
Expand Down
Loading
Loading