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();