From 03751f5e5023d8d79d684421f1faa625d8506961 Mon Sep 17 00:00:00 2001 From: 0xPratik Date: Mon, 2 Nov 2026 22:55:03 +0545 Subject: [PATCH] fix(panel): restore the committed width when a drag ends early A drag that ends without a release, such as a new press on the handle or the hook unmounting, now drops the preview and puts back the committed `--resizable-width`, so the CSS width and controlled state cannot diverge. The reset moves from `commit` into the drag's teardown, which every end path runs. Pointercancel and lost capture still commit the preview, as before. --- src/hooks/use-resizable.test.ts | 29 ++++++++++++++++++++++++++++- src/hooks/use-resizable.ts | 7 ++++--- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/src/hooks/use-resizable.test.ts b/src/hooks/use-resizable.test.ts index 9a7acd20..977d6544 100644 --- a/src/hooks/use-resizable.test.ts +++ b/src/hooks/use-resizable.test.ts @@ -250,7 +250,7 @@ describe("useResizable", () => { mounted.unmount(); }); - test("a controlled parent that ignores a change keeps its width", () => { + test("a controlled parent that ignores a change keeps its width", async () => { const mounted = mount({ width: 700 }); expect(shownWidth(mounted)).toBe(700); mounted.press("ArrowLeft"); @@ -258,6 +258,14 @@ describe("useResizable", () => { expect(shownWidth(mounted)).toBe(700); mounted.rerender({ width: 716 }); expect(shownWidth(mounted)).toBe(716); + + mounted.pointer("pointerdown", 500); + mounted.pointer("pointermove", 400); + await new Promise((resolve) => requestAnimationFrame(resolve)); + expect(shownWidth(mounted)).toBe(816); + mounted.pointer("pointerup", 400); + expect(mounted.changes).toEqual([716, 816]); + expect(shownWidth(mounted)).toBe(716); mounted.unmount(); }); @@ -344,6 +352,25 @@ describe("useResizable", () => { mounted.unmount(); }); + test("a drag cut short without a release puts the committed width back", async () => { + const mounted = mount({ defaultWidth: 600 }); + mounted.pointer("pointerdown", 500); + mounted.pointer("pointermove", 400); + await new Promise((resolve) => requestAnimationFrame(resolve)); + expect(mounted.panel.style.getPropertyValue(RESIZABLE_WIDTH_VAR)).toBe( + "700px", + ); + // A new press abandons the drag in flight; it never moves, so it commits + // nothing either. + mounted.pointer("pointerdown", 400); + mounted.pointer("pointerup", 400); + expect(mounted.changes).toEqual([]); + expect(mounted.panel.style.getPropertyValue(RESIZABLE_WIDTH_VAR)).toBe( + "600px", + ); + mounted.unmount(); + }); + test("a press that never moves past the drag threshold commits nothing", () => { // The stored 900 renders clamped to 560: a commit would overwrite it. rowWidth = 1000; diff --git a/src/hooks/use-resizable.ts b/src/hooks/use-resizable.ts index e4be8ac5..6980a68c 100644 --- a/src/hooks/use-resizable.ts +++ b/src/hooks/use-resizable.ts @@ -213,9 +213,6 @@ export function useResizable(options: UseResizableOptions): UseResizableResult { ); resolved = clamped === measureDefault(element) ? null : clamped; } - // Drop any drag-time override first: if the parent keeps its width, - // nothing else would put the element back. - writeWidth(element, widthRef.current); if (resolved !== widthRef.current) setWidth(resolved); measure(); }, @@ -286,6 +283,10 @@ export function useResizable(options: UseResizableOptions): UseResizableResult { // React only rewrites the attribute when its value changes, so put // back what it last rendered. if (startValue !== null) handle.setAttribute("aria-valuenow", startValue); + // Drop the preview, whatever ends the drag: a release commits it next, + // and a parent that keeps its width would leave nothing else to put + // the element back. + writeWidth(element, widthRef.current); draggingRef.current = false; stopDragRef.current = null; };