From b178ecfa319f3974437838fd7eddb1247e0c0935 Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Mon, 2 Feb 2026 16:46:57 -0500 Subject: [PATCH 1/4] Optimization: Replace `useForceUpdate` with `useSyncExternalStore` (#649) This should work around an undesirable edge case with "click" events that was reported in #627, while still maintaining the performance optimization added in #638. I have published this change as alpha version `react-resizable-panels@4.5.9-alpha.0` @michaelboyles Please verify this fixes the "click" issue you reported @evanfrawley Sorry to ask, but can you confirm this change doesn't negatively impact the issue you reported in #637? --- CHANGELOG.md | 4 +++ .../tests/tests/pointer-interactions.spec.tsx | 3 +++ lib/components/group/Group.tsx | 16 ----------- lib/components/panel/Panel.tsx | 27 ++++++++++++------- lib/components/panel/types.ts | 1 - lib/global/test/mockGroup.ts | 3 +-- package.json | 4 ++- 7 files changed, 29 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 917328d79..5632e0438 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## Unreleased + +- [649](https://github.com/bvaughn/react-resizable-panels/pull/649): Optimization: Replace `useForceUpdate` with `useSyncExternalStore` + ## 4.5.8 - [651](https://github.com/bvaughn/react-resizable-panels/pull/651): Disabled the change to collapsible panel behavior that was originally made in [635](https://github.com/bvaughn/react-resizable-panels/pull/635) due to another reported regression diff --git a/integrations/tests/tests/pointer-interactions.spec.tsx b/integrations/tests/tests/pointer-interactions.spec.tsx index f314353ac..d9f87cff8 100644 --- a/integrations/tests/tests/pointer-interactions.spec.tsx +++ b/integrations/tests/tests/pointer-interactions.spec.tsx @@ -296,6 +296,9 @@ test.describe("pointer interactions", () => { await expect(panel).toHaveCSS("pointer-events", "auto"); await page.mouse.down(); + await expect(panel).toHaveCSS("pointer-events", "auto"); + + await page.mouse.move(x + 1, y); await expect(panel).toHaveCSS("pointer-events", "none"); await page.mouse.up(); diff --git a/lib/components/group/Group.tsx b/lib/components/group/Group.tsx index 8b992a137..e3df0fcda 100644 --- a/lib/components/group/Group.tsx +++ b/lib/components/group/Group.tsx @@ -226,10 +226,6 @@ export function Group({ if (!defaultLayoutDeferred && derivedPanelConstraints.length > 0) { onLayoutChangeStable(layout); onLayoutChangedStable(layout); - - inMemoryValues.panels.forEach((panel) => { - panel.scheduleUpdate(); - }); } } @@ -241,14 +237,6 @@ export function Group({ const nextInteractionStateActive = interactionState.state === "active"; if (prevInteractionStateActive !== nextInteractionStateActive) { prevInteractionStateActive = nextInteractionStateActive; - - // The only reason to schedule a re-render in response to this event type - // is to disable pointer-events within a Panel while a drag is in progress - // (This is done to prevent text from being selected, etc) - // Unnecessary updates should be very fast in this case but we can still avoid them - inMemoryValues.panels.forEach((panel) => { - panel.scheduleUpdate(); - }); } } ); @@ -275,10 +263,6 @@ export function Group({ if (isCompleted) { onLayoutChangedStable(layout); } - - inMemoryValues.panels.forEach((panel) => { - panel.scheduleUpdate(); - }); } } ); diff --git a/lib/components/panel/Panel.tsx b/lib/components/panel/Panel.tsx index 1db49bc85..2dd972050 100644 --- a/lib/components/panel/Panel.tsx +++ b/lib/components/panel/Panel.tsx @@ -1,7 +1,6 @@ "use client"; -import { useRef, type CSSProperties } from "react"; -import { useForceUpdate } from "../../hooks/useForceUpdate"; +import { useRef, useSyncExternalStore, type CSSProperties } from "react"; import { useId } from "../../hooks/useId"; import { useIsomorphicLayoutEffect } from "../../hooks/useIsomorphicLayoutEffect"; import { useMergedRefs } from "../../hooks/useMergedRefs"; @@ -9,6 +8,7 @@ import { useStableCallback } from "../../hooks/useStableCallback"; import { useGroupContext } from "../group/useGroupContext"; import type { PanelProps, PanelSize } from "./types"; import { usePanelImperativeHandle } from "./usePanelImperativeHandle"; +import { eventEmitter } from "../../global/mutableState"; /** * A Panel wraps resizable content and can be configured with min/max size constraints and collapsible behavior. @@ -58,8 +58,6 @@ export function Panel({ const mergedRef = useMergedRefs(elementRef, elementRefProp); - const [, forceUpdate] = useForceUpdate(); - const { getPanelStyles, id: groupId, registerPanel } = useGroupContext(); const hasOnResize = onResizeUnstable !== null; @@ -92,15 +90,13 @@ export function Panel({ defaultSize, maxSize, minSize - }, - scheduleUpdate: forceUpdate + } }); } }, [ collapsedSize, collapsible, defaultSize, - forceUpdate, hasOnResize, id, idIsStable, @@ -112,7 +108,20 @@ export function Panel({ usePanelImperativeHandle(id, panelRef); - const panelStyles = getPanelStyles(groupId, id); + const panelStylesString = useSyncExternalStore( + (subscribe) => { + eventEmitter.addListener("mountedGroupsChange", subscribe); + + return () => { + eventEmitter.removeListener("mountedGroupsChange", subscribe); + }; + }, + + // useSyncExternalStore does not support a custom equality check + // stringify avoids re-rendering when the style value hasn't changed + () => JSON.stringify(getPanelStyles(groupId, id)), + () => JSON.stringify(getPanelStyles(groupId, id)) + ); return (
void; }; /** diff --git a/lib/global/test/mockGroup.ts b/lib/global/test/mockGroup.ts index 76f618673..ba4405780 100644 --- a/lib/global/test/mockGroup.ts +++ b/lib/global/test/mockGroup.ts @@ -103,8 +103,7 @@ export function mockGroup( prevSize: undefined }, panelConstraints: constraints, - onResize: vi.fn(), - scheduleUpdate: vi.fn() + onResize: vi.fn() }; mockPanels.add(panel); diff --git a/package.json b/package.json index feaaee3d4..c56ddb47e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-resizable-panels", - "version": "4.5.8", + "version": "4.5.9-alpha.0", "type": "module", "author": "Brian Vaughn (https://github.com/bvaughn/)", "contributors": [ @@ -33,6 +33,8 @@ "compress:og-image": "tsx ./scripts/compress-og-image", "e2e:install": "pnpm -C integrations/tests exec playwright install --with-deps", "e2e:test": "pnpm -C integrations/tests run test", + "e2e:test:main": "pnpm -C integrations/tests run test --project=chromium", + "e2e:test:popup": "pnpm -C integrations/tests run test --project=chromium:popup", "lint": "eslint .", "prerelease": "rimraf dist && pnpm run build:lib", "prettier": "prettier --write \"**/*.{css,html,js,json,jsx,ts,tsx}\"", From 7761b1d11a7873911b770c4a774f7b5a8bc18b9b Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Mon, 2 Feb 2026 19:45:17 -0500 Subject: [PATCH 2/4] Re-enable collapse/expand bugfix (#652) Related to #650 --- CHANGELOG.md | 1 + lib/global/utils/adjustLayoutByDelta.test.ts | 45 +++++++++++++++----- lib/global/utils/adjustLayoutByDelta.ts | 36 ++++++++-------- 3 files changed, 53 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5632e0438..50047715e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unreleased - [649](https://github.com/bvaughn/react-resizable-panels/pull/649): Optimization: Replace `useForceUpdate` with `useSyncExternalStore` +- [652](https://github.com/bvaughn/react-resizable-panels/pull/652): Re-enable collapsible panel bugfix after fixing another reported issue ## 4.5.8 diff --git a/lib/global/utils/adjustLayoutByDelta.test.ts b/lib/global/utils/adjustLayoutByDelta.test.ts index 59976e5fe..fa58ec32b 100644 --- a/lib/global/utils/adjustLayoutByDelta.test.ts +++ b/lib/global/utils/adjustLayoutByDelta.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "vitest"; -import { adjustLayoutByDelta as adjustLayoutByDeltaExternal } from "./adjustLayoutByDelta"; import type { Layout } from "../../components/group/types"; import type { PanelConstraints } from "../../components/panel/types"; +import { adjustLayoutByDelta as adjustLayoutByDeltaExternal } from "./adjustLayoutByDelta"; type Args = Parameters[0]; @@ -2013,8 +2013,7 @@ describe("adjustLayoutByDelta", () => { ).toEqual(closed); }); - // TODO Re-enable this once issues/650 is resolved - test.skip("open if delta is greater than minimum threshold", () => { + test("open if delta is greater than minimum threshold", () => { expect( adjustLayoutByDelta({ delta: panelId === "left" ? 6 : -6, @@ -2026,8 +2025,7 @@ describe("adjustLayoutByDelta", () => { ).toEqual(open); }); - // TODO Re-enable this once issues/650 is resolved - test.skip("close if delta is less than minimum threshold", () => { + test("close if delta is less than minimum threshold", () => { expect( adjustLayoutByDelta({ delta: panelId === "left" ? 4 : -4, @@ -2126,8 +2124,7 @@ describe("adjustLayoutByDelta", () => { ).toEqual(closed); }); - // TODO Re-enable this once issues/650 is resolved - test.skip("open if delta is greater than minimum threshold", () => { + test("open if delta is greater than minimum threshold", () => { expect( adjustLayoutByDelta({ delta: panelId === "left" ? 6 : -6, @@ -2139,8 +2136,7 @@ describe("adjustLayoutByDelta", () => { ).toEqual(open); }); - // TODO Re-enable this once issues/650 is resolved - test.skip("close if delta is less than minimum threshold", () => { + test("close if delta is less than minimum threshold", () => { expect( adjustLayoutByDelta({ delta: panelId === "left" ? 4 : -4, @@ -2239,8 +2235,35 @@ describe("adjustLayoutByDelta", () => { }); }); - // TODO Re-enable this once issues/650 is resolved - test.skip("edge case discussions/643", () => { + test("edge case issues/650", () => { + const collapsible = { + collapsedSize: 0, + collapsible: true, + minSize: 10 + }; + + ( + [ + [-4, c([collapsible, {}]), l([46, 54])], + [-4, c([{}, collapsible]), l([46, 54])], + [4, c([collapsible, {}]), l([54, 46])], + [4, c([{}, collapsible]), l([54, 46])] + ] satisfies [number, PanelConstraints[], Layout][] + ).forEach(([delta, panelConstraints, expectedLayout]) => { + expect( + adjustLayoutByDelta({ + delta, + initialLayout: l([50, 50]), + panelConstraints, + prevLayout: l([50, 50]), + pivotIndices: [0, 1], + trigger: "mouse-or-touch" + }) + ).toEqual(expectedLayout); + }); + }); + + test("edge case discussions/643", () => { ( [ [4, l([10, 90])], diff --git a/lib/global/utils/adjustLayoutByDelta.ts b/lib/global/utils/adjustLayoutByDelta.ts index 553320f8d..a3c18c575 100644 --- a/lib/global/utils/adjustLayoutByDelta.ts +++ b/lib/global/utils/adjustLayoutByDelta.ts @@ -131,8 +131,6 @@ export function adjustLayoutByDelta({ } break; } - // TODO Re-enable this once issues/650 is resolved - /* default: { // If we're starting from a collapsed state, dragging past the halfway point should cause the panel to expand // This can happen for positive or negative drags, and panels on either side of the separator can be collapsible @@ -146,36 +144,39 @@ export function adjustLayoutByDelta({ `Panel constraints not found for index ${index}` ); + const prevSize = initialLayout[index]; + const { collapsible, collapsedSize, minSize } = panelConstraints; - if (collapsible) { - // DEBUG.push(` -> collapsible ${isSecondPanel ? "2nd" : "1st"} panel`); + if (collapsible && compareLayoutNumbers(prevSize, minSize) < 0) { + // DEBUG.push(` -> collapsible ${delta < 0 ? "2nd" : "1st"} panel`); if (delta > 0) { const gapSize = minSize - collapsedSize; - const halfwayPoint = gapSize / 2; - // DEBUG.push(` -> halfway point: ${halfwayPoint}`); - // DEBUG.push(` between collapsed: ${collapsedSize}`); - // DEBUG.push(` and min: ${minSize}`); + const halfwayDelta = gapSize / 2; + // DEBUG.push(` -> halfway delta: ${halfwayDelta}`); + // DEBUG.push(` collapsed: ${collapsedSize}`); + // DEBUG.push(` min: ${minSize}`); - if (compareLayoutNumbers(delta, gapSize) < 0) { + const nextSize = prevSize + delta; + if (compareLayoutNumbers(nextSize, minSize) < 0) { // DEBUG.push(" -> adjusting delta"); // DEBUG.push(` from: ${delta}`); delta = - compareLayoutNumbers(delta, halfwayPoint) <= 0 ? 0 : gapSize; + compareLayoutNumbers(delta, halfwayDelta) <= 0 ? 0 : gapSize; // DEBUG.push(` to: ${delta}`); } } else { const gapSize = minSize - collapsedSize; - const halfwayPoint = 100 - gapSize / 2; - // DEBUG.push(` -> halfway point: ${halfwayPoint}`); - // DEBUG.push(` between collapsed: ${100 - collapsedSize}`); - // DEBUG.push(` and min: ${100 - minSize}`); + const halfwayDelta = 100 - gapSize / 2; + // DEBUG.push(` -> halfway delta: ${halfwayDelta}`); + // DEBUG.push(` collapsed: ${100 - collapsedSize}`); + // DEBUG.push(` min: ${100 - minSize}`); - //if (isSecondPanel) { - if (compareLayoutNumbers(Math.abs(delta), gapSize) < 0) { + const nextSize = prevSize - delta; + if (compareLayoutNumbers(nextSize, minSize) < 0) { // DEBUG.push(" -> adjusting delta"); // DEBUG.push(` from: ${delta}`); delta = - compareLayoutNumbers(100 + delta, halfwayPoint) > 0 + compareLayoutNumbers(100 + delta, halfwayDelta) > 0 ? 0 : -gapSize; // DEBUG.push(` to: ${delta}`); @@ -184,7 +185,6 @@ export function adjustLayoutByDelta({ } break; } - */ } // DEBUG.push(""); } From 0983bba51e8e4c38517ee91b4b1fd5c8f0772e03 Mon Sep 17 00:00:00 2001 From: Rupert Dunk Date: Tue, 3 Feb 2026 00:46:45 +0000 Subject: [PATCH 3/4] fix: persist setLayout to inMemoryLayouts cache (#654) I have a three panel layout and am attempting to persist the absolute width of some panels when conditionally rendering others, i.e. switching between a 2 and 3 panel layout. I can do this via the `setLayout` imperative API method, roughly something like this: ```ts const lastLayout = useRef(ref.current?.getLayout() ?? {}) // This is passed as the Group component's onLayoutChanged prop. const handleLayoutChanged = (layout: Layout) => { const next = {...layout} const prev = lastLayout.current const nextLength = Object.keys(next).length const prevLength = Object.keys(prev).length if (prevLength !== nextLength) { // Persist these if ('navigation' in next && 'navigation' in prev) { next.navigation = prev.navigation } if ('sidebar' in next && 'sidebar' in prev) { next.sidebar = prev.sidebar } // Apply remaining space to content if ('content' in next && 'content' in prev) { next.content = 100 - (next.navigation ?? 0) - (next.sidebar ?? 0) } ref.current?.setLayout(next) } lastLayout.current = next onLayoutChanged(next) } ``` However, currently `setLayout` doesn't seem to persist layouts to the `inMemoryLayouts` cache. As a result I am finding that when panels subsequently change (in my case on route change in my application), the layout is I assume read from the stale cache, and the panels snap back to the layout received by `handleLayoutChanged` prior to when I called `setLayout`. This fix just persists those layouts to the cache, which after testing this locally, seems to work. Let me know if you'd like more detail or a repro. --- .../utils/getImperativeGroupMethods.test.ts | 18 ++++++++++++++++++ lib/global/utils/getImperativeGroupMethods.ts | 4 ++++ 2 files changed, 22 insertions(+) diff --git a/lib/global/utils/getImperativeGroupMethods.test.ts b/lib/global/utils/getImperativeGroupMethods.test.ts index 9a0d991fa..580b1c611 100644 --- a/lib/global/utils/getImperativeGroupMethods.test.ts +++ b/lib/global/utils/getImperativeGroupMethods.test.ts @@ -137,5 +137,23 @@ describe("getImperativeGroupMethods", () => { } `); }); + + test("persists layout to inMemoryLayouts cache", () => { + const { api, group } = init([ + { defaultSize: 200, minSize: 100 }, + { defaultSize: 800 } + ]); + + api.setLayout({ + "A-1": 30, + "A-2": 70 + }); + + const panelIdsKey = "A-1,A-2"; + expect(group.inMemoryLayouts[panelIdsKey]).toEqual({ + "A-1": 30, + "A-2": 70 + }); + }); }); }); diff --git a/lib/global/utils/getImperativeGroupMethods.ts b/lib/global/utils/getImperativeGroupMethods.ts index a294e26fe..673853533 100644 --- a/lib/global/utils/getImperativeGroupMethods.ts +++ b/lib/global/utils/getImperativeGroupMethods.ts @@ -67,6 +67,10 @@ export function getImperativeGroupMethods({ separatorToPanels }) })); + + // Save the layout to in-memory cache so it persists when panel configuration changes + const panelIdsKey = group.panels.map(({ id }) => id).join(","); + group.inMemoryLayouts[panelIdsKey] = nextLayout; } return nextLayout; From 4a5e519d3be5f291ce9e721b110d9617148684a4 Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Mon, 2 Feb 2026 19:47:12 -0500 Subject: [PATCH 4/4] Update CHANGELOG with pending release changes --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 50047715e..de12bac9a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,8 @@ ## Unreleased -- [649](https://github.com/bvaughn/react-resizable-panels/pull/649): Optimization: Replace `useForceUpdate` with `useSyncExternalStore` +- [649](https://github.com/bvaughn/react-resizable-panels/pull/649): Optimization: Replace `useForceUpdate` with `useSyncExternalStore` to avoid side effect of swallowing "click" events in certain cases +- [654](https://github.com/bvaughn/react-resizable-panels/pull/654): **Bugfix** Imperative `Group` method `setLayout` persists layout to in-memory cache - [652](https://github.com/bvaughn/react-resizable-panels/pull/652): Re-enable collapsible panel bugfix after fixing another reported issue ## 4.5.8