diff --git a/CHANGELOG.md b/CHANGELOG.md index 68e6847a..aa3cea62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/integrations/vite/src/routes/Decoder.tsx b/integrations/vite/src/routes/Decoder.tsx index 0d5feb99..c8ed9791 100644 --- a/integrations/vite/src/routes/Decoder.tsx +++ b/integrations/vite/src/routes/Decoder.tsx @@ -1,16 +1,66 @@ -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; @@ -18,6 +68,8 @@ export function Decoder() { }; }; }>({ + imperativeGroupApiLayout: undefined, + imperativePanelApiSize: undefined, layout: {}, onLayoutCount: 0, panels: {} @@ -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, @@ -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: { @@ -57,7 +119,7 @@ export function Decoder() { }); return group; - }, [encoded]); + }, [encoded, groupRefProp, panelRefProp]); // Debugging // console.group("Decoder"); @@ -69,6 +131,20 @@ export function Decoder() {
{children}
+ {groupRefProp && ( + + )}{" "} + {panelRefProp && ( + + )} { + 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, + + + + + , + { 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, + + + + + , + { usePopUpWindow, usePanelCallbackRef, usePanelRef } + ); + + await expect( + mainPage.getByText("imperativePanelApiSize") + ).toContainText('"asPercentage": 70'); + } + ); + } + }); + } +}); diff --git a/integrations/vite/tests/utils/goToUrl.ts b/integrations/vite/tests/utils/goToUrl.ts index c1e5f3b1..ceb641cd 100644 --- a/integrations/vite/tests/utils/goToUrl.ts +++ b/integrations/vite/tests/utils/goToUrl.ts @@ -7,10 +7,20 @@ export async function goToUrl( page: Page, elementProp: ReactElement, config: { + useGroupCallbackRef?: boolean | undefined; + useGroupRef?: boolean | undefined; + usePanelCallbackRef?: boolean | undefined; + usePanelRef?: boolean | undefined; usePopUpWindow?: boolean | undefined; } = {} ): Promise { - const { usePopUpWindow = false } = config; + const { + useGroupCallbackRef = false, + useGroupRef = false, + usePanelCallbackRef = false, + usePanelRef = false, + usePopUpWindow = false + } = config; let element = elementProp; let encodedString = ""; @@ -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()); diff --git a/lib/components/group/Group.tsx b/lib/components/group/Group.tsx index d7001b23..468122b5 100644 --- a/lib/components/group/Group.tsx +++ b/lib/components/group/Group.tsx @@ -35,7 +35,7 @@ export function Group({ defaultLayout, disableCursor, disabled, - elementRef, + elementRef: elementRefProp, groupRef, id: idProp, onLayoutChange: onLayoutChangeUnstable, @@ -58,7 +58,7 @@ export function Group({ const id = useId(idProp); const [dragActive, setDragActive] = useState(false); - const [element, setElement] = useState(null); + const elementRef = useRef(null); const [layout, setLayout] = useState(defaultLayout ?? {}); const [panels, setPanels] = useState([]); const [separators, setSeparators] = useState([]); @@ -71,7 +71,7 @@ export function Group({ layouts: {} }); - const mergedRef = useMergedRefs(setElement, elementRef); + const mergedRef = useMergedRefs(elementRef, elementRefProp); useGroupImperativeHandle(id, groupRef); @@ -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; } @@ -195,7 +196,6 @@ export function Group({ }; }, [ disabled, - element, id, onLayoutChangeStable, orientation, diff --git a/lib/components/panel/Panel.tsx b/lib/components/panel/Panel.tsx index aa8e814b..5b32a10a 100644 --- a/lib/components/panel/Panel.tsx +++ b/lib/components/panel/Panel.tsx @@ -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"; @@ -41,7 +41,7 @@ export function Panel({ collapsedSize = "0%", collapsible = false, defaultSize, - elementRef, + elementRef: elementRefProp, id: idProp, maxSize = "100%", minSize = "0%", @@ -54,9 +54,9 @@ export function Panel({ const id = useId(idProp); - const [element, setElement] = useState(null); + const elementRef = useRef(null); - const mergedRef = useMergedRefs(setElement, elementRef); + const mergedRef = useMergedRefs(elementRef, elementRefProp); const { id: groupId, registerPanel } = useGroupContext(); @@ -67,6 +67,7 @@ export function Panel({ // Register Panel with parent Group useIsomorphicLayoutEffect(() => { + const element = elementRef.current; if (element !== null) { return registerPanel({ element, @@ -87,7 +88,6 @@ export function Panel({ collapsedSize, collapsible, defaultSize, - element, hasOnResize, id, idIsStable,