FN-8496: prevent mobile column snap drift
Keep mobile board columns centered after late compositor fling updates. - Reassert pinned scroll positions until the next touch interaction. - Cover delayed WebKit compositor writes after scroll settling. - Add a patch changeset for the mobile snap fix. Files changed: .../fn-8496-mobile-column-settle-residual.md | 7 +++++ .../hooks/__tests__/useColumnScrollSnap.test.ts | 21 +++++++++++++++ .../dashboard/app/hooks/useColumnScrollSnap.ts | 30 ++++++++++++++++++++++ 3 files changed, 58 insertions(+) Fusion-Task-Id: FN-8496 Fusion-Task-Lineage: b14f1cf4-df75-4652-bbe0-bec55c307321 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-8496-mobile-column-settle-residual.md
Normal file
7
.changeset/fn-8496-mobile-column-settle-residual.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Mobile board pan/fling always settles on one centered column, never between.
|
||||||
|
category: fix
|
||||||
|
dev: Closes residual useColumnScrollSnap settle race after FN-8489; keeps proximity snap and pin-until-next-touch.
|
||||||
@@ -279,6 +279,27 @@ describe("useColumnScrollSnap", () => {
|
|||||||
expect(scroller.scrollLeft).toBe(COLUMN_WIDTH);
|
expect(scroller.scrollLeft).toBe(COLUMN_WIDTH);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps the integer pin after a compositor fling tick arrives after earlier reassertions", () => {
|
||||||
|
const scroller = createScroller();
|
||||||
|
renderHook(() => useColumnScrollSnap(scroller, { mobileOnly: true, isUserInteraction: () => true }));
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
dispatchShortSwipe(scroller, { scrollDelta: 10, clientDelta: 20 });
|
||||||
|
// iOS can report scrollend before its final compositor fling tick.
|
||||||
|
scroller.dispatchEvent(new Event("scrollend"));
|
||||||
|
expect(scroller.scrollLeft).toBe(COLUMN_WIDTH);
|
||||||
|
|
||||||
|
// Let multiple watchdog passes complete before the callback-less compositor write.
|
||||||
|
vi.advanceTimersByTime(48);
|
||||||
|
scroller.scrollLeft = COLUMN_WIDTH + 40;
|
||||||
|
vi.advanceTimersByTime(16);
|
||||||
|
});
|
||||||
|
|
||||||
|
const columns = [...scroller.children] as HTMLElement[];
|
||||||
|
expect(scroller.scrollLeft).toBe(COLUMN_WIDTH);
|
||||||
|
expect(isColumnCentered(scroller, columns)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it("does not snap on touchcancel mid-drag", () => {
|
it("does not snap on touchcancel mid-drag", () => {
|
||||||
const scroller = createScroller();
|
const scroller = createScroller();
|
||||||
renderHook(() => useColumnScrollSnap(scroller, { mobileOnly: true, isUserInteraction: () => true }));
|
renderHook(() => useColumnScrollSnap(scroller, { mobileOnly: true, isUserInteraction: () => true }));
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ const SCROLL_IDLE_SETTLE_MS = 48;
|
|||||||
const CENTER_TOLERANCE_PX = 1;
|
const CENTER_TOLERANCE_PX = 1;
|
||||||
/** Minimum finger travel to count as a horizontal pan (short swipe still commits). */
|
/** Minimum finger travel to count as a horizontal pan (short swipe still commits). */
|
||||||
const MIN_PAN_CLIENT_PX = 12;
|
const MIN_PAN_CLIENT_PX = 12;
|
||||||
|
/** Keep a WebKit compositor write from outliving the main-thread hard jump. */
|
||||||
|
const PIN_REASSERT_INTERVAL_MS = 16;
|
||||||
|
|
||||||
export interface UseColumnScrollSnapOptions {
|
export interface UseColumnScrollSnapOptions {
|
||||||
/** Restrict magnetic snapping to phone-class viewports. */
|
/** Restrict magnetic snapping to phone-class viewports. */
|
||||||
@@ -233,12 +235,19 @@ export function useColumnScrollSnap(
|
|||||||
let capturedPointerId: number | null = null;
|
let capturedPointerId: number | null = null;
|
||||||
/** Force scrollLeft until the next user touch. */
|
/** Force scrollLeft until the next user touch. */
|
||||||
let pinnedScrollLeft: number | null = null;
|
let pinnedScrollLeft: number | null = null;
|
||||||
|
/** Continues correcting late WebKit compositor writes until the next user interaction. */
|
||||||
|
let pinReassertTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
const clearIdleTimer = () => {
|
const clearIdleTimer = () => {
|
||||||
if (idleTimer !== null) clearTimeout(idleTimer);
|
if (idleTimer !== null) clearTimeout(idleTimer);
|
||||||
idleTimer = null;
|
idleTimer = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const clearPinReassertion = () => {
|
||||||
|
if (pinReassertTimer !== null) clearTimeout(pinReassertTimer);
|
||||||
|
pinReassertTimer = null;
|
||||||
|
};
|
||||||
|
|
||||||
const restoreNativeSnap = () => {
|
const restoreNativeSnap = () => {
|
||||||
if (!nativeSnapSuspended) return;
|
if (!nativeSnapSuspended) return;
|
||||||
scroller.style.scrollSnapType = priorInlineScrollSnapType;
|
scroller.style.scrollSnapType = priorInlineScrollSnapType;
|
||||||
@@ -265,6 +274,7 @@ export function useColumnScrollSnap(
|
|||||||
};
|
};
|
||||||
|
|
||||||
const clearPin = () => {
|
const clearPin = () => {
|
||||||
|
clearPinReassertion();
|
||||||
pinnedScrollLeft = null;
|
pinnedScrollLeft = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -281,6 +291,24 @@ export function useColumnScrollSnap(
|
|||||||
lockedDirection = resolvePanDirection({ scrollDelta, clientDelta });
|
lockedDirection = resolvePanDirection({ scrollDelta, clientDelta });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:BoardNavigation 2026-07-22-19:15:
|
||||||
|
On phone-class WebKit, `scrollend` can precede a final compositor fling write that has no
|
||||||
|
usable `scroll` callback. Two post-jump tasks can both run before that late write, so retain a
|
||||||
|
lightweight pin watchdog until the next user interaction. It corrects only a changed value,
|
||||||
|
preserving free-scroll while held and CSS proximity rather than making snap mandatory.
|
||||||
|
*/
|
||||||
|
const reassertPinnedScrollLeft = () => {
|
||||||
|
pinReassertTimer = setTimeout(() => {
|
||||||
|
pinReassertTimer = null;
|
||||||
|
if (pinnedScrollLeft === null) return;
|
||||||
|
if (scroller.scrollLeft !== pinnedScrollLeft) {
|
||||||
|
hardJumpScrollLeft(scroller, pinnedScrollLeft);
|
||||||
|
}
|
||||||
|
reassertPinnedScrollLeft();
|
||||||
|
}, PIN_REASSERT_INTERVAL_MS);
|
||||||
|
};
|
||||||
|
|
||||||
const applySnapTo = (targetLeft: number) => {
|
const applySnapTo = (targetLeft: number) => {
|
||||||
const target = Math.round(targetLeft);
|
const target = Math.round(targetLeft);
|
||||||
pointerHeld = false;
|
pointerHeld = false;
|
||||||
@@ -288,6 +316,8 @@ export function useColumnScrollSnap(
|
|||||||
hardJumpScrollLeft(scroller, target);
|
hardJumpScrollLeft(scroller, target);
|
||||||
pinnedScrollLeft = target;
|
pinnedScrollLeft = target;
|
||||||
scroller.scrollLeft = target;
|
scroller.scrollLeft = target;
|
||||||
|
clearPinReassertion();
|
||||||
|
reassertPinnedScrollLeft();
|
||||||
};
|
};
|
||||||
|
|
||||||
const snapInScrollDirection = () => {
|
const snapInScrollDirection = () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user