Skip to content

Make layout queries aware of synchronously updated transforms - #59392

Draft
tomekzaw wants to merge 1 commit into
react:mainfrom
tomekzaw:synchronous-transform-overlay
Draft

tomekzaw wants to merge 1 commit into
react:mainfrom
tomekzaw:synchronous-transform-overlay

Conversation

@tomekzaw

@tomekzaw tomekzaw commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

Summary:

Motivation. Native-driven animations apply non-layout props straight to the mounted views, without a ShadowTree commit (synchronouslyUpdateViewOnUIThread): Animated with useNativeDriver, the shared Animation Backend, sticky headers, and libraries like Reanimated's synchronous-props path. While a transform animates, the committed tree lags behind the screen. Native hit-testing uses the live view hierarchy, but the layout queries (measure, measureInWindow, getBoundingClientRect, UIManager::getRelativeLayoutMetrics) read the committed transform. Pressability mixes the two — it measure()s the responder region on grant and compares it with native touch coordinates on every move — so a press with slight finger drift on a moving view fires onPressIn but silently drops onPress. This reproduces with plain Animated on a device and is the root cause behind #51621, #36504, #44768 and the passthroughAnimatedPropExplicitValues workaround in createAnimatedComponent.js.

Approaches considered.

  • Measure the mounted view instead of the ShadowTree (fix: pressability issues new arch #51835, measureAsyncOnUI): fixes Pressability only, adds a UI→JS hop per press, and leaves every other geometry query stale (measure, getBoundingClientRect, pointer-capture retargeting, libraries measuring on the UI thread).
  • Commit on every animation frame: exactly the cost the fast path exists to avoid.
  • Change Pressability to not use a measured rect: changes pressRetentionOffset semantics and helps no other consumer.

Chosen solution: a synchronous transform overlay. UIManager keeps a small thread-safe map from view tag to the last transform / transformOrigin written synchronously, parsed with the same conversions as ShadowNode props. It implements a new LayoutableShadowNode::TransformOverrideProvider; computeRelativeLayoutMetrics asks the provider (via LayoutInspectingPolicy) for a node's transform before falling back to getTransform(), so the result composes through nested and committed ancestor transforms, transformOrigin and percentages like committed props would. Recording happens at the platform funnel every synchronous write goes through (FabricUIManager.synchronouslyUpdateViewOnUIThread on Android, RCTSurfacePresenter on iOS, SchedulerDelegateImpl on ReactCxxPlatform); libraries that bypass them can call UIManager::recordSynchronousTransform. Only transform is tracked: it is the only synchronously written prop that affects geometry (opacity goes to layer.opacity, which neither UIKit's nor the ShadowTree's hit-testing consults). Entries live until the view is deleted (lazy pruning with a doubling threshold) or until the writer removes them (the Animation Backend does after its AnimationEndSync commit), mirroring the lifetime of the platforms' own view-level overrides. When nothing was written synchronously the provider is nullptr and the hot path is unchanged.

Behind the new enableSynchronousTransformOverlay feature flag (default off). Design notes: ReactCommon/react/renderer/uimanager/__docs__/SynchronousTransformOverlay.md. Not covered yet, as follow-ups: findNodeAtPoint (only used by the DevTools inspector) and the batched AnimatedProps path behind optimizedAnimatedPropUpdates.

Changelog:

[GENERAL] [FIXED] - Layout queries (measure, getBoundingClientRect, ...) and Pressable now account for natively-driven transform animations, behind enableSynchronousTransformOverlay

Test Plan:

  • yarn fantom packages/react-native/Libraries/Animated/__tests__/AnimatedSynchronousTransformOverlay-itest.js: 32/32 (4 tests × flag on/off × useSharedAnimatedBackend × animatedDeferStartOfTimingAnimations): mid-animation measure/measureInWindow/getBoundingClientRect of the animated view and its descendants, two nested natively-animated levels, a committed scale ancestor, pruning across a commit that deletes views, and the committed final values after the animation. Animated-itest.js still passes.
  • gtest: LayoutableShadowNodeTest.relativeLayoutMetricsWithTransformOverride and the new SynchronousTransformOverlayTest (parsing, transformOrigin merging, remove/prune/threshold). Not run locally (no OSS gtest runner).
  • yarn flow-check, yarn test for the feature flags, yarn format, yarn featureflags --update, C++ API snapshots regenerated.
  • Manual, flag force-enabled in RNTester: a Pressable inside an Animated.View with a 2-minute native-driven translateY, pressed 60 s in with finger drift, on a Samsung Galaxy A16 (Android 16) and the iPhone 18 Pro simulator. main: onPressIn: 1 | onPress: 0; this PR: 1 | 1, and measure() reports the live position instead of the committed one.
Android (Galaxy A16) on main Android (Galaxy A16) with the overlay iOS simulator with the overlay
onPressIn 1, onPress 0 on main onPressIn 2, onPress 2 with the overlay onPressIn 2, onPress 2 on iOS

Recording (Android, overlay on): the 2-minute animation, a mid-animation press with finger drift, then measure():

android-overlay-on.mp4

🤖 Generated with Claude Code

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Oct 23, 2026
@facebook-github-tools facebook-github-tools Bot added p: Software Mansion Partner: Software Mansion Partner labels Oct 23, 2026
@tomekzaw
tomekzaw force-pushed the synchronous-transform-overlay branch 2 times, most recently from b980bf2 to eed2d86 Compare October 23, 2026 10:38
@github-actions

Copy link
Copy Markdown

Visual regression

1 visual difference(s) at eed2d8661a8d. Visual differences need maintainer approval. Review the report, then apply visual-change-approved.

android: no capture

ios switch/custom-colors/toggled: Changed, 11804 px (0.474%)

diff

Expected and Captured
Expected Captured
expected captured

Summary:
Native-driven animations (Animated with `useNativeDriver`, the shared
Animation Backend, and third-party libraries like Reanimated's synchronous
props path) apply non-layout props such as `transform` directly to the
mounted views through `synchronouslyUpdateViewOnUIThread`, without a
ShadowTree commit. The committed tree therefore lags behind the screen for
the duration of the animation.

Layout queries (`measure`, `measureInWindow`, `getBoundingClientRect`,
`findNodeAtPoint`, `UIManager::getRelativeLayoutMetrics`) read the committed
`transform` prop, while native hit-testing uses the real view hierarchy.
`Pressability` mixes the two: it measures the responder region with
`measure()` and compares it with the touch coordinates reported by the
platform, so a press with a slight finger movement on a view that is being
animated leaves the stale rectangle and `onPress` is silently dropped
(react#51621, react#36504, react#44768; the sticky header hack
`passthroughAnimatedPropExplicitValues` exists for the same reason).

This adds a `SynchronousTransformOverlay` to `UIManager`: a thread-safe map
from view tag to the last `transform` / `transformOrigin` written
synchronously, recorded at the platform entry points every synchronous
update funnels through (`FabricUIManager.synchronouslyUpdateViewOnUIThread`
and the batched `AnimatedProps` path on Android, `RCTSurfacePresenter` on
iOS, `SchedulerDelegateImpl` on ReactCxxPlatform). The overlay implements a
new `LayoutableShadowNode::TransformOverrideProvider` interface, which
`computeRelativeLayoutMetrics` and `findNodeAtPoint` consult (through
`LayoutInspectingPolicy`) before falling back to `getTransform()`. Entries
are resolved against the committed layout metrics exactly like
`ConcreteViewShadowNode::getTransform` resolves props, live until the view
is deleted (lazy pruning on commit) or until the writer removes them (the
Animation Backend does so when it clears its registry after the
`AnimationEndSync` commit). Libraries that bypass the platform entry points
can call `UIManager::recordSynchronousTransform` directly.

Everything is behind the new `enableSynchronousTransformOverlay` feature
flag (default off). See
`ReactCommon/react/renderer/uimanager/__docs__/SynchronousTransformOverlay.md`.

Changelog:
[GENERAL] [FIXED] - Layout queries and Pressable now take natively-driven `transform` animations into account (behind `enableSynchronousTransformOverlay`)

Test Plan:
- `yarn fantom packages/react-native/Libraries/Animated/__tests__/AnimatedSynchronousTransformOverlay-itest.js`: 16/16 passed (both flag values x `useSharedAnimatedBackend` x `animatedDeferStartOfTimingAnimations`).
- New gtest cases in `LayoutableShadowNodeTest`, `FindNodeAtPointTest` and `SynchronousTransformOverlayTest` (not run locally: no OSS C++ unit test runner).
- `yarn flow-check`, ESLint on the changed JS, `yarn test` for the feature flags, `yarn format`.
- RNTester, new "Pressable inside a natively animated view" example, with the flag enabled (see the follow-up commit): on a Samsung Galaxy A16 (Android 16) and the iPhone 18 Pro simulator, a press with finger drift on the moving button fires both `onPressIn` and `onPress`, and `measure()` reports the live position. On `main` the same press only fires `onPressIn`.
- Not run: `scripts/cxx-api` snapshot regeneration (requires doxygen).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@tomekzaw
tomekzaw force-pushed the synchronous-transform-overlay branch from eed2d86 to 6b7b2f5 Compare October 23, 2026 15:20

This branch has not been deployed

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

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. p: Software Mansion Partner: Software Mansion Partner

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant