From 4959df67864f9d8845437d750803f9cad519f43a Mon Sep 17 00:00:00 2001 From: alundgren <445243+alundgren@users.noreply.github.com> Date: Fri, 28 Aug 2026 08:34:25 +0200 Subject: [PATCH 1/2] fix(web): stop the resize cursor sticking after the panel closes mid-drag useResizableWidth sets cursor and user-select on document.body when a drag starts and only clears them from onPointerUp and onPointerCancel. The drag handle unmounts mid-drag when the panel is maximized, switched out of inline mode, or closed, after which no pointer handler can run and both global styles stay behind for the rest of the session. Clean up on unmount when a drag is still in flight, and add regression coverage for the hook. Co-Authored-By: Claude Opus 5 (1M context) --- apps/web/src/hooks/useResizableWidth.test.tsx | 223 ++++++++++++++++++ apps/web/src/hooks/useResizableWidth.ts | 15 +- 2 files changed, 237 insertions(+), 1 deletion(-) 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..49aea3029373 --- /dev/null +++ b/apps/web/src/hooks/useResizableWidth.test.tsx @@ -0,0 +1,223 @@ +import { useEffect } from "react"; +import { act } from "react"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; + +import { + useResizableWidth, + type ResizableWidthHandlers, + type UseResizableWidthOptions, +} from "./useResizableWidth"; + +const OPTIONS: UseResizableWidthOptions = { + storageKey: "t3code:test-panel-width", + defaultWidth: 540, + minWidth: 360, + maxWidth: 900, + edge: "left", +}; + +/** + * Minimal CSSOM stand-in for `document.body.style`. Mirrors the real mapping + * between the camelCase property and the dashed name `removeProperty` takes, + * so a cleanup that removes the wrong name shows up as a leak here too. + */ +function createBodyStyle() { + const declarations = new Map(); + return { + get cursor() { + return declarations.get("cursor") ?? ""; + }, + set cursor(value: string) { + declarations.set("cursor", value); + }, + get userSelect() { + return declarations.get("user-select") ?? ""; + }, + set userSelect(value: string) { + declarations.set("user-select", value); + }, + getPropertyValue(name: string) { + return declarations.get(name) ?? ""; + }, + removeProperty(name: string) { + const previous = declarations.get(name) ?? ""; + declarations.delete(name); + return previous; + }, + }; +} + +// ReactDOM needs a host tree; this suite intentionally has no DOM dependency. +class TestNode { + parentNode: TestNode | null = null; + childNodes: TestNode[] = []; + readonly nodeName: string; + readonly tagName: string; + readonly namespaceURI = "http://www.w3.org/1999/xhtml"; + readonly style = {}; + + constructor( + name: string, + readonly ownerDocument: TestNode | null = null, + readonly nodeType = 1, + ) { + this.nodeName = name.toUpperCase(); + this.tagName = this.nodeName; + } + + set textContent(_value: string) { + this.childNodes = []; + } + + appendChild(child: TestNode) { + child.parentNode = this; + this.childNodes.push(child); + return child; + } + + removeChild(child: TestNode) { + this.childNodes.splice(this.childNodes.indexOf(child), 1); + child.parentNode = null; + return child; + } + + createElement(name: string) { + return new TestNode(name, this); + } + + addEventListener() {} + removeEventListener() {} + setAttribute() {} +} + +function installTestDom() { + const document = new TestNode("#document", null, 9) as TestNode & { + body: { style: ReturnType }; + }; + document.body = { style: createBodyStyle() }; + const window = { + document, + HTMLIFrameElement: TestNode, + addEventListener() {}, + removeEventListener() {}, + }; + vi.stubGlobal("document", document); + vi.stubGlobal("window", window); + vi.stubGlobal("HTMLIFrameElement", TestNode); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { + callback(0); + return 1; + }); + vi.stubGlobal("cancelAnimationFrame", () => {}); + return document; +} + +/** Stand-in for the resize handle element, tracking its pointer capture. */ +function createHandleElement() { + let capturedPointerId: number | null = null; + return { + setPointerCapture(pointerId: number) { + capturedPointerId = pointerId; + }, + hasPointerCapture(pointerId: number) { + return capturedPointerId === pointerId; + }, + releasePointerCapture(pointerId: number) { + if (capturedPointerId === pointerId) capturedPointerId = null; + }, + get capturedPointerId() { + return capturedPointerId; + }, + }; +} + +function pointerEvent( + handle: ReturnType, + overrides: { pointerId?: number; clientX?: number; button?: number } = {}, +) { + return { + button: overrides.button ?? 0, + pointerId: overrides.pointerId ?? 1, + clientX: overrides.clientX ?? 0, + currentTarget: handle, + preventDefault() {}, + stopPropagation() {}, + } as unknown as Parameters[0]; +} + +function Probe(props: { onHandlers: (handlers: ResizableWidthHandlers) => void }) { + const { handlers } = useResizableWidth(OPTIONS); + useEffect(() => { + props.onHandlers(handlers); + }, [handlers, props]); + return null; +} + +async function mountProbe(document: TestNode) { + const { createRoot } = await import("react-dom/client"); + const root = createRoot(document.createElement("div") as unknown as Element); + let handlers: ResizableWidthHandlers | null = null; + await act(() => { + root.render( + { + handlers = next; + }} + />, + ); + }); + if (handlers === null) throw new Error("handlers were never published"); + return { root, handlers: handlers as ResizableWidthHandlers }; +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("useResizableWidth global cursor state", () => { + it("clears the global cursor when the drag ends with a pointer up", async () => { + const document = installTestDom(); + const handle = createHandleElement(); + const { root, handlers } = await mountProbe(document); + + try { + await act(() => { + handlers.onPointerDown(pointerEvent(handle, { clientX: 800 })); + }); + expect(document.body.style.cursor).toBe("col-resize"); + expect(document.body.style.userSelect).toBe("none"); + + await act(() => { + handlers.onPointerUp(pointerEvent(handle, { clientX: 760 })); + }); + + expect(document.body.style.cursor).toBe(""); + expect(document.body.style.userSelect).toBe(""); + expect(handle.capturedPointerId).toBeNull(); + } finally { + await act(() => root.unmount()); + } + }); + + it("clears the global cursor when the handle unmounts mid-drag", async () => { + const document = installTestDom(); + const handle = createHandleElement(); + const { root, handlers } = await mountProbe(document); + + await act(() => { + handlers.onPointerDown(pointerEvent(handle, { clientX: 800 })); + }); + expect(document.body.style.cursor).toBe("col-resize"); + + // The panel can disappear under an in-flight drag: maximizing the right + // panel, switching it out of inline mode, or closing it all unmount the + // handle. No further pointer event can reach a handler after that, so the + // hook itself has to give the body its cursor back. + await act(() => root.unmount()); + + expect(document.body.style.cursor).toBe(""); + expect(document.body.style.userSelect).toBe(""); + expect(handle.capturedPointerId).toBeNull(); + }); +}); diff --git a/apps/web/src/hooks/useResizableWidth.ts b/apps/web/src/hooks/useResizableWidth.ts index 08c067471f74..0859ba08d6d0 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"; @@ -90,6 +96,13 @@ export function useResizableWidth(options: UseResizableWidthOptions): { dragStateRef.current = null; }, []); + useEffect(() => { + return () => { + const state = dragStateRef.current; + if (state) releasePointer(state.pointerId); + }; + }, [releasePointer]); + const onPointerDown = useCallback( (event: ReactPointerEvent) => { if (event.button !== 0) return; From eb13b55c8afeb22f81f4c703576562263daa7899 Mon Sep 17 00:00:00 2001 From: alundgren <445243+alundgren@users.noreply.github.com> Date: Fri, 28 Aug 2026 08:52:30 +0200 Subject: [PATCH 2/2] fix(web): tie resize cleanup to the drag handle element PreviewPanelShell owns the hook but renders the handle only while the panel is inline and not maximized, so maximizing removes the handle without unmounting the hook. The unmount effect never ran for that case, which is the most common way the drag is interrupted. Clean up from a ref callback on the handle instead. It runs when the element leaves the DOM, covering both handle removal and full unmount, and cannot drift out of sync with the condition that renders the handle. Co-Authored-By: Claude Opus 5 (1M context) --- apps/web/src/hooks/useResizableWidth.test.tsx | 213 ++++++++++-------- apps/web/src/hooks/useResizableWidth.ts | 27 ++- 2 files changed, 127 insertions(+), 113 deletions(-) diff --git a/apps/web/src/hooks/useResizableWidth.test.tsx b/apps/web/src/hooks/useResizableWidth.test.tsx index 49aea3029373..65d4a93b0ede 100644 --- a/apps/web/src/hooks/useResizableWidth.test.tsx +++ b/apps/web/src/hooks/useResizableWidth.test.tsx @@ -1,5 +1,4 @@ -import { useEffect } from "react"; -import { act } from "react"; +import { act, useEffect } from "react"; import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import { @@ -17,9 +16,8 @@ const OPTIONS: UseResizableWidthOptions = { }; /** - * Minimal CSSOM stand-in for `document.body.style`. Mirrors the real mapping - * between the camelCase property and the dashed name `removeProperty` takes, - * so a cleanup that removes the wrong name shows up as a leak here too. + * Minimal CSSOM stand-in for `document.body.style`, mirroring the real mapping + * between the camelCase property and the dashed name `removeProperty` takes. */ function createBodyStyle() { const declarations = new Map(); @@ -36,9 +34,6 @@ function createBodyStyle() { set userSelect(value: string) { declarations.set("user-select", value); }, - getPropertyValue(name: string) { - return declarations.get(name) ?? ""; - }, removeProperty(name: string) { const previous = declarations.get(name) ?? ""; declarations.delete(name); @@ -51,6 +46,7 @@ function createBodyStyle() { class TestNode { parentNode: TestNode | null = null; childNodes: TestNode[] = []; + capturedPointerId: number | null = null; readonly nodeName: string; readonly tagName: string; readonly namespaceURI = "http://www.w3.org/1999/xhtml"; @@ -85,12 +81,51 @@ class TestNode { return new TestNode(name, this); } + setPointerCapture(pointerId: number) { + this.capturedPointerId = pointerId; + } + + hasPointerCapture(pointerId: number) { + return this.capturedPointerId === pointerId; + } + + releasePointerCapture(pointerId: number) { + if (this.capturedPointerId === pointerId) this.capturedPointerId = null; + } + addEventListener() {} removeEventListener() {} setAttribute() {} } -function installTestDom() { +function pointerEvent(handle: TestNode, clientX: number, pointerId = 1) { + return { + button: 0, + pointerId, + clientX, + currentTarget: handle, + preventDefault() {}, + stopPropagation() {}, + } as unknown as Parameters[0]; +} + +function Probe(props: { + handleMounted: boolean; + onHandlers: (handlers: ResizableWidthHandlers) => void; +}) { + const { handlers } = useResizableWidth(OPTIONS); + useEffect(() => { + props.onHandlers(handlers); + }, [handlers, props]); + return props.handleMounted ?
: null; +} + +/** + * Mounts the hook behind a drag handle that can be removed independently of + * the hook, mirroring `PreviewPanelShell`: the shell owns the hook and renders + * the handle only while the panel is inline and not maximized. + */ +async function mountProbe() { const document = new TestNode("#document", null, 9) as TestNode & { body: { style: ReturnType }; }; @@ -101,6 +136,7 @@ function installTestDom() { addEventListener() {}, removeEventListener() {}, }; + Object.assign(document, { defaultView: window, activeElement: null }); vi.stubGlobal("document", document); vi.stubGlobal("window", window); vi.stubGlobal("HTMLIFrameElement", TestNode); @@ -110,65 +146,37 @@ function installTestDom() { return 1; }); vi.stubGlobal("cancelAnimationFrame", () => {}); - return document; -} -/** Stand-in for the resize handle element, tracking its pointer capture. */ -function createHandleElement() { - let capturedPointerId: number | null = null; - return { - setPointerCapture(pointerId: number) { - capturedPointerId = pointerId; - }, - hasPointerCapture(pointerId: number) { - return capturedPointerId === pointerId; - }, - releasePointerCapture(pointerId: number) { - if (capturedPointerId === pointerId) capturedPointerId = null; - }, - get capturedPointerId() { - return capturedPointerId; - }, - }; -} - -function pointerEvent( - handle: ReturnType, - overrides: { pointerId?: number; clientX?: number; button?: number } = {}, -) { - return { - button: overrides.button ?? 0, - pointerId: overrides.pointerId ?? 1, - clientX: overrides.clientX ?? 0, - currentTarget: handle, - preventDefault() {}, - stopPropagation() {}, - } as unknown as Parameters[0]; -} - -function Probe(props: { onHandlers: (handlers: ResizableWidthHandlers) => void }) { - const { handlers } = useResizableWidth(OPTIONS); - useEffect(() => { - props.onHandlers(handlers); - }, [handlers, props]); - return null; -} - -async function mountProbe(document: TestNode) { + const container = document.createElement("div"); const { createRoot } = await import("react-dom/client"); - const root = createRoot(document.createElement("div") as unknown as Element); + const root = createRoot(container as unknown as Element); let handlers: ResizableWidthHandlers | null = null; - await act(() => { - root.render( - { - handlers = next; - }} - />, - ); - }); + + const render = async (handleMounted: boolean) => { + await act(() => { + root.render( + { + handlers = next; + }} + />, + ); + }); + }; + + await render(true); + const handle = container.childNodes[0]; + if (!handle) throw new Error("the drag handle was never rendered"); if (handlers === null) throw new Error("handlers were never published"); - return { root, handlers: handlers as ResizableWidthHandlers }; + + return { + body: document.body, + handle, + handlers: handlers as ResizableWidthHandlers, + unmountHandle: () => render(false), + unmountAll: () => act(() => root.unmount()), + }; } afterEach(() => { @@ -177,47 +185,54 @@ afterEach(() => { describe("useResizableWidth global cursor state", () => { it("clears the global cursor when the drag ends with a pointer up", async () => { - const document = installTestDom(); - const handle = createHandleElement(); - const { root, handlers } = await mountProbe(document); - - try { - await act(() => { - handlers.onPointerDown(pointerEvent(handle, { clientX: 800 })); - }); - expect(document.body.style.cursor).toBe("col-resize"); - expect(document.body.style.userSelect).toBe("none"); - - await act(() => { - handlers.onPointerUp(pointerEvent(handle, { clientX: 760 })); - }); - - expect(document.body.style.cursor).toBe(""); - expect(document.body.style.userSelect).toBe(""); - expect(handle.capturedPointerId).toBeNull(); - } finally { - await act(() => root.unmount()); - } + const probe = await mountProbe(); + + await act(() => { + probe.handlers.onPointerDown(pointerEvent(probe.handle, 800)); + }); + expect(probe.body.style.cursor).toBe("col-resize"); + expect(probe.body.style.userSelect).toBe("none"); + + await act(() => { + probe.handlers.onPointerUp(pointerEvent(probe.handle, 760)); + }); + + expect(probe.body.style.cursor).toBe(""); + expect(probe.body.style.userSelect).toBe(""); + expect(probe.handle.capturedPointerId).toBeNull(); + + await probe.unmountAll(); + }); + + it("clears the global cursor when the handle is removed mid-drag", async () => { + const probe = await mountProbe(); + + await act(() => { + probe.handlers.onPointerDown(pointerEvent(probe.handle, 800)); + }); + expect(probe.body.style.cursor).toBe("col-resize"); + + await probe.unmountHandle(); + + expect(probe.body.style.cursor).toBe(""); + expect(probe.body.style.userSelect).toBe(""); + expect(probe.handle.capturedPointerId).toBeNull(); + + await probe.unmountAll(); }); - it("clears the global cursor when the handle unmounts mid-drag", async () => { - const document = installTestDom(); - const handle = createHandleElement(); - const { root, handlers } = await mountProbe(document); + it("clears the global cursor when the whole panel unmounts mid-drag", async () => { + const probe = await mountProbe(); await act(() => { - handlers.onPointerDown(pointerEvent(handle, { clientX: 800 })); + probe.handlers.onPointerDown(pointerEvent(probe.handle, 800)); }); - expect(document.body.style.cursor).toBe("col-resize"); + expect(probe.body.style.cursor).toBe("col-resize"); - // The panel can disappear under an in-flight drag: maximizing the right - // panel, switching it out of inline mode, or closing it all unmount the - // handle. No further pointer event can reach a handler after that, so the - // hook itself has to give the body its cursor back. - await act(() => root.unmount()); + await probe.unmountAll(); - expect(document.body.style.cursor).toBe(""); - expect(document.body.style.userSelect).toBe(""); - expect(handle.capturedPointerId).toBeNull(); + expect(probe.body.style.cursor).toBe(""); + expect(probe.body.style.userSelect).toBe(""); + expect(probe.handle.capturedPointerId).toBeNull(); }); }); diff --git a/apps/web/src/hooks/useResizableWidth.ts b/apps/web/src/hooks/useResizableWidth.ts index 0859ba08d6d0..445c4cec7fdd 100644 --- a/apps/web/src/hooks/useResizableWidth.ts +++ b/apps/web/src/hooks/useResizableWidth.ts @@ -1,11 +1,5 @@ import * as Schema from "effect/Schema"; -import { - type PointerEvent as ReactPointerEvent, - useCallback, - useEffect, - useRef, - useState, -} from "react"; +import { type PointerEvent as ReactPointerEvent, useCallback, useRef, useState } from "react"; import { getLocalStorageItem, setLocalStorageItem } from "./useLocalStorage"; @@ -26,6 +20,7 @@ export interface UseResizableWidthOptions { } export interface ResizableWidthHandlers { + readonly ref: (element: HTMLElement | null) => (() => void) | undefined; readonly onPointerDown: (event: ReactPointerEvent) => void; readonly onPointerMove: (event: ReactPointerEvent) => void; readonly onPointerUp: (event: ReactPointerEvent) => void; @@ -96,12 +91,16 @@ export function useResizableWidth(options: UseResizableWidthOptions): { dragStateRef.current = null; }, []); - useEffect(() => { - return () => { - const state = dragStateRef.current; - if (state) releasePointer(state.pointerId); - }; - }, [releasePointer]); + const ref = useCallback( + (element: HTMLElement | null) => { + if (element === null) return undefined; + return () => { + const state = dragStateRef.current; + if (state) releasePointer(state.pointerId); + }; + }, + [releasePointer], + ); const onPointerDown = useCallback( (event: ReactPointerEvent) => { @@ -176,6 +175,6 @@ export function useResizableWidth(options: UseResizableWidthOptions): { return { width: clampedWidth, - handlers: { onPointerDown, onPointerMove, onPointerUp, onPointerCancel }, + handlers: { ref, onPointerDown, onPointerMove, onPointerUp, onPointerCancel }, }; }