Skip to content

[0.84] Fix ScrollView Pressables stay stuck and next tap is dead on high-DPI displays#16139

Merged
acoates-ms merged 3 commits into
microsoft:0.84-stablefrom
Virtual-Fulfillment-Technologies-Inc:vendora/scroll-touch-scaling-fix
May 13, 2026
Merged

[0.84] Fix ScrollView Pressables stay stuck and next tap is dead on high-DPI displays#16139
acoates-ms merged 3 commits into
microsoft:0.84-stablefrom
Virtual-Fulfillment-Technologies-Inc:vendora/scroll-touch-scaling-fix

Conversation

@gmacmaster
Copy link
Copy Markdown
Contributor

@gmacmaster gmacmaster commented May 13, 2026

Addresses a "works on my computer" issue caused by display scaling.

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

Type of Change

  • Bug fix (non-breaking change which fixes an issue)

Why

On Fabric, tapping a Pressable inside a ScrollView, then scrolling that ScrollView with a touch drag, leaves behind two compounding bugs:

  1. The originally-pressed Pressable stays visually stuck in its pressed state — onPressOut never fires because React Native is never told the touch ended.
  2. On any Windows display configured at >100% scaling, the next tap on a row inside the just-scrolled ScrollView also fails to register a press. The touch visibly lands on the row, but the press is silently dismissed as pressInpressOut(0ms) with no press event.

Both symptoms compound into the "stuck button + dead column" behavior reported in the issue. The previous fix in #16106 only addressed (1) via onPointerCaptureLost, which doesn't fire on the InteractionTracker redirect codepath; this PR adds the missing redirect-path handler and addresses (2) for the first time.

Resolves #16047

What

Two underlying causes, each with a focused fix.

Fix A — synthesized touchcancel from InputPointerSource.PointerRoutedAway (the "stuck Pressable" symptom)

When ScrollView calls VisualInteractionSource::TryRedirectForManipulation to hand the active pointer over to the OS InteractionTracker, WinAppSDK does NOT fire PointerCaptureLost for the redirected pointer. It does, however, fire InputPointerSource.PointerRoutedAway — verified empirically on the repro environment (thanks to @acoates-ms for the pointer to that event). Without any cleanup hook on this path, CompositionEventHandler::m_activeTouches retained a zombie entry whose target is the originally-pressed Pressable, and JS never received a touchcancel / touchend.

The fix subscribes to PointerRoutedAway on the same InputPointerSource we already use for the other pointer events, and routes it into a new onPointerRoutedAway method that calls a shared CancelActiveTouchForPointerInternal helper (also extracted out of onPointerCaptureLost so both paths use the same implementation). The helper looks up the active touch by PointerId, removes it, and dispatches the synthesized touchcancel. No new IDL surface, no new public API, no new event flowing across module boundaries — just one more InputPointerSource event handler on the same source we already use, mirroring the existing PointerCaptureLost shape.

Fix B — DIP conversion in updateStateWithContentOffset() (the "next tap is dead at non-100% DPI" symptom)

m_scrollVisual.ScrollPosition() returns the InteractionTracker position in physical pixels (the scroll visual is sized as layoutMetrics.frame.size.* * pointScaleFactor — see updateLayoutMetrics / updateContentVisualSize), but ScrollViewShadowNode::ConcreteState::contentOffset is in DIPs. The other consumers of args.Position() in ScrollViewComponentView.cpp (the throttled ScrollPositionChanged handler and the embedded-element handler) already divide by pointScaleFactor before publishing to the JS event emitter — only the authoritative state-write path, updateStateWithContentOffset, was missing the same conversion.

At any non-100% Windows display scale, this caused JS UIManager.measure() to over-subtract the scroll offset by pointScaleFactor×. The touch still landed at the correct visual position (native composition hit-testing uses the raw physical scroll value consistently — see ScrollViewComponentView::hitTest), but Pressability's _responderRegion measurement reported pre-scroll-relative bounds that didn't contain the touch coordinates. LEAVE_PRESS_RECT then fired synchronously inside pressInpressOut(0ms) → no press.

The fix is one conversion in updateStateWithContentOffset() — divide ScrollPosition() by pointScaleFactor before storing into contentOffset. Plus one related fix in the same file: ScrollMomentumEnd now also calls updateStateWithContentOffset() so the final settled scroll position isn't dropped by the per-frame throttle. It was the only completion path that was missing this call; ScrollEndDrag and ScrollBeginDrag already had it.

Important Files Changed

Filename Overview
vnext/Microsoft.ReactNative/Fabric/Composition/CompositionEventHandler.cpp Subscribes to InputPointerSource.PointerRoutedAway; adds onPointerRoutedAway; extracts CancelActiveTouchForPointerInternal shared by both onPointerCaptureLost and onPointerRoutedAway.
vnext/Microsoft.ReactNative/Fabric/Composition/CompositionEventHandler.h Declares onPointerRoutedAway, m_pointerRoutedAwayToken, and the shared internal helper.
vnext/Microsoft.ReactNative/Fabric/Composition/ScrollViewComponentView.cpp Fix B: DIP conversion in updateStateWithContentOffset() and call it from ScrollMomentumEnd so the final settled offset isn't lost.
change/react-native-windows-d89877b5-…json Beachball prerelease/patch changeset.

Screenshots

Before:

dropped_touch.mp4

After:

fixed_touch.mp4

Testing

No automated regression test. Both bugs require a real InteractionTracker driven by a real WinAppSDK InputPointerSource at a >100% display scale, which is not currently mockable in the existing integration-test harness without significant new scaffolding.

Manual smoke test on the original 150%-scaled repro environment from #16047:

  1. Build and launch e2e-test-app-fabric: yarn windows from packages/e2e-test-app-fabric.
  2. Open any screen with a vertically scrolling list of Pressable rows (e.g. the RNTester component browser).
  3. Tap a row → onPress fires, the row highlights briefly, no stuck pressed state.
  4. Drag-scroll the list with touch, then immediately tap another row → onPress fires for the new row. (Was broken before Fix B.)
  5. Press-and-hold a row, then drag-scroll the list without lifting → the originally-pressed row releases its pressed visual state the moment the scroll claims the gesture. (Was broken before Fix A.)
  6. Repeat each step at 100% display scaling — no regression.

yarn windows builds clean against the final sources.

Changelog

Yes.

Fix #16047: Pressables inside ScrollView no longer remain stuck in the pressed state after a touch-driven scroll, and tapping a row immediately after a scroll now reliably fires onPress on Windows displays running at non-100% scaling.

@acoates-ms
Copy link
Copy Markdown
Contributor

acoates-ms commented May 13, 2026

When ScrollView calls VisualInteractionSource::TryRedirectForManipulation to hand the active pointer over to the OS InteractionTracker, WinAppSDK does NOT reliably fire PointerCaptureLost for the redirected pointer.

Are we sure that this isn't something like TryRedirectForManipulation returning false in this case?

There is also https://learn.microsoft.com/en-us/windows/windows-app-sdk/api/winrt/microsoft.ui.input.inputpointersource.pointerroutedaway?view=windows-app-sdk-1.8 ? Maybe that gets fired instead? (Just throwing out ideas, since its obviously not ideal to be faking pointer capture lost when we dont get an event.

@gmacmaster
Copy link
Copy Markdown
Contributor Author

When ScrollView calls VisualInteractionSource::TryRedirectForManipulation to hand the active pointer over to the OS InteractionTracker, WinAppSDK does NOT reliably fire PointerCaptureLost for the redirected pointer.

Are we sure that this isn't something like TryRedirectForManipulation returning false in this case?

There is also https://learn.microsoft.com/en-us/windows/windows-app-sdk/api/winrt/microsoft.ui.input.inputpointersource.pointerroutedaway?view=windows-app-sdk-1.8 ? Maybe that gets fired instead? (Just throwing out ideas, since its obviously not ideal to be faking pointer capture lost when we dont get an event.

@acoates-ms Thank you for the push back! Didn't know about PointerRoutedAway. I added a PointerRoutedAway subscriber alongside the existing PointerCaptureLost one as a verification step and it fires reliably on the TryRedirectForManipulation codepath, so I've ripped the entire InteractingStateEntered plumbing out and routed Fix A through it instead.

The new shape mirrors onPointerCaptureLost: Initialize() adds one more InputPointerSource event handler, onPointerRoutedAway calls a shared CancelActiveTouchForPointerInternal helper

Fix B is unchanged.

I have updated the pr description to match the new functionality

@acoates-ms
Copy link
Copy Markdown
Contributor

Much better! - Thanks

I appreciate that you want the fix in 0.84 asap, but lets make sure to also get a main version so that the change doesn't get lost going forward.

@acoates-ms
Copy link
Copy Markdown
Contributor

/azp run

@gmacmaster
Copy link
Copy Markdown
Contributor Author

Much better! - Thanks

I appreciate that you want the fix in 0.84 asap, but lets make sure to also get a main version so that the change doesn't get lost going forward.

Of course, I'll bring this over and create a pr to main right now

@azure-pipelines
Copy link
Copy Markdown
Contributor

Azure Pipelines successfully started running 1 pipeline(s).

@acoates-ms
Copy link
Copy Markdown
Contributor

/azp run

@azure-pipelines
Copy link
Copy Markdown
Contributor

Azure Pipelines successfully started running 1 pipeline(s).

@gmacmaster
Copy link
Copy Markdown
Contributor Author

Much better! - Thanks

I appreciate that you want the fix in 0.84 asap, but lets make sure to also get a main version so that the change doesn't get lost going forward.

Pr created #16140

@acoates-ms acoates-ms enabled auto-merge (squash) May 13, 2026 16:59
@acoates-ms acoates-ms merged commit 514f1c8 into microsoft:0.84-stable May 13, 2026
38 checks passed
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.

2 participants