Skip to content

Commit 192c8ac

Browse files
adamleithpclaude
andcommitted
fix(sidebar): race-proof drag-close width restore, peek unmount reset, arm state inline
Track drag-ended-closed in a ref set synchronously with the closing mousemove (mouseup can beat React's listener re-registration), cancel peek when the sidebar unmounts so the module singleton can't leak a stale overlay into the next mount, and disarm the resize handle inline during render instead of a prop-watching effect (react-doctor no-adjust-state-on-prop-change). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 4a130c4 commit 192c8ac

2 files changed

Lines changed: 29 additions & 11 deletions

File tree

packages/ui/src/features/canvas/components/ChannelsSidebar.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,9 @@ export function ChannelsSidebar() {
5757
useEffect(() => {
5858
if (open) cancelSidebarPeek();
5959
}, [open]);
60+
// The peek store is a module-level singleton — if this sidebar unmounts
61+
// while peeked (route without it), a stale peek would greet the remount.
62+
useEffect(() => () => cancelSidebarPeek(), []);
6063

6164
// Channels stay behind project-bluebird: the toggle only appears where the
6265
// canvas backend is wired, and a persisted "on" is ignored when the flag is

packages/ui/src/primitives/ResizableSidebar.tsx

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -57,11 +57,17 @@ export const ResizableSidebar: React.FC<ResizableSidebarProps> = ({
5757
// SIDEBAR_MIN_WIDTH on the way to the edge, so if the drag ends closed we
5858
// put this back — the next open should restore the user's chosen width.
5959
const dragStartWidthRef = React.useRef(width);
60+
// Whether the drag has closed the sidebar. Tracked in a ref, synchronously
61+
// with the mousemove that closes it — mouseup can fire before React
62+
// re-registers the listeners with the post-close open/peek values, so the
63+
// closure state can't be trusted for the width restore.
64+
const dragEndedClosedRef = React.useRef(false);
6065

6166
const handleMouseDown = (e: React.MouseEvent) => {
6267
e.preventDefault();
6368
dragOriginRef.current = open ? "docked" : "overlay";
6469
dragStartWidthRef.current = width;
70+
dragEndedClosedRef.current = false;
6571
setIsResizing(true);
6672
document.body.style.cursor = "col-resize";
6773
document.body.style.userSelect = "none";
@@ -99,6 +105,7 @@ export const ResizableSidebar: React.FC<ResizableSidebarProps> = ({
99105
if (open) {
100106
if (pointer < DRAG_COLLAPSE_AT && setOpen) {
101107
setOpen(false);
108+
dragEndedClosedRef.current = true;
102109
return;
103110
}
104111
setWidth(clamped);
@@ -108,6 +115,7 @@ export const ResizableSidebar: React.FC<ResizableSidebarProps> = ({
108115
if (peek) {
109116
if (pointer < DRAG_COLLAPSE_AT) {
110117
onPeekDismiss?.();
118+
dragEndedClosedRef.current = true;
111119
return;
112120
}
113121
setWidth(clamped);
@@ -122,6 +130,7 @@ export const ResizableSidebar: React.FC<ResizableSidebarProps> = ({
122130
} else {
123131
onPeekEnter?.();
124132
}
133+
dragEndedClosedRef.current = false;
125134
setWidth(clamped);
126135
}
127136
};
@@ -134,7 +143,7 @@ export const ResizableSidebar: React.FC<ResizableSidebarProps> = ({
134143
// Drag ended closed (drag-to-close or peek dismiss): the collapse walk
135144
// clamped the store width to min on the way down — restore the pre-drag
136145
// width so the next open comes back at the user's chosen size.
137-
if (!open && !peek) {
146+
if (dragEndedClosedRef.current) {
138147
setWidth(dragStartWidthRef.current);
139148
}
140149
// A floating-panel drag suppresses the panel's mouseleave (the pointer
@@ -176,22 +185,28 @@ export const ResizableSidebar: React.FC<ResizableSidebarProps> = ({
176185

177186
// While the panel slides, the resize handle sweeps under a stationary
178187
// pointer and picks up a stale :hover (browsers only recompute hover on
179-
// pointer moves) — the primary line would stick on. Disarm the handle for
180-
// the duration of any slide; a grab in progress keeps it live.
188+
// pointer moves) — the primary line would stick on. Any open/peek flip
189+
// starts a slide, so disarm the handle inline during that same render (a
190+
// grab in progress keeps it live), then re-arm once the slide is over.
181191
const [handleArmed, setHandleArmed] = React.useState(true);
182-
const skipFirstArmCycle = React.useRef(true);
183-
// biome-ignore lint/correctness/useExhaustiveDependencies: open/overlayVisible are pure triggers — any open/peek flip starts a slide and must disarm the handle
184-
React.useEffect(() => {
185-
if (skipFirstArmCycle.current) {
186-
skipFirstArmCycle.current = false;
187-
return;
188-
}
192+
const [prevSlideState, setPrevSlideState] = React.useState({
193+
open,
194+
overlayVisible,
195+
});
196+
if (
197+
prevSlideState.open !== open ||
198+
prevSlideState.overlayVisible !== overlayVisible
199+
) {
200+
setPrevSlideState({ open, overlayVisible });
189201
setHandleArmed(false);
202+
}
203+
React.useEffect(() => {
204+
if (handleArmed) return;
190205
// Slightly past the 200ms slide; timer-based so reduced-motion (no
191206
// transitionend) can't leave the handle disarmed.
192207
const timer = setTimeout(() => setHandleArmed(true), 250);
193208
return () => clearTimeout(timer);
194-
}, [open, overlayVisible]);
209+
}, [handleArmed]);
195210

196211
return (
197212
<Box

0 commit comments

Comments
 (0)