authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-06 20:28:57-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-06 20:28:57-07:00
logf5fb056d248be557e14358d08525361eca047e01
treeda1cac55dd1187ea258bd94f908d1fcbb240c387
parent6134b90ffb19c18e0b2f468aa172095a393470b0
signature Signed by PGP key B5690EEEBB952194

fix(web): clear stuck panel resize cursor (#10461)


2 files changed, 163 insertions(+), 7 deletions(-)

apps/web/src/hooks/useResizableWidth.test.tsx created+127
......@@ -0,0 +1,127 @@
1import { act, useLayoutEffect, type PointerEvent } from "react";
2import { create, type ReactTestRenderer } from "react-test-renderer";
3import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
4
5import { useResizableWidth } from "./useResizableWidth";
6
7let renderer: ReactTestRenderer;
8let result: ReturnType<typeof useResizableWidth>;
9let captured = false;
10const target = {
11 setPointerCapture: () => {
12 captured = true;
13 },
14 hasPointerCapture: () => captured,
15 releasePointerCapture: () => {
16 captured = false;
17 },
18};
19const style = {
20 cursor: "",
21 userSelect: "",
22 removeProperty(property: string) {
23 if (property === "cursor") this.cursor = "";
24 if (property === "user-select") this.userSelect = "";
25 },
26};
27const setItem = vi.fn();
28const cancelAnimationFrame = vi.fn();
29let events: EventTarget;
30let frame: FrameRequestCallback | undefined;
31
32function pointer(clientX = 100) {
33 return {
34 button: 0,
35 pointerId: 1,
36 clientX,
37 currentTarget: target,
38 preventDefault() {},
39 stopPropagation() {},
40 } as unknown as PointerEvent<HTMLElement>;
41}
42
43function Panel() {
44 const resize = useResizableWidth({
45 storageKey: "test-panel-width",
46 defaultWidth: 400,
47 minWidth: 200,
48 maxWidth: 800,
49 edge: "left",
50 });
51 useLayoutEffect(() => {
52 result = resize;
53 });
54 return null;
55}
56
57beforeEach(async () => {
58 captured = false;
59 frame = undefined;
60 style.cursor = "";
61 style.userSelect = "";
62 events = new EventTarget();
63 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
64 vi.stubGlobal("window", {
65 addEventListener: events.addEventListener.bind(events),
66 removeEventListener: events.removeEventListener.bind(events),
67 localStorage: { getItem: () => null, setItem },
68 });
69 vi.stubGlobal("document", { body: { style } });
70 vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
71 frame = callback;
72 return 42;
73 });
74 vi.stubGlobal("cancelAnimationFrame", cancelAnimationFrame);
75 await act(() => {
76 renderer = create(<Panel />);
77 });
78});
79
80afterEach(async () => {
81 await act(() => renderer.unmount());
82 vi.unstubAllGlobals();
83 vi.clearAllMocks();
84});
85
86describe("panel resize cleanup", () => {
87 it.each(["unmount", "lost capture", "blur", "cancel"])(
88 "clears the cursor and pending resize after %s",
89 async (reason) => {
90 await act(() => {
91 result.handlers.onPointerDown(pointer());
92 result.handlers.onPointerMove(pointer(50));
93 });
94 await act(() => frame?.(0));
95 expect(result.width).toBe(450);
96 // Queue another move to check that interruption cancels pending work too.
97 await act(() => result.handlers.onPointerMove(pointer(25)));
98 expect(style.cursor).toBe("col-resize");
99 expect(style.userSelect).toBe("none");
100 await act(() => {
101 if (reason === "unmount") renderer.unmount();
102 if (reason === "lost capture") result.handlers.onLostPointerCapture(pointer());
103 if (reason === "blur") events.dispatchEvent(new Event("blur"));
104 if (reason === "cancel") result.handlers.onPointerCancel(pointer());
105 });
106 expect(style.cursor).toBe("");
107 expect(style.userSelect).toBe("");
108 expect(captured).toBe(false);
109 expect(cancelAnimationFrame).toHaveBeenCalledWith(42);
110 expect(setItem).not.toHaveBeenCalled();
111 if (reason !== "unmount") expect(result.width).toBe(400);
112 },
113 );
114
115 it("saves the final width when release is followed by lost capture", async () => {
116 await act(() => {
117 result.handlers.onPointerDown(pointer());
118 result.handlers.onPointerMove(pointer(50));
119 result.handlers.onPointerUp(pointer(50));
120 result.handlers.onLostPointerCapture(pointer(50));
121 });
122 expect(result.width).toBe(450);
123 expect(setItem).toHaveBeenCalledExactlyOnceWith("test-panel-width", "450");
124 expect(style.cursor).toBe("");
125 expect(captured).toBe(false);
126 });
127});
apps/web/src/hooks/useResizableWidth.ts+36-7
......@@ -1,5 +1,11 @@
11import * as Schema from "effect/Schema";
2import { type PointerEvent as ReactPointerEvent, useCallback, useRef, useState } from "react";
2import {
3 type PointerEvent as ReactPointerEvent,
4 useCallback,
5 useEffect,
6 useRef,
7 useState,
8} from "react";
39
410import { getLocalStorageItem, setLocalStorageItem } from "./useLocalStorage";
511
......@@ -24,6 +30,7 @@ export interface ResizableWidthHandlers {
2430 readonly onPointerMove: (event: ReactPointerEvent<HTMLElement>) => void;
2531 readonly onPointerUp: (event: ReactPointerEvent<HTMLElement>) => void;
2632 readonly onPointerCancel: (event: ReactPointerEvent<HTMLElement>) => void;
33 readonly onLostPointerCapture: (event: ReactPointerEvent<HTMLElement>) => void;
2734}
2835
2936/**
......@@ -75,6 +82,8 @@ export function useResizableWidth(options: UseResizableWidthOptions): {
7582 const releasePointer = useCallback((pointerId: number) => {
7683 const state = dragStateRef.current;
7784 if (!state) return;
85 // Clear first because releasing capture can trigger another cleanup.
86 dragStateRef.current = null;
7887 if (state.rafId !== null) {
7988 cancelAnimationFrame(state.rafId);
8089 }
......@@ -87,12 +96,27 @@ export function useResizableWidth(options: UseResizableWidthOptions): {
8796 }
8897 document.body.style.removeProperty("cursor");
8998 document.body.style.removeProperty("user-select");
90 dragStateRef.current = null;
9199 }, []);
92100
101 const cancelDrag = useCallback(() => {
102 const state = dragStateRef.current;
103 if (!state) return;
104 releasePointer(state.pointerId);
105 setWidth(state.startWidth);
106 }, [releasePointer]);
107
108 useEffect(() => {
109 window.addEventListener("blur", cancelDrag);
110 return () => {
111 window.removeEventListener("blur", cancelDrag);
112 const state = dragStateRef.current;
113 if (state) releasePointer(state.pointerId);
114 };
115 }, [cancelDrag, releasePointer]);
116
93117 const onPointerDown = useCallback(
94118 (event: ReactPointerEvent<HTMLElement>) => {
95 if (event.button !== 0) return;
119 if (event.button !== 0 || dragStateRef.current) return;
96120 event.preventDefault();
97121 event.stopPropagation();
98122 const target = event.currentTarget;
......@@ -155,14 +179,19 @@ export function useResizableWidth(options: UseResizableWidthOptions): {
155179 const state = dragStateRef.current;
156180 if (!state || state.pointerId !== event.pointerId) return;
157181 // Don't persist a cancelled drag; revert to the start width.
158 releasePointer(event.pointerId);
159 setWidth(state.startWidth);
182 cancelDrag();
160183 },
161 [releasePointer],
184 [cancelDrag],
162185 );
163186
164187 return {
165188 width: clampedWidth,
166 handlers: { onPointerDown, onPointerMove, onPointerUp, onPointerCancel },
189 handlers: {
190 onPointerDown,
191 onPointerMove,
192 onPointerUp,
193 onPointerCancel,
194 onLostPointerCapture: onPointerCancel,
195 },
167196 };
168197}