From 8e284f99b32104c4a934cb2aac694520e71ab6da Mon Sep 17 00:00:00 2001 From: Gordon MacMaster <31481849+gmacmaster@users.noreply.github.com> Date: Wed, 13 May 2026 08:54:39 -0400 Subject: [PATCH 1/3] fix scroll touch scaling issue --- ...-d89877b5-21f1-487a-b114-6048bf2284ae.json | 7 ++ .../TestCompositionContext.h | 5 ++ .../CompositionSwitcher.idl | 12 +++ .../Composition/CompositionContextHelper.cpp | 62 +++++++++++++++ .../Composition/CompositionEventHandler.cpp | 75 +++++++++++++++---- .../Composition/CompositionEventHandler.h | 26 +++++++ .../Fabric/Composition/ReactNativeIsland.cpp | 6 ++ .../Fabric/Composition/ReactNativeIsland.h | 5 ++ .../Composition/ScrollViewComponentView.cpp | 59 +++++++++++++-- .../Composition/ScrollViewComponentView.h | 2 + 10 files changed, 237 insertions(+), 22 deletions(-) create mode 100644 change/react-native-windows-d89877b5-21f1-487a-b114-6048bf2284ae.json diff --git a/change/react-native-windows-d89877b5-21f1-487a-b114-6048bf2284ae.json b/change/react-native-windows-d89877b5-21f1-487a-b114-6048bf2284ae.json new file mode 100644 index 00000000000..138559ebb8c --- /dev/null +++ b/change/react-native-windows-d89877b5-21f1-487a-b114-6048bf2284ae.json @@ -0,0 +1,7 @@ +{ + "type": "prerelease", + "comment": "Fix #16047: Pressables inside ScrollView remained stuck in the pressed state after a touch-driven scroll, and on non-100% Windows display scales the next tap on a row would not register `press`. Two underlying causes were addressed: (1) VisualInteractionSource::TryRedirectForManipulation does not reliably fire PointerCaptureLost for the redirected pointer, leaving a zombie entry in CompositionEventHandler::m_activeTouches — now resolved by synthesizing a touchcancel from the InteractionTracker's InteractingStateEntered callback; and (2) ScrollViewComponentView::updateStateWithContentOffset wrote the raw physical-pixel ScrollPosition into ScrollViewShadowNode state's contentOffset, which Fabric layout treats as DIPs, so JS UIManager.measure() over-subtracted the offset by pointScaleFactor after any scroll on a >100% display, causing Pressability to fire LEAVE_PRESS_RECT synchronously and suppress press — now divides by pointScaleFactor to match the JS event-emitter paths in the same file.", + "packageName": "react-native-windows", + "email": "gordomacmaster@gmail.com", + "dependentChangeType": "patch" +} diff --git a/vnext/Desktop.IntegrationTests/TestCompositionContext.h b/vnext/Desktop.IntegrationTests/TestCompositionContext.h index 134b1a61c81..827139b8a43 100644 --- a/vnext/Desktop.IntegrationTests/TestCompositionContext.h +++ b/vnext/Desktop.IntegrationTests/TestCompositionContext.h @@ -117,6 +117,11 @@ struct TestScrollVisual : TestVisualBase const &) { + return {}; + } + void InteractingStateEntered(winrt::event_token) {} void ContentSize(winrt::Windows::Foundation::Numerics::float2) {} winrt::Windows::Foundation::Numerics::float3 ScrollPosition() { return {}; diff --git a/vnext/Microsoft.ReactNative/CompositionSwitcher.idl b/vnext/Microsoft.ReactNative/CompositionSwitcher.idl index 1896b0dc357..70940f8cbce 100644 --- a/vnext/Microsoft.ReactNative/CompositionSwitcher.idl +++ b/vnext/Microsoft.ReactNative/CompositionSwitcher.idl @@ -101,6 +101,17 @@ enum SnapPointsAlignment { }; } +// Fired when the underlying InteractionTracker claims a pointer for scrolling +// (TryRedirectForManipulation succeeded and the user moved enough). Carries the +// PointerId of the touch that was redirected so the caller can cancel any active +// touch RN is tracking for it. The OS does not reliably deliver +// PointerCaptureLost / PointerReleased after redirect (see issue #16047). +[webhosthidden][experimental] interface IInteractingStateEnteredArgs { + Int32 PointerId { + get; + }; +} + [webhosthidden][experimental] interface IScrollVisual requires IVisual { @@ -111,6 +122,7 @@ enum SnapPointsAlignment { event Windows.Foundation.EventHandler ScrollEndDrag; event Windows.Foundation.EventHandler ScrollMomentumBegin; event Windows.Foundation.EventHandler ScrollMomentumEnd; + event Windows.Foundation.EventHandler InteractingStateEntered; void ContentSize(Windows.Foundation.Numerics.Vector2 size); Windows.Foundation.Numerics.Vector3 ScrollPosition { get; diff --git a/vnext/Microsoft.ReactNative/Fabric/Composition/CompositionContextHelper.cpp b/vnext/Microsoft.ReactNative/Fabric/Composition/CompositionContextHelper.cpp index 46def20b57d..a4253d2f494 100644 --- a/vnext/Microsoft.ReactNative/Fabric/Composition/CompositionContextHelper.cpp +++ b/vnext/Microsoft.ReactNative/Fabric/Composition/CompositionContextHelper.cpp @@ -703,6 +703,20 @@ struct CompScrollPositionChangedArgs winrt::Windows::Foundation::Numerics::float2 m_position; }; +struct CompInteractingStateEnteredArgs + : winrt::implements< + CompInteractingStateEnteredArgs, + winrt::Microsoft::ReactNative::Composition::Experimental::IInteractingStateEnteredArgs> { + CompInteractingStateEnteredArgs(int32_t pointerId) : m_pointerId(pointerId) {} + + int32_t PointerId() const noexcept { + return m_pointerId; + } + + private: + int32_t m_pointerId; +}; + template struct CompScrollerVisual : winrt::implements< CompScrollerVisual, @@ -740,6 +754,10 @@ struct CompScrollerVisual : winrt::implements< m_outer->m_custom = false; m_outer->m_inertia = false; m_outer->m_interacting = false; + // Defensive: if InteractingStateEntered never fired (tap that didn't move + // far enough to claim the gesture), the redirected pointerId would leak + // forward and be wrongly cancelled on the *next* press's interaction. + m_outer->m_redirectedPointerId = -1; } void InertiaStateEntered( typename TTypeRedirects::InteractionTracker sender, @@ -767,6 +785,17 @@ struct CompScrollerVisual : winrt::implements< // Mark that we're now interacting and remember the requestId (user manipulations => 0) m_outer->m_interacting = true; + // Surface the redirected pointerId so RN can synthesize a touch-cancel. + // Only fire for user-driven manipulations (requestId == 0); programmatic + // Try* calls don't have an originating pointer. + if (args.RequestId() == 0 && m_outer->m_redirectedPointerId != -1) { + const int32_t pointerIdToCancel = m_outer->m_redirectedPointerId; + // Clear before firing so re-entrant callers (e.g. another touch arriving + // synchronously inside the cancel) don't double-cancel the same id. + m_outer->m_redirectedPointerId = -1; + m_outer->FireInteractingStateEntered(pointerIdToCancel); + } + // Fire when the user starts dragging the object m_outer->FireScrollBeginDrag({sender.Position().x, sender.Position().y}); } @@ -825,6 +854,15 @@ struct CompScrollerVisual : winrt::implements< if constexpr (std::is_same_v) { auto pointerDeviceType = args.Pointer().PointerDeviceType(); if (pointerDeviceType == winrt::Microsoft::ReactNative::Composition::Input::PointerDeviceType::Touch) { + // Issue #16047: remember which pointerId we asked the InteractionTracker + // to manipulate. If the tracker actually claims the gesture + // (InteractingStateEntered), we'll surface this id so RN can synthesize a + // touch-cancel — the OS does not reliably deliver PointerCaptureLost or + // PointerReleased for the redirected pointer. Multi-finger panning would + // clobber this single-slot id; the common case is one finger, and the + // worst-case for multi-touch matches the pre-fix behavior (the second + // touch's cancel would still be missed). + m_redirectedPointerId = static_cast(args.Pointer().PointerId()); m_visualInteractionSource.TryRedirectForManipulation(args.GetCurrentPoint(args.OriginalSource()).Inner()); } } @@ -1081,6 +1119,13 @@ struct CompScrollerVisual : winrt::implements< return m_scrollMomentumEndEvent.add(handler); } + winrt::event_token InteractingStateEntered( + winrt::Windows::Foundation::EventHandler< + winrt::Microsoft::ReactNative::Composition::Experimental::IInteractingStateEnteredArgs> const + &handler) noexcept { + return m_interactingStateEnteredEvent.add(handler); + } + void ScrollPositionChanged(winrt::event_token const &token) noexcept { m_scrollPositionChangedEvent.remove(token); } @@ -1101,6 +1146,10 @@ struct CompScrollerVisual : winrt::implements< m_scrollMomentumEndEvent.remove(token); } + void InteractingStateEntered(winrt::event_token const &token) noexcept { + m_interactingStateEnteredEvent.remove(token); + } + void ContentSize(winrt::Windows::Foundation::Numerics::float2 const &size) noexcept { bool sizeChanged = (m_contentSize.x != size.x || m_contentSize.y != size.y); m_contentSize = size; @@ -1191,6 +1240,10 @@ struct CompScrollerVisual : winrt::implements< m_scrollMomentumEndEvent(*this, winrt::make(position)); } + void FireInteractingStateEntered(int32_t pointerId) noexcept { + m_interactingStateEnteredEvent(*this, winrt::make(pointerId)); + } + void UpdateMaxPosition() noexcept { m_interactionTracker.MaxPosition( {std::max(m_contentSize.x - m_visualSize.x, 0), @@ -1443,6 +1496,15 @@ struct CompScrollerVisual : winrt::implements< winrt::event> m_scrollMomentumEndEvent; + winrt::event> + m_interactingStateEnteredEvent; + // PointerId of the touch that was passed to TryRedirectForManipulation. Set in + // OnPointerPressed for touch pointers; cleared in InteractingStateEntered (after + // surfacing it) and defensively in IdleStateEntered. -1 means "no redirect + // pending" (e.g. mouse, programmatic scroll, or the redirect already produced + // an InteractingStateEntered). + int32_t m_redirectedPointerId{-1}; typename TTypeRedirects::SpriteVisual m_visual{nullptr}; typename TTypeRedirects::SpriteVisual m_contentVisual{nullptr}; typename TTypeRedirects::InteractionTracker m_interactionTracker{nullptr}; diff --git a/vnext/Microsoft.ReactNative/Fabric/Composition/CompositionEventHandler.cpp b/vnext/Microsoft.ReactNative/Fabric/Composition/CompositionEventHandler.cpp index 73e5b6bc17c..9f77b929d45 100644 --- a/vnext/Microsoft.ReactNative/Fabric/Composition/CompositionEventHandler.cpp +++ b/vnext/Microsoft.ReactNative/Fabric/Composition/CompositionEventHandler.cpp @@ -1117,24 +1117,58 @@ void CompositionEventHandler::onPointerCaptureLost( m_pointerCapturingComponentTag = -1; } - // Also cancel any active touch for the specific pointer that lost capture, even - // when no JS-level CapturePointer was ever issued. This handles ScrollView (and - // any other VisualInteractionSource) calling TryRedirectForManipulation: the OS - // reassigns the pointer to the InteractionTracker, fires PointerCaptureLost, and - // then stops delivering PointerMoved/PointerReleased to us. Without this cleanup - // m_activeTouches keeps a zombie entry whose target is the originally-pressed - // Pressable, leaving it visually pressed and causing later taps to be attributed - // to that original target. If the entry was already cleared above (for a JS-level - // capture) or by onPointerReleased running first, the find() is a no-op. - PointerId pointerId = pointerPoint.PointerId(); + // Defense-in-depth cleanup for the specific pointer that lost capture, even + // when no JS-level CapturePointer was ever issued. WinAppSDK does NOT + // reliably fire PointerCaptureLost for pointers redirected via + // VisualInteractionSource::TryRedirectForManipulation (issue #16047), so the + // ScrollView redirect path is handled separately via CancelTouchesForPointer + // wired to IScrollVisual::InteractingStateEntered. This path covers the + // remaining system-driven losses (focus change, another window stealing + // input, system back gesture, etc.). + CancelActiveTouchForPointerInternal(pointerPoint.PointerId(), pointerPoint, keyModifiers); +} + +bool CompositionEventHandler::CancelActiveTouchForPointerInternal( + PointerId pointerId, + const winrt::Microsoft::ReactNative::Composition::Input::PointerPoint &pointerPoint, + winrt::Windows::System::VirtualKeyModifiers keyModifiers) noexcept { auto activeTouch = m_activeTouches.find(pointerId); - if (activeTouch != m_activeTouches.end()) { - ActiveTouch cancelledTouchCopy = std::move(activeTouch->second); - m_activeTouches.erase(activeTouch); - if (cancelledTouchCopy.eventEmitter) { - DispatchSynthesizedTouchCancelForActiveTouch(cancelledTouchCopy, pointerPoint, keyModifiers); - } + if (activeTouch == m_activeTouches.end()) { + return false; } + + ActiveTouch cancelledTouchCopy = std::move(activeTouch->second); + m_activeTouches.erase(activeTouch); + + if (!cancelledTouchCopy.eventEmitter) { + return false; + } + + // Prefer the live PointerPoint passed in by the caller; fall back to the + // last one observed for this active touch (set in onPointerPressed and + // refreshed in onPointerMoved). The cached point is needed when the caller + // doesn't have access to a PointerPoint, e.g. CancelTouchesForPointer + // invoked from the ScrollView InteractionTracker callback. + auto pointForDispatch = pointerPoint; + if (!pointForDispatch) { + pointForDispatch = cancelledTouchCopy.lastPointerPoint; + } + if (!pointForDispatch) { + return false; + } + + DispatchSynthesizedTouchCancelForActiveTouch(cancelledTouchCopy, pointForDispatch, keyModifiers); + return true; +} + +bool CompositionEventHandler::CancelTouchesForPointer(PointerId pointerId) noexcept { + // Issue #16047: ScrollView's InteractionTracker calls this when it claims a + // pointer for manipulation. Pass a null PointerPoint — the internal helper + // will fall back to the cached lastPointerPoint from the ActiveTouch. + return CancelActiveTouchForPointerInternal( + pointerId, + winrt::Microsoft::ReactNative::Composition::Input::PointerPoint{nullptr}, + winrt::Windows::System::VirtualKeyModifiers::None); } void CompositionEventHandler::onPointerMoved( @@ -1192,6 +1226,11 @@ void CompositionEventHandler::onPointerMoved( if (isActiveTouch) { // For active touches with responders, also dispatch through touch event system UpdateActiveTouch(activeTouch->second, ptScaled, ptLocal); + // Cache the latest PointerPoint so CancelActiveTouchForPointerInternal + // can synthesize a touch-cancel event with realistic coordinates when + // it's invoked from a path that doesn't carry its own PointerPoint + // (e.g. ScrollView's InteractionTracker callback for issue #16047). + activeTouch->second.lastPointerPoint = pointerPoint; DispatchTouchEvent(TouchEventType::Move, pointerId, pointerPoint, keyModifiers); } } @@ -1384,6 +1423,10 @@ void CompositionEventHandler::onPointerPressed( } UpdateActiveTouch(activeTouch, ptScaled, ptLocal); + // Cache the latest PointerPoint so CancelActiveTouchForPointerInternal + // can synthesize a touch-cancel with realistic coordinates when invoked + // from a path lacking its own PointerPoint (issue #16047 ScrollView path). + activeTouch.lastPointerPoint = pointerPoint; activeTouch.isPrimary = pointerPoint.Properties().IsPrimary(); // Map the Windows pointer ID to a small identifier (0–19) safe for use as a JS array index. diff --git a/vnext/Microsoft.ReactNative/Fabric/Composition/CompositionEventHandler.h b/vnext/Microsoft.ReactNative/Fabric/Composition/CompositionEventHandler.h index 4c7ee251b9a..db0836c2286 100644 --- a/vnext/Microsoft.ReactNative/Fabric/Composition/CompositionEventHandler.h +++ b/vnext/Microsoft.ReactNative/Fabric/Composition/CompositionEventHandler.h @@ -47,6 +47,15 @@ class CompositionEventHandler : public std::enable_shared_from_this GetTouchableViewsInPathToRoot( const winrt::Microsoft::ReactNative::ComponentView &componentView); diff --git a/vnext/Microsoft.ReactNative/Fabric/Composition/ReactNativeIsland.cpp b/vnext/Microsoft.ReactNative/Fabric/Composition/ReactNativeIsland.cpp index b60f47ef5a3..4c44b420e74 100644 --- a/vnext/Microsoft.ReactNative/Fabric/Composition/ReactNativeIsland.cpp +++ b/vnext/Microsoft.ReactNative/Fabric/Composition/ReactNativeIsland.cpp @@ -470,6 +470,12 @@ void ReactNativeIsland::ReleasePointerCapture( } } +void ReactNativeIsland::CancelTouchesForPointer(int32_t pointerId) noexcept { + if (m_CompositionEventHandler) { + m_CompositionEventHandler->CancelTouchesForPointer(static_cast<::Microsoft::ReactNative::PointerId>(pointerId)); + } +} + void ReactNativeIsland::InitRootView( winrt::Microsoft::ReactNative::IReactContext &&context, winrt::Microsoft::ReactNative::ReactViewOptions &&viewOptions) noexcept { diff --git a/vnext/Microsoft.ReactNative/Fabric/Composition/ReactNativeIsland.h b/vnext/Microsoft.ReactNative/Fabric/Composition/ReactNativeIsland.h index fc14c896fe0..ae7c128166e 100644 --- a/vnext/Microsoft.ReactNative/Fabric/Composition/ReactNativeIsland.h +++ b/vnext/Microsoft.ReactNative/Fabric/Composition/ReactNativeIsland.h @@ -135,6 +135,11 @@ struct ReactNativeIsland const winrt::Microsoft::ReactNative::Composition::Input::Pointer &pointer, facebook::react::Tag tag) noexcept; + // Cancel any active touch for the given pointerId. Forwarded by ScrollView when + // the underlying InteractionTracker claims a touch for scrolling — see + // CompositionEventHandler::CancelTouchesForPointer for the rationale (issue #16047). + void CancelTouchesForPointer(int32_t pointerId) noexcept; + void AddFragmentCompositionEventHandler( winrt::Microsoft::ReactNative::IReactContext context, winrt::Microsoft::ReactNative::ComponentView componentView) noexcept; diff --git a/vnext/Microsoft.ReactNative/Fabric/Composition/ScrollViewComponentView.cpp b/vnext/Microsoft.ReactNative/Fabric/Composition/ScrollViewComponentView.cpp index d88fff312bb..cd169061018 100644 --- a/vnext/Microsoft.ReactNative/Fabric/Composition/ScrollViewComponentView.cpp +++ b/vnext/Microsoft.ReactNative/Fabric/Composition/ScrollViewComponentView.cpp @@ -23,6 +23,7 @@ #include #include "ContentIslandComponentView.h" #include "JSValueReader.h" +#include "ReactNativeIsland.h" #include "RootComponentView.h" namespace winrt::Microsoft::ReactNative::Composition::implementation { @@ -849,13 +850,28 @@ void ScrollViewComponentView::updateStateWithContentOffset() noexcept { return; } - auto scrollPosition = m_scrollVisual.ScrollPosition(); - m_verticalScrollbarComponent->ContentOffset(scrollPosition); - m_horizontalScrollbarComponent->ContentOffset(scrollPosition); - - m_state->updateState([scrollPosition](const facebook::react::ScrollViewShadowNode::ConcreteState::Data &data) { + // Issue #16047: m_scrollVisual.ScrollPosition() returns the InteractionTracker + // position in PHYSICAL pixels (the visual is sized as + // layoutMetrics.frame.size.* * pointScaleFactor — see updateLayoutMetrics / + // updateContentVisualSize) but ScrollViewShadowNode state's contentOffset is + // in DIPs. Without the conversion, JS UIManager.measure() over-subtracts by + // pointScaleFactor on non-100% display scales, leaving Pressables inside a + // scrolled ScrollView with stale page-space bounds that don't contain the + // touch — Pressability fires LEAVE_PRESS_RECT inside pressIn and suppresses + // press. The JS-event-emitter paths in this file (see lines using + // args.Position() / pointScaleFactor) already do this division. + auto rawScrollPosition = m_scrollVisual.ScrollPosition(); + const float pointScaleFactor = + m_layoutMetrics.pointScaleFactor > 0.0f ? m_layoutMetrics.pointScaleFactor : 1.0f; + facebook::react::Point contentOffsetDips{ + rawScrollPosition.x / pointScaleFactor, rawScrollPosition.y / pointScaleFactor}; + + m_verticalScrollbarComponent->ContentOffset(rawScrollPosition); + m_horizontalScrollbarComponent->ContentOffset(rawScrollPosition); + + m_state->updateState([contentOffsetDips](const facebook::react::ScrollViewShadowNode::ConcreteState::Data &data) { auto newData = data; - newData.contentOffset = {scrollPosition.x, scrollPosition.y}; + newData.contentOffset = contentOffsetDips; return std::make_shared(newData); }); } @@ -1389,6 +1405,13 @@ winrt::Microsoft::ReactNative::Composition::Experimental::IVisual ScrollViewComp [this]( winrt::IInspectable const & /*sender*/, winrt::Microsoft::ReactNative::Composition::Experimental::IScrollPositionChangedArgs const &args) { + // Issue #16047: push the FINAL settled scroll position into Fabric's + // shadow tree before notifying JS. The per-frame ScrollPositionChanged + // updates can drop the last inertia delta, leaving contentOffset stale + // and JS UIManager.measure() returning pre-settle-relative bounds. + // ScrollEndDrag / ScrollBeginDrag already call this; momentum-end was + // the missing completion path. + updateStateWithContentOffset(); auto eventEmitter = GetEventEmitter(); if (eventEmitter) { auto scrollMetrics = getScrollMetrics(eventEmitter, args); @@ -1397,6 +1420,30 @@ winrt::Microsoft::ReactNative::Composition::Experimental::IVisual ScrollViewComp } }); + // Issue #16047: when the InteractionTracker claims a touch for scrolling, the + // OS stops delivering further pointer events for that PointerId — including + // PointerCaptureLost and PointerReleased — so RN's m_activeTouches keeps a + // zombie entry and the originally-pressed Pressable never receives an + // onPressOut. Synthesize a touch-cancel ourselves the moment the tracker + // takes over, before the user lifts their finger. + m_scrollInteractingStateEnteredRevoker = m_scrollVisual.InteractingStateEntered( + winrt::auto_revoke, + [this]( + winrt::IInspectable const & /*sender*/, + winrt::Microsoft::ReactNative::Composition::Experimental::IInteractingStateEnteredArgs const &args) { + const int32_t pointerId = args.PointerId(); + auto root = rootComponentView(); + if (!root) { + return; + } + auto rootView = root->ReactNativeIsland(); + if (!rootView) { + return; + } + winrt::get_self(rootView) + ->CancelTouchesForPointer(pointerId); + }); + return visual; } diff --git a/vnext/Microsoft.ReactNative/Fabric/Composition/ScrollViewComponentView.h b/vnext/Microsoft.ReactNative/Fabric/Composition/ScrollViewComponentView.h index d0ecc5ca755..db179c090c4 100644 --- a/vnext/Microsoft.ReactNative/Fabric/Composition/ScrollViewComponentView.h +++ b/vnext/Microsoft.ReactNative/Fabric/Composition/ScrollViewComponentView.h @@ -163,6 +163,8 @@ struct ScrollInteractionTrackerOwner : public winrt::implements< m_scrollMomentumBeginRevoker{}; winrt::Microsoft::ReactNative::Composition::Experimental::IScrollVisual::ScrollMomentumEnd_revoker m_scrollMomentumEndRevoker{}; + winrt::Microsoft::ReactNative::Composition::Experimental::IScrollVisual::InteractingStateEntered_revoker + m_scrollInteractingStateEnteredRevoker{}; float m_zoomFactor{1.0f}; bool m_isScrollingFromInertia = false; From 20f5a6b4120e3c98eca215c8d5054cbb90c0e1f3 Mon Sep 17 00:00:00 2001 From: Gordon MacMaster <31481849+gmacmaster@users.noreply.github.com> Date: Wed, 13 May 2026 11:49:04 -0400 Subject: [PATCH 2/3] Refactor #16047 Fix A to use InputPointerSource.PointerRoutedAway --- ...-d89877b5-21f1-487a-b114-6048bf2284ae.json | 2 +- .../TestCompositionContext.h | 5 -- .../CompositionSwitcher.idl | 12 --- .../Composition/CompositionContextHelper.cpp | 62 -------------- .../Composition/CompositionEventHandler.cpp | 85 ++++++++++--------- .../Composition/CompositionEventHandler.h | 25 ++---- .../Fabric/Composition/ReactNativeIsland.cpp | 6 -- .../Fabric/Composition/ReactNativeIsland.h | 5 -- .../Composition/ScrollViewComponentView.cpp | 24 ------ .../Composition/ScrollViewComponentView.h | 2 - 10 files changed, 51 insertions(+), 177 deletions(-) diff --git a/change/react-native-windows-d89877b5-21f1-487a-b114-6048bf2284ae.json b/change/react-native-windows-d89877b5-21f1-487a-b114-6048bf2284ae.json index 138559ebb8c..ff435f638cd 100644 --- a/change/react-native-windows-d89877b5-21f1-487a-b114-6048bf2284ae.json +++ b/change/react-native-windows-d89877b5-21f1-487a-b114-6048bf2284ae.json @@ -1,6 +1,6 @@ { "type": "prerelease", - "comment": "Fix #16047: Pressables inside ScrollView remained stuck in the pressed state after a touch-driven scroll, and on non-100% Windows display scales the next tap on a row would not register `press`. Two underlying causes were addressed: (1) VisualInteractionSource::TryRedirectForManipulation does not reliably fire PointerCaptureLost for the redirected pointer, leaving a zombie entry in CompositionEventHandler::m_activeTouches — now resolved by synthesizing a touchcancel from the InteractionTracker's InteractingStateEntered callback; and (2) ScrollViewComponentView::updateStateWithContentOffset wrote the raw physical-pixel ScrollPosition into ScrollViewShadowNode state's contentOffset, which Fabric layout treats as DIPs, so JS UIManager.measure() over-subtracted the offset by pointScaleFactor after any scroll on a >100% display, causing Pressability to fire LEAVE_PRESS_RECT synchronously and suppress press — now divides by pointScaleFactor to match the JS event-emitter paths in the same file.", + "comment": "Fix #16047: Pressables inside ScrollView remained stuck in the pressed state after a touch-driven scroll, and on non-100% Windows display scales the next tap on a row would not register `press`. Two underlying causes were addressed: (1) VisualInteractionSource::TryRedirectForManipulation does not deliver PointerCaptureLost for the redirected pointer, leaving a zombie entry in CompositionEventHandler::m_activeTouches — now resolved by synthesizing a touchcancel from the InputPointerSource.PointerRoutedAway event, which fires reliably on the redirect path; and (2) ScrollViewComponentView::updateStateWithContentOffset wrote the raw physical-pixel ScrollPosition into ScrollViewShadowNode state's contentOffset, which Fabric layout treats as DIPs, so JS UIManager.measure() over-subtracted the offset by pointScaleFactor after any scroll on a >100% display, causing Pressability to fire LEAVE_PRESS_RECT synchronously and suppress press — now divides by pointScaleFactor to match the JS event-emitter paths in the same file.", "packageName": "react-native-windows", "email": "gordomacmaster@gmail.com", "dependentChangeType": "patch" diff --git a/vnext/Desktop.IntegrationTests/TestCompositionContext.h b/vnext/Desktop.IntegrationTests/TestCompositionContext.h index 827139b8a43..134b1a61c81 100644 --- a/vnext/Desktop.IntegrationTests/TestCompositionContext.h +++ b/vnext/Desktop.IntegrationTests/TestCompositionContext.h @@ -117,11 +117,6 @@ struct TestScrollVisual : TestVisualBase const &) { - return {}; - } - void InteractingStateEntered(winrt::event_token) {} void ContentSize(winrt::Windows::Foundation::Numerics::float2) {} winrt::Windows::Foundation::Numerics::float3 ScrollPosition() { return {}; diff --git a/vnext/Microsoft.ReactNative/CompositionSwitcher.idl b/vnext/Microsoft.ReactNative/CompositionSwitcher.idl index 70940f8cbce..1896b0dc357 100644 --- a/vnext/Microsoft.ReactNative/CompositionSwitcher.idl +++ b/vnext/Microsoft.ReactNative/CompositionSwitcher.idl @@ -101,17 +101,6 @@ enum SnapPointsAlignment { }; } -// Fired when the underlying InteractionTracker claims a pointer for scrolling -// (TryRedirectForManipulation succeeded and the user moved enough). Carries the -// PointerId of the touch that was redirected so the caller can cancel any active -// touch RN is tracking for it. The OS does not reliably deliver -// PointerCaptureLost / PointerReleased after redirect (see issue #16047). -[webhosthidden][experimental] interface IInteractingStateEnteredArgs { - Int32 PointerId { - get; - }; -} - [webhosthidden][experimental] interface IScrollVisual requires IVisual { @@ -122,7 +111,6 @@ enum SnapPointsAlignment { event Windows.Foundation.EventHandler ScrollEndDrag; event Windows.Foundation.EventHandler ScrollMomentumBegin; event Windows.Foundation.EventHandler ScrollMomentumEnd; - event Windows.Foundation.EventHandler InteractingStateEntered; void ContentSize(Windows.Foundation.Numerics.Vector2 size); Windows.Foundation.Numerics.Vector3 ScrollPosition { get; diff --git a/vnext/Microsoft.ReactNative/Fabric/Composition/CompositionContextHelper.cpp b/vnext/Microsoft.ReactNative/Fabric/Composition/CompositionContextHelper.cpp index a4253d2f494..46def20b57d 100644 --- a/vnext/Microsoft.ReactNative/Fabric/Composition/CompositionContextHelper.cpp +++ b/vnext/Microsoft.ReactNative/Fabric/Composition/CompositionContextHelper.cpp @@ -703,20 +703,6 @@ struct CompScrollPositionChangedArgs winrt::Windows::Foundation::Numerics::float2 m_position; }; -struct CompInteractingStateEnteredArgs - : winrt::implements< - CompInteractingStateEnteredArgs, - winrt::Microsoft::ReactNative::Composition::Experimental::IInteractingStateEnteredArgs> { - CompInteractingStateEnteredArgs(int32_t pointerId) : m_pointerId(pointerId) {} - - int32_t PointerId() const noexcept { - return m_pointerId; - } - - private: - int32_t m_pointerId; -}; - template struct CompScrollerVisual : winrt::implements< CompScrollerVisual, @@ -754,10 +740,6 @@ struct CompScrollerVisual : winrt::implements< m_outer->m_custom = false; m_outer->m_inertia = false; m_outer->m_interacting = false; - // Defensive: if InteractingStateEntered never fired (tap that didn't move - // far enough to claim the gesture), the redirected pointerId would leak - // forward and be wrongly cancelled on the *next* press's interaction. - m_outer->m_redirectedPointerId = -1; } void InertiaStateEntered( typename TTypeRedirects::InteractionTracker sender, @@ -785,17 +767,6 @@ struct CompScrollerVisual : winrt::implements< // Mark that we're now interacting and remember the requestId (user manipulations => 0) m_outer->m_interacting = true; - // Surface the redirected pointerId so RN can synthesize a touch-cancel. - // Only fire for user-driven manipulations (requestId == 0); programmatic - // Try* calls don't have an originating pointer. - if (args.RequestId() == 0 && m_outer->m_redirectedPointerId != -1) { - const int32_t pointerIdToCancel = m_outer->m_redirectedPointerId; - // Clear before firing so re-entrant callers (e.g. another touch arriving - // synchronously inside the cancel) don't double-cancel the same id. - m_outer->m_redirectedPointerId = -1; - m_outer->FireInteractingStateEntered(pointerIdToCancel); - } - // Fire when the user starts dragging the object m_outer->FireScrollBeginDrag({sender.Position().x, sender.Position().y}); } @@ -854,15 +825,6 @@ struct CompScrollerVisual : winrt::implements< if constexpr (std::is_same_v) { auto pointerDeviceType = args.Pointer().PointerDeviceType(); if (pointerDeviceType == winrt::Microsoft::ReactNative::Composition::Input::PointerDeviceType::Touch) { - // Issue #16047: remember which pointerId we asked the InteractionTracker - // to manipulate. If the tracker actually claims the gesture - // (InteractingStateEntered), we'll surface this id so RN can synthesize a - // touch-cancel — the OS does not reliably deliver PointerCaptureLost or - // PointerReleased for the redirected pointer. Multi-finger panning would - // clobber this single-slot id; the common case is one finger, and the - // worst-case for multi-touch matches the pre-fix behavior (the second - // touch's cancel would still be missed). - m_redirectedPointerId = static_cast(args.Pointer().PointerId()); m_visualInteractionSource.TryRedirectForManipulation(args.GetCurrentPoint(args.OriginalSource()).Inner()); } } @@ -1119,13 +1081,6 @@ struct CompScrollerVisual : winrt::implements< return m_scrollMomentumEndEvent.add(handler); } - winrt::event_token InteractingStateEntered( - winrt::Windows::Foundation::EventHandler< - winrt::Microsoft::ReactNative::Composition::Experimental::IInteractingStateEnteredArgs> const - &handler) noexcept { - return m_interactingStateEnteredEvent.add(handler); - } - void ScrollPositionChanged(winrt::event_token const &token) noexcept { m_scrollPositionChangedEvent.remove(token); } @@ -1146,10 +1101,6 @@ struct CompScrollerVisual : winrt::implements< m_scrollMomentumEndEvent.remove(token); } - void InteractingStateEntered(winrt::event_token const &token) noexcept { - m_interactingStateEnteredEvent.remove(token); - } - void ContentSize(winrt::Windows::Foundation::Numerics::float2 const &size) noexcept { bool sizeChanged = (m_contentSize.x != size.x || m_contentSize.y != size.y); m_contentSize = size; @@ -1240,10 +1191,6 @@ struct CompScrollerVisual : winrt::implements< m_scrollMomentumEndEvent(*this, winrt::make(position)); } - void FireInteractingStateEntered(int32_t pointerId) noexcept { - m_interactingStateEnteredEvent(*this, winrt::make(pointerId)); - } - void UpdateMaxPosition() noexcept { m_interactionTracker.MaxPosition( {std::max(m_contentSize.x - m_visualSize.x, 0), @@ -1496,15 +1443,6 @@ struct CompScrollerVisual : winrt::implements< winrt::event> m_scrollMomentumEndEvent; - winrt::event> - m_interactingStateEnteredEvent; - // PointerId of the touch that was passed to TryRedirectForManipulation. Set in - // OnPointerPressed for touch pointers; cleared in InteractingStateEntered (after - // surfacing it) and defensively in IdleStateEntered. -1 means "no redirect - // pending" (e.g. mouse, programmatic scroll, or the redirect already produced - // an InteractingStateEntered). - int32_t m_redirectedPointerId{-1}; typename TTypeRedirects::SpriteVisual m_visual{nullptr}; typename TTypeRedirects::SpriteVisual m_contentVisual{nullptr}; typename TTypeRedirects::InteractionTracker m_interactionTracker{nullptr}; diff --git a/vnext/Microsoft.ReactNative/Fabric/Composition/CompositionEventHandler.cpp b/vnext/Microsoft.ReactNative/Fabric/Composition/CompositionEventHandler.cpp index 9f77b929d45..eddd7bf0df9 100644 --- a/vnext/Microsoft.ReactNative/Fabric/Composition/CompositionEventHandler.cpp +++ b/vnext/Microsoft.ReactNative/Fabric/Composition/CompositionEventHandler.cpp @@ -224,6 +224,28 @@ void CompositionEventHandler::Initialize() noexcept { } }); + // Issue #16047: when ScrollView calls VisualInteractionSource::TryRedirectForManipulation + // and the OS hands the pointer over to the InteractionTracker, WinAppSDK + // does not fire PointerCaptureLost on this source — but it does fire + // PointerRoutedAway. Treat it the same way as captureloss: cancel any + // active touch RN is tracking for this pointer so Pressables don't get + // stuck in their pressed state. + m_pointerRoutedAwayToken = pointerSource.PointerRoutedAway( + [wkThis = weak_from_this()]( + winrt::Microsoft::UI::Input::InputPointerSource const &, + winrt::Microsoft::UI::Input::PointerEventArgs const &args) { + if (auto strongThis = wkThis.lock()) { + if (auto strongRootView = strongThis->m_wkRootView.get()) { + if (strongThis->SurfaceId() == -1) + return; + + auto pp = winrt::make( + args.CurrentPoint(), strongRootView.ScaleFactor()); + strongThis->onPointerRoutedAway(pp, args.KeyModifiers()); + } + } + }); + m_pointerWheelChangedToken = pointerSource.PointerWheelChanged([wkThis = weak_from_this()]( winrt::Microsoft::UI::Input::InputPointerSource const &, @@ -369,6 +391,7 @@ CompositionEventHandler::~CompositionEventHandler() { pointerSource.PointerReleased(m_pointerReleasedToken); pointerSource.PointerMoved(m_pointerMovedToken); pointerSource.PointerCaptureLost(m_pointerCaptureLostToken); + pointerSource.PointerRoutedAway(m_pointerRoutedAwayToken); pointerSource.PointerWheelChanged(m_pointerWheelChangedToken); pointerSource.PointerExited(m_pointerExitedToken); auto keyboardSource = winrt::Microsoft::UI::Input::InputKeyboardSource::GetForIsland(island); @@ -1118,13 +1141,27 @@ void CompositionEventHandler::onPointerCaptureLost( } // Defense-in-depth cleanup for the specific pointer that lost capture, even - // when no JS-level CapturePointer was ever issued. WinAppSDK does NOT - // reliably fire PointerCaptureLost for pointers redirected via - // VisualInteractionSource::TryRedirectForManipulation (issue #16047), so the - // ScrollView redirect path is handled separately via CancelTouchesForPointer - // wired to IScrollVisual::InteractingStateEntered. This path covers the - // remaining system-driven losses (focus change, another window stealing - // input, system back gesture, etc.). + // when no JS-level CapturePointer was ever issued. The ScrollView + // TryRedirectForManipulation path comes in via PointerRoutedAway, not + // PointerCaptureLost (see onPointerRoutedAway and issue #16047), so this + // path covers the remaining system-driven losses (focus change, another + // window stealing input, system back gesture, etc.). + CancelActiveTouchForPointerInternal(pointerPoint.PointerId(), pointerPoint, keyModifiers); +} + +void CompositionEventHandler::onPointerRoutedAway( + const winrt::Microsoft::ReactNative::Composition::Input::PointerPoint &pointerPoint, + winrt::Windows::System::VirtualKeyModifiers keyModifiers) noexcept { + if (SurfaceId() == -1) + return; + + // Issue #16047: WinAppSDK fires PointerRoutedAway when the OS hands the + // pointer to another InputPointerSource — most importantly for us, when + // ScrollView calls VisualInteractionSource::TryRedirectForManipulation and + // the InteractionTracker takes the gesture for scrolling. We never get + // PointerMoved / PointerReleased / PointerCaptureLost for that pointer + // afterwards, so without this cleanup m_activeTouches keeps a zombie entry + // and the originally-pressed Pressable stays stuck in its pressed state. CancelActiveTouchForPointerInternal(pointerPoint.PointerId(), pointerPoint, keyModifiers); } @@ -1144,33 +1181,10 @@ bool CompositionEventHandler::CancelActiveTouchForPointerInternal( return false; } - // Prefer the live PointerPoint passed in by the caller; fall back to the - // last one observed for this active touch (set in onPointerPressed and - // refreshed in onPointerMoved). The cached point is needed when the caller - // doesn't have access to a PointerPoint, e.g. CancelTouchesForPointer - // invoked from the ScrollView InteractionTracker callback. - auto pointForDispatch = pointerPoint; - if (!pointForDispatch) { - pointForDispatch = cancelledTouchCopy.lastPointerPoint; - } - if (!pointForDispatch) { - return false; - } - - DispatchSynthesizedTouchCancelForActiveTouch(cancelledTouchCopy, pointForDispatch, keyModifiers); + DispatchSynthesizedTouchCancelForActiveTouch(cancelledTouchCopy, pointerPoint, keyModifiers); return true; } -bool CompositionEventHandler::CancelTouchesForPointer(PointerId pointerId) noexcept { - // Issue #16047: ScrollView's InteractionTracker calls this when it claims a - // pointer for manipulation. Pass a null PointerPoint — the internal helper - // will fall back to the cached lastPointerPoint from the ActiveTouch. - return CancelActiveTouchForPointerInternal( - pointerId, - winrt::Microsoft::ReactNative::Composition::Input::PointerPoint{nullptr}, - winrt::Windows::System::VirtualKeyModifiers::None); -} - void CompositionEventHandler::onPointerMoved( const winrt::Microsoft::ReactNative::Composition::Input::PointerPoint &pointerPoint, winrt::Windows::System::VirtualKeyModifiers keyModifiers) noexcept { @@ -1226,11 +1240,6 @@ void CompositionEventHandler::onPointerMoved( if (isActiveTouch) { // For active touches with responders, also dispatch through touch event system UpdateActiveTouch(activeTouch->second, ptScaled, ptLocal); - // Cache the latest PointerPoint so CancelActiveTouchForPointerInternal - // can synthesize a touch-cancel event with realistic coordinates when - // it's invoked from a path that doesn't carry its own PointerPoint - // (e.g. ScrollView's InteractionTracker callback for issue #16047). - activeTouch->second.lastPointerPoint = pointerPoint; DispatchTouchEvent(TouchEventType::Move, pointerId, pointerPoint, keyModifiers); } } @@ -1423,10 +1432,6 @@ void CompositionEventHandler::onPointerPressed( } UpdateActiveTouch(activeTouch, ptScaled, ptLocal); - // Cache the latest PointerPoint so CancelActiveTouchForPointerInternal - // can synthesize a touch-cancel with realistic coordinates when invoked - // from a path lacking its own PointerPoint (issue #16047 ScrollView path). - activeTouch.lastPointerPoint = pointerPoint; activeTouch.isPrimary = pointerPoint.Properties().IsPrimary(); // Map the Windows pointer ID to a small identifier (0–19) safe for use as a JS array index. diff --git a/vnext/Microsoft.ReactNative/Fabric/Composition/CompositionEventHandler.h b/vnext/Microsoft.ReactNative/Fabric/Composition/CompositionEventHandler.h index db0836c2286..e252ab57306 100644 --- a/vnext/Microsoft.ReactNative/Fabric/Composition/CompositionEventHandler.h +++ b/vnext/Microsoft.ReactNative/Fabric/Composition/CompositionEventHandler.h @@ -47,15 +47,6 @@ class CompositionEventHandler : public std::enable_shared_from_thisCancelTouchesForPointer(static_cast<::Microsoft::ReactNative::PointerId>(pointerId)); - } -} - void ReactNativeIsland::InitRootView( winrt::Microsoft::ReactNative::IReactContext &&context, winrt::Microsoft::ReactNative::ReactViewOptions &&viewOptions) noexcept { diff --git a/vnext/Microsoft.ReactNative/Fabric/Composition/ReactNativeIsland.h b/vnext/Microsoft.ReactNative/Fabric/Composition/ReactNativeIsland.h index ae7c128166e..fc14c896fe0 100644 --- a/vnext/Microsoft.ReactNative/Fabric/Composition/ReactNativeIsland.h +++ b/vnext/Microsoft.ReactNative/Fabric/Composition/ReactNativeIsland.h @@ -135,11 +135,6 @@ struct ReactNativeIsland const winrt::Microsoft::ReactNative::Composition::Input::Pointer &pointer, facebook::react::Tag tag) noexcept; - // Cancel any active touch for the given pointerId. Forwarded by ScrollView when - // the underlying InteractionTracker claims a touch for scrolling — see - // CompositionEventHandler::CancelTouchesForPointer for the rationale (issue #16047). - void CancelTouchesForPointer(int32_t pointerId) noexcept; - void AddFragmentCompositionEventHandler( winrt::Microsoft::ReactNative::IReactContext context, winrt::Microsoft::ReactNative::ComponentView componentView) noexcept; diff --git a/vnext/Microsoft.ReactNative/Fabric/Composition/ScrollViewComponentView.cpp b/vnext/Microsoft.ReactNative/Fabric/Composition/ScrollViewComponentView.cpp index cd169061018..b6f9c1637a5 100644 --- a/vnext/Microsoft.ReactNative/Fabric/Composition/ScrollViewComponentView.cpp +++ b/vnext/Microsoft.ReactNative/Fabric/Composition/ScrollViewComponentView.cpp @@ -1420,30 +1420,6 @@ winrt::Microsoft::ReactNative::Composition::Experimental::IVisual ScrollViewComp } }); - // Issue #16047: when the InteractionTracker claims a touch for scrolling, the - // OS stops delivering further pointer events for that PointerId — including - // PointerCaptureLost and PointerReleased — so RN's m_activeTouches keeps a - // zombie entry and the originally-pressed Pressable never receives an - // onPressOut. Synthesize a touch-cancel ourselves the moment the tracker - // takes over, before the user lifts their finger. - m_scrollInteractingStateEnteredRevoker = m_scrollVisual.InteractingStateEntered( - winrt::auto_revoke, - [this]( - winrt::IInspectable const & /*sender*/, - winrt::Microsoft::ReactNative::Composition::Experimental::IInteractingStateEnteredArgs const &args) { - const int32_t pointerId = args.PointerId(); - auto root = rootComponentView(); - if (!root) { - return; - } - auto rootView = root->ReactNativeIsland(); - if (!rootView) { - return; - } - winrt::get_self(rootView) - ->CancelTouchesForPointer(pointerId); - }); - return visual; } diff --git a/vnext/Microsoft.ReactNative/Fabric/Composition/ScrollViewComponentView.h b/vnext/Microsoft.ReactNative/Fabric/Composition/ScrollViewComponentView.h index db179c090c4..d0ecc5ca755 100644 --- a/vnext/Microsoft.ReactNative/Fabric/Composition/ScrollViewComponentView.h +++ b/vnext/Microsoft.ReactNative/Fabric/Composition/ScrollViewComponentView.h @@ -163,8 +163,6 @@ struct ScrollInteractionTrackerOwner : public winrt::implements< m_scrollMomentumBeginRevoker{}; winrt::Microsoft::ReactNative::Composition::Experimental::IScrollVisual::ScrollMomentumEnd_revoker m_scrollMomentumEndRevoker{}; - winrt::Microsoft::ReactNative::Composition::Experimental::IScrollVisual::InteractingStateEntered_revoker - m_scrollInteractingStateEnteredRevoker{}; float m_zoomFactor{1.0f}; bool m_isScrollingFromInertia = false; From f0667ce98c10811df00ea82c5c6df89ac3a407de Mon Sep 17 00:00:00 2001 From: Gordon MacMaster <31481849+gmacmaster@users.noreply.github.com> Date: Wed, 13 May 2026 12:05:16 -0400 Subject: [PATCH 3/3] format --- .../Fabric/Composition/CompositionEventHandler.cpp | 8 ++++---- .../Fabric/Composition/ScrollViewComponentView.cpp | 3 +-- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/vnext/Microsoft.ReactNative/Fabric/Composition/CompositionEventHandler.cpp b/vnext/Microsoft.ReactNative/Fabric/Composition/CompositionEventHandler.cpp index eddd7bf0df9..2f46d6f60de 100644 --- a/vnext/Microsoft.ReactNative/Fabric/Composition/CompositionEventHandler.cpp +++ b/vnext/Microsoft.ReactNative/Fabric/Composition/CompositionEventHandler.cpp @@ -230,10 +230,10 @@ void CompositionEventHandler::Initialize() noexcept { // PointerRoutedAway. Treat it the same way as captureloss: cancel any // active touch RN is tracking for this pointer so Pressables don't get // stuck in their pressed state. - m_pointerRoutedAwayToken = pointerSource.PointerRoutedAway( - [wkThis = weak_from_this()]( - winrt::Microsoft::UI::Input::InputPointerSource const &, - winrt::Microsoft::UI::Input::PointerEventArgs const &args) { + m_pointerRoutedAwayToken = + pointerSource.PointerRoutedAway([wkThis = weak_from_this()]( + winrt::Microsoft::UI::Input::InputPointerSource const &, + winrt::Microsoft::UI::Input::PointerEventArgs const &args) { if (auto strongThis = wkThis.lock()) { if (auto strongRootView = strongThis->m_wkRootView.get()) { if (strongThis->SurfaceId() == -1) diff --git a/vnext/Microsoft.ReactNative/Fabric/Composition/ScrollViewComponentView.cpp b/vnext/Microsoft.ReactNative/Fabric/Composition/ScrollViewComponentView.cpp index b6f9c1637a5..2c58e04c42b 100644 --- a/vnext/Microsoft.ReactNative/Fabric/Composition/ScrollViewComponentView.cpp +++ b/vnext/Microsoft.ReactNative/Fabric/Composition/ScrollViewComponentView.cpp @@ -861,8 +861,7 @@ void ScrollViewComponentView::updateStateWithContentOffset() noexcept { // press. The JS-event-emitter paths in this file (see lines using // args.Position() / pointScaleFactor) already do this division. auto rawScrollPosition = m_scrollVisual.ScrollPosition(); - const float pointScaleFactor = - m_layoutMetrics.pointScaleFactor > 0.0f ? m_layoutMetrics.pointScaleFactor : 1.0f; + const float pointScaleFactor = m_layoutMetrics.pointScaleFactor > 0.0f ? m_layoutMetrics.pointScaleFactor : 1.0f; facebook::react::Point contentOffsetDips{ rawScrollPosition.x / pointScaleFactor, rawScrollPosition.y / pointScaleFactor};