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);
|
||||
});
|
||||
|
||||
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", () => {
|
||||
const scroller = createScroller();
|
||||
renderHook(() => useColumnScrollSnap(scroller, { mobileOnly: true, isUserInteraction: () => true }));
|
||||
|
||||
@@ -14,6 +14,8 @@ const SCROLL_IDLE_SETTLE_MS = 48;
|
||||
const CENTER_TOLERANCE_PX = 1;
|
||||
/** Minimum finger travel to count as a horizontal pan (short swipe still commits). */
|
||||
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 {
|
||||
/** Restrict magnetic snapping to phone-class viewports. */
|
||||
@@ -233,12 +235,19 @@ export function useColumnScrollSnap(
|
||||
let capturedPointerId: number | null = null;
|
||||
/** Force scrollLeft until the next user touch. */
|
||||
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 = () => {
|
||||
if (idleTimer !== null) clearTimeout(idleTimer);
|
||||
idleTimer = null;
|
||||
};
|
||||
|
||||
const clearPinReassertion = () => {
|
||||
if (pinReassertTimer !== null) clearTimeout(pinReassertTimer);
|
||||
pinReassertTimer = null;
|
||||
};
|
||||
|
||||
const restoreNativeSnap = () => {
|
||||
if (!nativeSnapSuspended) return;
|
||||
scroller.style.scrollSnapType = priorInlineScrollSnapType;
|
||||
@@ -265,6 +274,7 @@ export function useColumnScrollSnap(
|
||||
};
|
||||
|
||||
const clearPin = () => {
|
||||
clearPinReassertion();
|
||||
pinnedScrollLeft = null;
|
||||
};
|
||||
|
||||
@@ -281,6 +291,24 @@ export function useColumnScrollSnap(
|
||||
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 target = Math.round(targetLeft);
|
||||
pointerHeld = false;
|
||||
@@ -288,6 +316,8 @@ export function useColumnScrollSnap(
|
||||
hardJumpScrollLeft(scroller, target);
|
||||
pinnedScrollLeft = target;
|
||||
scroller.scrollLeft = target;
|
||||
clearPinReassertion();
|
||||
reassertPinnedScrollLeft();
|
||||
};
|
||||
|
||||
const snapInScrollDirection = () => {
|
||||
|
||||
Reference in New Issue
Block a user