diff --git a/apps/web/src/hooks/useResizableWidth.test.tsx b/apps/web/src/hooks/useResizableWidth.test.tsx new file mode 100644 index 000000000000..204e8fc7e4dd --- /dev/null +++ b/apps/web/src/hooks/useResizableWidth.test.tsx @@ -0,0 +1,127 @@ +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; +let frame: FrameRequestCallback | undefined; + +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; + frame = undefined; + 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", (callback: FrameRequestCallback) => { + frame = callback; + return 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)); + }); + 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(() => { + 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(); + if (reason !== "unmount") 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, + }, }; }