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:
gsxdsm
2026-07-22 15:02:43 -07:00
parent d0d10aae6a
commit b9ce662d4f
3 changed files with 58 additions and 0 deletions

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

View File

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

View File

@@ -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 = () => {