From d1094220eee37c87850cbd0cbda4fd931f1587d4 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 6 Sep 2026 19:50:17 -0700 Subject: [PATCH 1/2] fix(web): clear stuck panel resize cursor --- apps/web/src/hooks/useResizableWidth.test.tsx | 118 ++++++++++++++++++ apps/web/src/hooks/useResizableWidth.ts | 43 +++++-- 2 files changed, 154 insertions(+), 7 deletions(-) create mode 100644 apps/web/src/hooks/useResizableWidth.test.tsx diff --git a/apps/web/src/hooks/useResizableWidth.test.tsx b/apps/web/src/hooks/useResizableWidth.test.tsx new file mode 100644 index 000000000000..dcfd4d140667 --- /dev/null +++ b/apps/web/src/hooks/useResizableWidth.test.tsx @@ -0,0 +1,118 @@ +import { act, useLayoutEffect, type PointerEvent } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { useResizableWidth } from "./useResizableWidth"; + +let renderer: ReactTestRenderer; +let result: ReturnType; +let captured = false; +const target = { + setPointerCapture: () => { + captured = true; + }, + hasPointerCapture: () => captured, + releasePointerCapture: () => { + captured = false; + }, +}; +const style = { + cursor: "", + userSelect: "", + removeProperty(property: string) { + if (property === "cursor") this.cursor = ""; + if (property === "user-select") this.userSelect = ""; + }, +}; +const setItem = vi.fn(); +const cancelAnimationFrame = vi.fn(); +let events: EventTarget; + +function pointer(clientX = 100) { + return { + button: 0, + pointerId: 1, + clientX, + currentTarget: target, + preventDefault() {}, + stopPropagation() {}, + } as unknown as PointerEvent; +} + +function Panel() { + const resize = useResizableWidth({ + storageKey: "test-panel-width", + defaultWidth: 400, + minWidth: 200, + maxWidth: 800, + edge: "left", + }); + useLayoutEffect(() => { + result = resize; + }); + return null; +} + +beforeEach(async () => { + captured = false; + style.cursor = ""; + style.userSelect = ""; + events = new EventTarget(); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("window", { + addEventListener: events.addEventListener.bind(events), + removeEventListener: events.removeEventListener.bind(events), + localStorage: { getItem: () => null, setItem }, + }); + vi.stubGlobal("document", { body: { style } }); + vi.stubGlobal("requestAnimationFrame", () => 42); + vi.stubGlobal("cancelAnimationFrame", cancelAnimationFrame); + await act(() => { + renderer = create(); + }); +}); + +afterEach(async () => { + await act(() => renderer.unmount()); + vi.unstubAllGlobals(); + vi.clearAllMocks(); +}); + +describe("panel resize cleanup", () => { + it.each(["unmount", "lost capture", "blur", "cancel"])( + "clears the cursor and pending resize after %s", + async (reason) => { + await act(() => { + result.handlers.onPointerDown(pointer()); + result.handlers.onPointerMove(pointer(50)); + }); + expect(style.cursor).toBe("col-resize"); + expect(style.userSelect).toBe("none"); + await act(() => { + if (reason === "unmount") renderer.unmount(); + if (reason === "lost capture") result.handlers.onLostPointerCapture(pointer()); + if (reason === "blur") events.dispatchEvent(new Event("blur")); + if (reason === "cancel") result.handlers.onPointerCancel(pointer()); + }); + expect(style.cursor).toBe(""); + expect(style.userSelect).toBe(""); + expect(captured).toBe(false); + expect(cancelAnimationFrame).toHaveBeenCalledWith(42); + expect(setItem).not.toHaveBeenCalled(); + expect(result.width).toBe(400); + }, + ); + + it("saves the final width when release is followed by lost capture", async () => { + await act(() => { + result.handlers.onPointerDown(pointer()); + result.handlers.onPointerMove(pointer(50)); + result.handlers.onPointerUp(pointer(50)); + result.handlers.onLostPointerCapture(pointer(50)); + }); + expect(result.width).toBe(450); + expect(setItem).toHaveBeenCalledExactlyOnceWith("test-panel-width", "450"); + expect(style.cursor).toBe(""); + expect(captured).toBe(false); + }); +}); diff --git a/apps/web/src/hooks/useResizableWidth.ts b/apps/web/src/hooks/useResizableWidth.ts index 08c067471f74..7bdb6be339a0 100644 --- a/apps/web/src/hooks/useResizableWidth.ts +++ b/apps/web/src/hooks/useResizableWidth.ts @@ -1,5 +1,11 @@ import * as Schema from "effect/Schema"; -import { type PointerEvent as ReactPointerEvent, useCallback, useRef, useState } from "react"; +import { + type PointerEvent as ReactPointerEvent, + useCallback, + useEffect, + useRef, + useState, +} from "react"; import { getLocalStorageItem, setLocalStorageItem } from "./useLocalStorage"; @@ -24,6 +30,7 @@ export interface ResizableWidthHandlers { readonly onPointerMove: (event: ReactPointerEvent) => void; readonly onPointerUp: (event: ReactPointerEvent) => void; readonly onPointerCancel: (event: ReactPointerEvent) => void; + readonly onLostPointerCapture: (event: ReactPointerEvent) => void; } /** @@ -75,6 +82,8 @@ export function useResizableWidth(options: UseResizableWidthOptions): { const releasePointer = useCallback((pointerId: number) => { const state = dragStateRef.current; if (!state) return; + // Clear first because releasing capture can trigger another cleanup. + dragStateRef.current = null; if (state.rafId !== null) { cancelAnimationFrame(state.rafId); } @@ -87,12 +96,27 @@ export function useResizableWidth(options: UseResizableWidthOptions): { } document.body.style.removeProperty("cursor"); document.body.style.removeProperty("user-select"); - dragStateRef.current = null; }, []); + const cancelDrag = useCallback(() => { + const state = dragStateRef.current; + if (!state) return; + releasePointer(state.pointerId); + setWidth(state.startWidth); + }, [releasePointer]); + + useEffect(() => { + window.addEventListener("blur", cancelDrag); + return () => { + window.removeEventListener("blur", cancelDrag); + const state = dragStateRef.current; + if (state) releasePointer(state.pointerId); + }; + }, [cancelDrag, releasePointer]); + const onPointerDown = useCallback( (event: ReactPointerEvent) => { - if (event.button !== 0) return; + if (event.button !== 0 || dragStateRef.current) return; event.preventDefault(); event.stopPropagation(); const target = event.currentTarget; @@ -155,14 +179,19 @@ export function useResizableWidth(options: UseResizableWidthOptions): { const state = dragStateRef.current; if (!state || state.pointerId !== event.pointerId) return; // Don't persist a cancelled drag; revert to the start width. - releasePointer(event.pointerId); - setWidth(state.startWidth); + cancelDrag(); }, - [releasePointer], + [cancelDrag], ); return { width: clampedWidth, - handlers: { onPointerDown, onPointerMove, onPointerUp, onPointerCancel }, + handlers: { + onPointerDown, + onPointerMove, + onPointerUp, + onPointerCancel, + onLostPointerCapture: onPointerCancel, + }, }; } From d811a6b07913548623053730777a6439a5c05ca3 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 6 Sep 2026 19:54:49 -0700 Subject: [PATCH 2/2] test(web): verify cancelled resize restores width --- apps/web/src/hooks/useResizableWidth.test.tsx | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/apps/web/src/hooks/useResizableWidth.test.tsx b/apps/web/src/hooks/useResizableWidth.test.tsx index dcfd4d140667..204e8fc7e4dd 100644 --- a/apps/web/src/hooks/useResizableWidth.test.tsx +++ b/apps/web/src/hooks/useResizableWidth.test.tsx @@ -27,6 +27,7 @@ const style = { const setItem = vi.fn(); const cancelAnimationFrame = vi.fn(); let events: EventTarget; +let frame: FrameRequestCallback | undefined; function pointer(clientX = 100) { return { @@ -55,6 +56,7 @@ function Panel() { beforeEach(async () => { captured = false; + frame = undefined; style.cursor = ""; style.userSelect = ""; events = new EventTarget(); @@ -65,7 +67,10 @@ beforeEach(async () => { localStorage: { getItem: () => null, setItem }, }); vi.stubGlobal("document", { body: { style } }); - vi.stubGlobal("requestAnimationFrame", () => 42); + vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { + frame = callback; + return 42; + }); vi.stubGlobal("cancelAnimationFrame", cancelAnimationFrame); await act(() => { renderer = create(); @@ -86,6 +91,10 @@ describe("panel resize cleanup", () => { result.handlers.onPointerDown(pointer()); result.handlers.onPointerMove(pointer(50)); }); + await act(() => frame?.(0)); + expect(result.width).toBe(450); + // Queue another move to check that interruption cancels pending work too. + await act(() => result.handlers.onPointerMove(pointer(25))); expect(style.cursor).toBe("col-resize"); expect(style.userSelect).toBe("none"); await act(() => { @@ -99,7 +108,7 @@ describe("panel resize cleanup", () => { expect(captured).toBe(false); expect(cancelAnimationFrame).toHaveBeenCalledWith(42); expect(setItem).not.toHaveBeenCalled(); - expect(result.width).toBe(400); + if (reason !== "unmount") expect(result.width).toBe(400); }, );