From b9ce662d4f0a37e6f7f0c0f0d198ae9c340606cb Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 22 Jul 2026 15:02:43 -0700 Subject: [PATCH] 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) --- .../fn-8496-mobile-column-settle-residual.md | 7 +++++ .../__tests__/useColumnScrollSnap.test.ts | 21 +++++++++++++ .../app/hooks/useColumnScrollSnap.ts | 30 +++++++++++++++++++ 3 files changed, 58 insertions(+) create mode 100644 .changeset/fn-8496-mobile-column-settle-residual.md diff --git a/.changeset/fn-8496-mobile-column-settle-residual.md b/.changeset/fn-8496-mobile-column-settle-residual.md new file mode 100644 index 0000000000..58e01867d2 --- /dev/null +++ b/.changeset/fn-8496-mobile-column-settle-residual.md @@ -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. diff --git a/packages/dashboard/app/hooks/__tests__/useColumnScrollSnap.test.ts b/packages/dashboard/app/hooks/__tests__/useColumnScrollSnap.test.ts index d7ea559b73..cd531d998f 100644 --- a/packages/dashboard/app/hooks/__tests__/useColumnScrollSnap.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useColumnScrollSnap.test.ts @@ -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 })); diff --git a/packages/dashboard/app/hooks/useColumnScrollSnap.ts b/packages/dashboard/app/hooks/useColumnScrollSnap.ts index 8d7cdd1580..e74c58cff9 100644 --- a/packages/dashboard/app/hooks/useColumnScrollSnap.ts +++ b/packages/dashboard/app/hooks/useColumnScrollSnap.ts @@ -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 | 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 = () => {