Skip to content

fix(pointer): backport #16140 (PointerRoutedAway + ScrollView contentOffset DIP) to 0.83-stable#16335

Open
FaithfulAudio wants to merge 1 commit into
microsoft:0.83-stablefrom
FacilitronWorks:fix/backport-16140-pointer-routed-away
Open

fix(pointer): backport #16140 (PointerRoutedAway + ScrollView contentOffset DIP) to 0.83-stable#16335
FaithfulAudio wants to merge 1 commit into
microsoft:0.83-stablefrom
FacilitronWorks:fix/backport-16140-pointer-routed-away

Conversation

@FaithfulAudio

@FaithfulAudio FaithfulAudio commented Jul 26, 2026

Copy link
Copy Markdown

[0.83-stable] Backport #16140: ScrollView Pressables stuck / dead tap on high-DPI displays

Backport of #16140 ("Fix ScrollView Pressables stuck and dead tap on high-DPI displays", merged to main 2026-05-13, commit df07be9) onto 0.83-stable. Original fix by @gmacmaster; #16140 was itself the main cherry-pick of #16139 ("[0.84] Fix ScrollView Pressables stay stuck and next tap is dead on high-DPI displays", merged to 0.84-stable). All credit for the diagnosis and the fix belongs to that work — this PR only carries it back to 0.83. Fixes #16047 ("Scrolling Views not working with touch screen devices") on 0.83.

0.83-stable does not contain any of it today — PointerRoutedAway and the pointScaleFactor division in updateStateWithContentOffset both have zero hits at that ref.

Problem

In a Fabric/Composition app on a touch device:

  1. A Pressable/TouchableOpacity inside a ScrollView stays stuck in its pressed state after the finger starts a scroll, and a later tap elsewhere can be attributed to that originally-pressed target.
  2. On a Windows display scale other than 100%, after any scroll the next tap on a row does not fire press at all.

Root cause

Two independent defects, exactly as diagnosed in #16047 / #16140:

(a) m_activeTouches keeps a zombie entry when ScrollView redirects the pointer.
ScrollViewComponentView calls VisualInteractionSource::TryRedirectForManipulation, and the OS reassigns the pointer to the InteractionTracker. WinAppSDK does not raise PointerCaptureLost on our InputPointerSource for that path — it raises PointerRoutedAway, which CompositionEventHandler never subscribed to. After the redirect we get no PointerMoved / PointerReleased / PointerCaptureLost for that pointer, so the active-touch entry is never erased and the pressed Pressable never receives a cancel.

(b) ScrollView writes physical pixels into a DIP-typed state field.
ScrollViewComponentView::updateStateWithContentOffset() stored m_scrollVisual.ScrollPosition() straight into ScrollViewShadowNode::ConcreteState::Data::contentOffset. ScrollPosition() is in physical pixels — the scroll visual is sized layoutMetrics.frame.size.* * pointScaleFactor (see updateLayoutMetrics / updateContentVisualSize) — but contentOffset is consumed as DIPs. So JS UIManager.measure() over-subtracted the offset by pointScaleFactor after any scroll on a >100% scale, and Pressability fired LEAVE_PRESS_RECT synchronously inside pressIn, suppressing press. Every other consumer in the same file already divides by pointScaleFactorgetScrollMetrics(), ScrollInteractionTrackerOwner::ValuesChanged(), and hitTest(); only the state write did not.

Additionally, ScrollMomentumEnd was the one scroll-completion path that did not call updateStateWithContentOffset() (ScrollBeginDrag and ScrollEndDrag both do), so the last inertia delta could leave contentOffset stale even with the unit fix in place.

Fix

Three files, matching #16140:

  • CompositionEventHandler.cpp / .h — subscribe to InputPointerSource.PointerRoutedAway, add onPointerRoutedAway, which cancels and erases the active touch for that pointer; add m_pointerRoutedAwayToken and unsubscribe it in the destructor alongside the other pointer tokens.
  • ScrollViewComponentView.cpp — divide ScrollPosition() by pointScaleFactor (guarded to 1.0f when non-positive) before writing contentOffset. The scrollbar components keep receiving the raw physical-pixel position, which is what they expect.
  • ScrollViewComponentView.cpp — call updateStateWithContentOffset() at the top of the ScrollMomentumEnd handler, before notifying JS.

How this differs from #16140 on main — please read

A literal cherry-pick cannot build on 0.83-stable, because #16140 was written on top of infrastructure that only exists on main. DispatchSynthesizedTouchCancelForActiveTouch and CancelActiveTouchForPointerInternal both have zero occurrences in CompositionEventHandler.{cpp,h} at 0.83-stable, so the merged form of onPointerRoutedAway would not resolve.

I diffed the added-line sets against #16140 to confirm this: the only things that differ are the onPointerRoutedAway body, the main-only helper/onPointerCaptureLost refactor, the ReactNativeIsland.h include, and that one comment line. Everything else — the PointerRoutedAway subscription lambda, the destructor unsubscribe, the header declaration, m_pointerRoutedAwayToken, the pointScaleFactor division block, and the momentum-end call — is byte-for-byte identical to #16140.

Validation

What I have not verified — please weigh these before merging:

Caveats for reviewers:

  • The onPointerCaptureLost divergence above is the thing to check. Because 0.83-stable's onPointerCaptureLost has no active-touch cleanup, the touch-cancel coverage on this branch after merging only this PR is narrower than on main: the ScrollView redirect path is fixed, but other system-driven capture losses (focus change, another window stealing input) still leak. That gap is intentional scope separation, not an oversight.
  • I chose std::find_if over pair.second.touch.identifier rather than main's m_activeTouches.find(pointerId), purely to match the surrounding 0.83 code. The two are equivalent here (the map key is the pointer id); happy to switch to the plain find.
  • If you would rather I mirror Fix ScrollView Pressables stuck and dead tap on high-DPI displays #16140 exactly by also introducing DispatchSynthesizedTouchCancelForActiveTouch + CancelActiveTouchForPointerInternal on 0.83-stable, say so and I will — it just pulls in more of main than a minimal backport needs, and it collides with fix(fabric): touch-input reliability — primary-button labeling, guaranteed touch END/CANCEL, phantom active-touch self-heal #16333.
  • The pointScaleFactor > 0.0f ? ... : 1.0f guard is Fix ScrollView Pressables stuck and dead tap on high-DPI displays #16140's; keeping it avoids a divide-by-zero if updateStateWithContentOffset() ever runs before the first updateLayoutMetrics.
  • The scrollbar components intentionally still get the raw physical-pixel offset — m_verticalScrollbarComponent->ContentOffset(rawScrollPosition) receives exactly the same value the pre-patch code passed, so scrollbar behaviour is unchanged by construction. (The scrollbar helper stores it and multiplies incoming DIP hit-test points by m_scaleFactor, i.e. it works in physical pixels internally.) Only the shadow-node state is converted.
  • Change file included (type: patch). Let me know if you would prefer prerelease to match Fix ScrollView Pressables stuck and dead tap on high-DPI displays #16140's own change file.
Microsoft Reviewers: Open in CodeFlow

…w contentOffset DIP) to 0.83-stable

Backports upstream PR microsoft#16140 by @gmacmaster (merged to main 2026-05-13, itself a
cherry-pick of microsoft#16139 onto 0.84-stable) so that 0.83-stable also gets it.

A literal cherry-pick does not build here, so two adaptations were required and
are documented in the PR description:

- onPointerRoutedAway uses the 0.83-era DispatchTouchEvent(TouchEventType::Cancel, ...)
  plus std::find_if over pair.second.touch.identifier, because microsoft#16140's
  CancelActiveTouchForPointerInternal / DispatchSynthesizedTouchCancelForActiveTouch
  helpers do not exist on this branch.
- microsoft#16140's onPointerCaptureLost refactor is omitted: on main that function already
  had an active-touch cleanup block to refactor; on 0.83-stable it has none.

Everything else - the subscription lambda, the destructor unsubscribe, the header
declaration and token, the pointScaleFactor conversion, and the momentum-end
updateStateWithContentOffset() call - is byte-for-byte identical to microsoft#16140.
@FaithfulAudio
FaithfulAudio requested a review from a team as a code owner July 26, 2026 06:50
@azure-pipelines

Copy link
Copy Markdown
Contributor
Azure Pipelines:
There may be pipelines that require an authorized user to comment /azp run to run.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant