From 1bda76d4885848ad44e199137fe3002ebd10791f Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 22 Jul 2026 17:05:02 -0700 Subject: [PATCH] fix(dashboard): keep mobile board gesture alive through native-scroll pointercancel iOS/Android fire pointercancel when the native pan claims a touch while the touch stream keeps flowing. Treating it as gesture end either orphaned the gesture (drag released mid-screen rested between columns until the next tap) or armed the idle settle with the finger still down (slow scrolls at the edge columns glitched and snapped back). Track the live touch sequence and ignore pointercancel while it is active; touchend stays the real finger lift. Co-Authored-By: Claude Fable 5 --- .../mobile-board-pointercancel-settle.md | 7 ++ .../__tests__/useColumnScrollSnap.test.ts | 74 +++++++++++++++++++ .../app/hooks/useColumnScrollSnap.ts | 29 +++++++- 3 files changed, 109 insertions(+), 1 deletion(-) create mode 100644 .changeset/mobile-board-pointercancel-settle.md diff --git a/.changeset/mobile-board-pointercancel-settle.md b/.changeset/mobile-board-pointercancel-settle.md new file mode 100644 index 0000000000..a5d382bbd1 --- /dev/null +++ b/.changeset/mobile-board-pointercancel-settle.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Fix the mobile board resting between columns after a drag and edge-column snap-back glitches during slow scrolls. +category: fix +dev: useColumnScrollSnap now ignores pointercancel while the touch stream is still live (native scroll takeover); touchend remains the real finger lift, so gestures are neither orphaned nor idle-settled mid-drag. diff --git a/packages/dashboard/app/hooks/__tests__/useColumnScrollSnap.test.ts b/packages/dashboard/app/hooks/__tests__/useColumnScrollSnap.test.ts index 019f69c771..a9d95f659c 100644 --- a/packages/dashboard/app/hooks/__tests__/useColumnScrollSnap.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useColumnScrollSnap.test.ts @@ -318,6 +318,80 @@ describe("useColumnScrollSnap", () => { expect(scroller.scrollLeft).toBe(COLUMN_WIDTH); }); + /* + FNXC:BoardNavigation 2026-07-22-20:10: + iOS/Android fire pointercancel when native scrolling claims the touch, while touchmove/touchend + keep flowing. An early pointercancel must not orphan the gesture (board resting mid-column until + the next tap), and it must not arm the idle settle while the finger is still down (mid-drag + snap-back fighting a slow scroll, worst at the edge columns). + */ + it("still settles after native scroll takeover cancels the pointer stream early", () => { + const scroller = createScroller(3, 0); + renderHook(() => useColumnScrollSnap(scroller, { mobileOnly: true, isUserInteraction: () => true })); + + act(() => { + scroller.dispatchEvent(new Event("touchstart")); + dispatchPointerEvent(scroller, "pointerdown", 200); + // Native pan claims the gesture before 12px of finger travel. + dispatchPointerEvent(scroller, "pointercancel", 195); + // Touch stream continues: finger drags the board to a mid-column rest, then lifts. + scroller.scrollLeft = 40; + scroller.dispatchEvent(new Event("scroll")); + scroller.dispatchEvent(new Event("touchend")); + }); + settleAfterMomentum(); + + // Regression: the orphaned gesture previously left the board resting at 40 until a tap. + expect(scroller.scrollLeft).toBe(COLUMN_WIDTH); + expect(isColumnCentered(scroller, [...scroller.children] as HTMLElement[])).toBe(true); + }); + + it("does not snap mid-drag when the finger pauses after pointercancel", () => { + const scroller = createScroller(3, COLUMN_WIDTH * 2); + renderHook(() => useColumnScrollSnap(scroller, { mobileOnly: true, isUserInteraction: () => true })); + + act(() => { + scroller.dispatchEvent(new Event("touchstart")); + dispatchPointerEvent(scroller, "pointerdown", 100); + dispatchPointerEvent(scroller, "pointercancel", 100); + // Slow scroll away from the last column, then the finger pauses while still down. + scroller.scrollLeft = COLUMN_WIDTH * 2 - 20; + scroller.dispatchEvent(new Event("scroll")); + }); + act(() => { + vi.advanceTimersByTime(120); + }); + // Regression: the idle settle previously fired mid-drag and snapped back to the edge column. + expect(scroller.scrollLeft).toBe(COLUMN_WIDTH * 2 - 20); + + act(() => { + scroller.scrollLeft = COLUMN_WIDTH + 50; + scroller.dispatchEvent(new Event("scroll")); + scroller.dispatchEvent(new Event("touchend")); + }); + settleAfterMomentum(); + + expect(scroller.scrollLeft).toBe(COLUMN_WIDTH); + expect(isColumnCentered(scroller, [...scroller.children] as HTMLElement[])).toBe(true); + }); + + it("still fully cancels on pointercancel when no touch stream is active", () => { + const scroller = createScroller(3, 0); + renderHook(() => useColumnScrollSnap(scroller, { mobileOnly: true, isUserInteraction: () => true })); + + act(() => { + // Pointer-only gesture (no touchstart): pointercancel is a genuine gesture end. + dispatchPointerEvent(scroller, "pointerdown", 200); + dispatchPointerEvent(scroller, "pointermove", 160); + scroller.scrollLeft = 30; + scroller.dispatchEvent(new Event("scroll")); + dispatchPointerEvent(scroller, "pointercancel", 160); + }); + settleAfterMomentum(); + + expect(scroller.scrollLeft).toBe(COLUMN_WIDTH); + }); + it("does not snap on mount or programmatic scrolling", () => { const scroller = createScroller(); renderHook(() => useColumnScrollSnap(scroller, { mobileOnly: true, isUserInteraction: () => true })); diff --git a/packages/dashboard/app/hooks/useColumnScrollSnap.ts b/packages/dashboard/app/hooks/useColumnScrollSnap.ts index 299746401d..b73123f8fd 100644 --- a/packages/dashboard/app/hooks/useColumnScrollSnap.ts +++ b/packages/dashboard/app/hooks/useColumnScrollSnap.ts @@ -233,6 +233,18 @@ export function useColumnScrollSnap( let interactionActive = false; let pointerHeld = false; + /* + FNXC:BoardNavigation 2026-07-22-20:10: + iOS/Android fire `pointercancel` when the native scroll pan claims a touch, but the TOUCH + stream (touchmove/touchend) keeps going. Treating that pointercancel as gesture end either + orphaned the gesture (early cancel, no movement yet → interactionActive false → the later + touchend no-ops and the board rests mid-column until the next tap) or armed the idle settle + while the finger was still down (slow drag with a brief pause hard-jumped/fought the finger, + worst at the edge columns where rubber-band makes WebKit claim the pan aggressively). + Track whether a touch sequence is live and ignore pointercancel while it is — touchend is + the real finger lift. touchcancel remains a genuine gesture cancel. + */ + let touchSequenceActive = false; let gestureStartScrollLeft = scroller.scrollLeft; let lastScrollLeft = scroller.scrollLeft; let gestureStartClientX: number | null = null; @@ -438,6 +450,7 @@ export function useColumnScrollSnap( const beginInteraction = (event: Event) => { if (!isUserInteraction(event)) return; + if (event.type === "touchstart") touchSequenceActive = true; clearPin(); // Mid-momentum re-touch (or duplicate pointerdown+touchstart): cancel pending snap and re-baseline. @@ -534,6 +547,8 @@ export function useColumnScrollSnap( }; const handleFingerLift = (event: Event) => { + // Clear before any early return so a stale flag can't outlive the touch sequence. + if (event.type === "touchend") touchSequenceActive = false; if (!interactionActive || pinnedScrollLeft !== null) return; if ("isPrimary" in event && (event as PointerEvent).isPrimary === false) return; @@ -550,7 +565,19 @@ export function useColumnScrollSnap( armIdleSettle(); }; - const handleGestureCancel = () => { + const handleGestureCancel = (event: Event) => { + if (event.type === "touchcancel") { + touchSequenceActive = false; + } else if (touchSequenceActive) { + /* + FNXC:BoardNavigation 2026-07-22-20:10: + pointercancel from native scroll takeover while the finger is still down: the gesture + continues on the touch stream. Only drop the (now dead) pointer capture; touchend or + touchcancel will end the gesture. + */ + releasePointerCapture(); + return; + } if (!interactionActive || pinnedScrollLeft !== null) return; pointerHeld = false; releasePointerCapture();