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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
## 4.1.0

- [567](https://github.com/bvaughn/react-resizable-panels/pull/567): `useDefaultLayout` hook supports saving and restoring multiple Panel layouts
- [568](https://github.com/bvaughn/react-resizable-panels/pull/568): Fix race in `useGroupRef` and `usePanelRef` hooks

## 4.0.16

Expand Down
86 changes: 81 additions & 5 deletions integrations/vite/src/routes/Decoder.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,75 @@
import { useMemo, useState } from "react";
import { useLayoutEffect, useMemo, useRef, useState } from "react";
import { assert, Box } from "react-lib-tools";
import type { Layout, PanelSize } from "react-resizable-panels";
import { useParams } from "react-router";
import {
useGroupCallbackRef,
useGroupRef,
usePanelCallbackRef,
usePanelRef,
type Layout,
type PanelSize
} from "react-resizable-panels";
import { useParams, useSearchParams } from "react-router";
import { decode } from "../../tests/utils/serializer/decode";
import { DebugData } from "../components/DebugData";

export function Decoder() {
const { encoded } = useParams();
const [searchParams] = useSearchParams();

const [group, setGroup] = useGroupCallbackRef();
const groupRef = useGroupRef();
const groupRefProp = searchParams.has("useGroupCallbackRef")
? setGroup
: searchParams.has("useGroupRef")
? groupRef
: undefined;

const [panel, setPanel] = usePanelCallbackRef();
const panelRef = usePanelRef();
const panelRefProp = searchParams.has("usePanelCallbackRef")
? setPanel
: searchParams.has("usePanelRef")
? panelRef
: undefined;

const stableCallbacksRef = useRef<{
readGroupLayout: () => void;
readPanelSize: () => void;
}>({
readGroupLayout: () => {},
readPanelSize: () => {}
});
useLayoutEffect(() => {
stableCallbacksRef.current.readGroupLayout = () => {
const imperativeGroupApiLayout =
group?.getLayout() ?? groupRef.current?.getLayout();
if (imperativeGroupApiLayout) {
setState((prevState) => ({ ...prevState, imperativeGroupApiLayout }));
}
};
stableCallbacksRef.current.readPanelSize = () => {
const imperativePanelApiSize =
panel?.getSize() ?? panelRef.current?.getSize();
if (imperativePanelApiSize) {
setState((prevState) => ({ ...prevState, imperativePanelApiSize }));
}
};
});

const [state, setState] = useState<{
onLayoutCount: number;
imperativeGroupApiLayout: Layout | undefined;
imperativePanelApiSize: PanelSize | undefined;
layout: Layout;
onLayoutCount: number;
panels: {
[id: number | string]: {
onResizeCount: number;
panelSize: PanelSize;
};
};
}>({
imperativeGroupApiLayout: undefined,
imperativePanelApiSize: undefined,
layout: {},
onLayoutCount: 0,
panels: {}
Expand All @@ -30,7 +82,12 @@ export function Decoder() {

const group = decode(encoded, {
groupProps: {
groupRef: groupRefProp,
onLayoutChange: (layout) => {
setTimeout(() => {
stableCallbacksRef.current.readGroupLayout();
}, 0);

setState((prev) => ({
...prev,
onLayoutCount: prev.onLayoutCount + 1,
Expand All @@ -39,9 +96,14 @@ export function Decoder() {
}
},
panelProps: {
panelRef: panelRefProp,
onResize: (panelSize, id) => {
assert(id, "Panel id required");

setTimeout(() => {
stableCallbacksRef.current.readPanelSize();
}, 0);

setState((prev) => ({
...prev,
panels: {
Expand All @@ -57,7 +119,7 @@ export function Decoder() {
});

return group;
}, [encoded]);
}, [encoded, groupRefProp, panelRefProp]);

// Debugging
// console.group("Decoder");
Expand All @@ -69,6 +131,20 @@ export function Decoder() {
<Box direction="column" gap={2}>
<div>{children}</div>
<Box className="p-2 overflow-auto" direction="row" gap={2} wrap>
{groupRefProp && (
<DebugData
data={{
imperativeGroupApiLayout: state.imperativeGroupApiLayout
}}
/>
)}{" "}
{panelRefProp && (
<DebugData
data={{
imperativePanelApiSize: state.imperativePanelApiSize
}}
/>
)}
<DebugData
data={{
layout: state.layout,
Expand Down
58 changes: 58 additions & 0 deletions integrations/vite/tests/imperative-api-hooks.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { expect, test } from "@playwright/test";
import { Group, Panel, Separator } from "react-resizable-panels";
import { goToUrl } from "./utils/goToUrl";

// High level tests; more nuanced scenarios are covered by unit tests
test.describe("imperative API hooks", () => {
for (const usePopUpWindow of [true, false]) {
test.describe(usePopUpWindow ? "in a popup" : "in the main window", () => {
for (const { useGroupCallbackRef, useGroupRef } of [
{ useGroupRef: true },
{ useGroupCallbackRef: true }
]) {
test(
useGroupCallbackRef ? "useGroupCallbackRef" : "useGroupRef",
async ({ page: mainPage }) => {
await goToUrl(
mainPage,
<Group>
<Panel id="left" defaultSize="30" />
<Separator />
<Panel id="right" />
</Group>,
{ usePopUpWindow, useGroupCallbackRef, useGroupRef }
);

await expect(
mainPage.getByText("imperativeGroupApiLayout")
).toContainText('"left": 30');
}
);
}

for (const { usePanelCallbackRef, usePanelRef } of [
{ usePanelRef: true },
{ usePanelCallbackRef: true }
]) {
test(
usePanelCallbackRef ? "usePanelCallbackRef" : "usePanelRef",
async ({ page: mainPage }) => {
await goToUrl(
mainPage,
<Group>
<Panel id="left" defaultSize="30" />
<Separator />
<Panel id="right" />
</Group>,
{ usePopUpWindow, usePanelCallbackRef, usePanelRef }
);

await expect(
mainPage.getByText("imperativePanelApiSize")
).toContainText('"asPercentage": 70');
}
);
}
});
}
});
25 changes: 23 additions & 2 deletions integrations/vite/tests/utils/goToUrl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,20 @@ export async function goToUrl(
page: Page,
elementProp: ReactElement<unknown>,
config: {
useGroupCallbackRef?: boolean | undefined;
useGroupRef?: boolean | undefined;
usePanelCallbackRef?: boolean | undefined;
usePanelRef?: boolean | undefined;
usePopUpWindow?: boolean | undefined;
} = {}
): Promise<Page> {
const { usePopUpWindow = false } = config;
const {
useGroupCallbackRef = false,
useGroupRef = false,
usePanelCallbackRef = false,
usePanelRef = false,
usePopUpWindow = false
} = config;

let element = elementProp;
let encodedString = "";
Expand All @@ -25,7 +35,18 @@ export async function goToUrl(
encodedString = encode(element);
}

const url = new URL(`http://localhost:3012/e2e/decoder/${encodedString}`);
const queryParams = [
useGroupCallbackRef ? "useGroupCallbackRef" : undefined,
useGroupRef ? "useGroupRef" : undefined,
usePanelCallbackRef ? "usePanelCallbackRef" : undefined,
usePanelRef ? "usePanelRef" : undefined
]
.filter(Boolean)
.join("&");

const url = new URL(
`http://localhost:3012/e2e/decoder/${encodedString}?${queryParams}`
);

// Uncomment when testing for easier repro
console.log("\n\n" + url.toString());
Expand Down
8 changes: 4 additions & 4 deletions lib/components/group/Group.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export function Group({
defaultLayout,
disableCursor,
disabled,
elementRef,
elementRef: elementRefProp,
groupRef,
id: idProp,
onLayoutChange: onLayoutChangeUnstable,
Expand All @@ -58,7 +58,7 @@ export function Group({
const id = useId(idProp);

const [dragActive, setDragActive] = useState(false);
const [element, setElement] = useState<HTMLDivElement | null>(null);
const elementRef = useRef<HTMLDivElement | null>(null);
const [layout, setLayout] = useState<Layout>(defaultLayout ?? {});
const [panels, setPanels] = useState<RegisteredPanel[]>([]);
const [separators, setSeparators] = useState<RegisteredSeparator[]>([]);
Expand All @@ -71,7 +71,7 @@ export function Group({
layouts: {}
});

const mergedRef = useMergedRefs(setElement, elementRef);
const mergedRef = useMergedRefs(elementRef, elementRefProp);

useGroupImperativeHandle(id, groupRef);

Expand Down Expand Up @@ -109,6 +109,7 @@ export function Group({
// Register Group and child Panels/Separators with global state
// Listen to global state for drag state related to this Group
useIsomorphicLayoutEffect(() => {
const element = elementRef.current;
if (element === null) {
return;
}
Expand Down Expand Up @@ -195,7 +196,6 @@ export function Group({
};
}, [
disabled,
element,
id,
onLayoutChangeStable,
orientation,
Expand Down
10 changes: 5 additions & 5 deletions lib/components/panel/Panel.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"use client";

import type { Property } from "csstype";
import { useState, type CSSProperties } from "react";
import { useRef, type CSSProperties } from "react";
import { useId } from "../../hooks/useId";
import { useIsomorphicLayoutEffect } from "../../hooks/useIsomorphicLayoutEffect";
import { useMergedRefs } from "../../hooks/useMergedRefs";
Expand Down Expand Up @@ -41,7 +41,7 @@ export function Panel({
collapsedSize = "0%",
collapsible = false,
defaultSize,
elementRef,
elementRef: elementRefProp,
id: idProp,
maxSize = "100%",
minSize = "0%",
Expand All @@ -54,9 +54,9 @@ export function Panel({

const id = useId(idProp);

const [element, setElement] = useState<HTMLDivElement | null>(null);
const elementRef = useRef<HTMLDivElement | null>(null);

const mergedRef = useMergedRefs(setElement, elementRef);
const mergedRef = useMergedRefs(elementRef, elementRefProp);

const { id: groupId, registerPanel } = useGroupContext();

Expand All @@ -67,6 +67,7 @@ export function Panel({

// Register Panel with parent Group
useIsomorphicLayoutEffect(() => {
const element = elementRef.current;
if (element !== null) {
return registerPanel({
element,
Expand All @@ -87,7 +88,6 @@ export function Panel({
collapsedSize,
collapsible,
defaultSize,
element,
hasOnResize,
id,
idIsStable,
Expand Down
Loading