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 <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-22 17:05:02 -07:00
parent 1dd36ed4c6
commit 1bda76d488
3 changed files with 109 additions and 1 deletions

View File

@@ -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.

View File

@@ -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 }));

View File

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