Repository navigation
Conversation
tomekzaw
force-pushed
the
synchronous-transform-overlay
branch
2 times, most recently
from
October 23, 2026 10:38
b980bf2 to
eed2d86
Compare
Visual regression1 visual difference(s) at android: no capture ios
|
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
force-pushed
the
synchronous-transform-overlay
branch
from
October 23, 2026 15:20
eed2d86 to
6b7b2f5
Compare
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



Summary:
Motivation. Native-driven animations apply non-layout props straight to the mounted views, without a ShadowTree commit (
synchronouslyUpdateViewOnUIThread):AnimatedwithuseNativeDriver, the shared Animation Backend, sticky headers, and libraries like Reanimated's synchronous-props path. While atransformanimates, 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 committedtransform.Pressabilitymixes the two — itmeasure()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 firesonPressInbut silently dropsonPress. This reproduces with plainAnimatedon a device and is the root cause behind #51621, #36504, #44768 and thepassthroughAnimatedPropExplicitValuesworkaround increateAnimatedComponent.js.Approaches considered.
measureAsyncOnUI): fixesPressabilityonly, 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).Pressabilityto not use a measured rect: changespressRetentionOffsetsemantics and helps no other consumer.Chosen solution: a synchronous transform overlay.
UIManagerkeeps a small thread-safe map from view tag to the lasttransform/transformOriginwritten synchronously, parsed with the same conversions as ShadowNode props. It implements a newLayoutableShadowNode::TransformOverrideProvider;computeRelativeLayoutMetricsasks the provider (viaLayoutInspectingPolicy) for a node's transform before falling back togetTransform(), so the result composes through nested and committed ancestor transforms,transformOriginand percentages like committed props would. Recording happens at the platform funnel every synchronous write goes through (FabricUIManager.synchronouslyUpdateViewOnUIThreadon Android,RCTSurfacePresenteron iOS,SchedulerDelegateImplon ReactCxxPlatform); libraries that bypass them can callUIManager::recordSynchronousTransform. Onlytransformis tracked: it is the only synchronously written prop that affects geometry (opacity goes tolayer.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 itsAnimationEndSynccommit), mirroring the lifetime of the platforms' own view-level overrides. When nothing was written synchronously the provider isnullptrand the hot path is unchanged.Behind the new
enableSynchronousTransformOverlayfeature 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 batchedAnimatedPropspath behindoptimizedAnimatedPropUpdates.Changelog:
[GENERAL] [FIXED] - Layout queries (
measure,getBoundingClientRect, ...) andPressablenow account for natively-driventransformanimations, behindenableSynchronousTransformOverlayTest Plan:
yarn fantom packages/react-native/Libraries/Animated/__tests__/AnimatedSynchronousTransformOverlay-itest.js: 32/32 (4 tests × flag on/off ×useSharedAnimatedBackend×animatedDeferStartOfTimingAnimations): mid-animationmeasure/measureInWindow/getBoundingClientRectof the animated view and its descendants, two nested natively-animated levels, a committedscaleancestor, pruning across a commit that deletes views, and the committed final values after the animation.Animated-itest.jsstill passes.LayoutableShadowNodeTest.relativeLayoutMetricsWithTransformOverrideand the newSynchronousTransformOverlayTest(parsing,transformOriginmerging, remove/prune/threshold). Not run locally (no OSS gtest runner).yarn flow-check,yarn testfor the feature flags,yarn format,yarn featureflags --update, C++ API snapshots regenerated.Pressableinside anAnimated.Viewwith a 2-minute native-driventranslateY, 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, andmeasure()reports the live position instead of the committed one.mainRecording (Android, overlay on): the 2-minute animation, a mid-animation press with finger drift, then
measure():android-overlay-on.mp4
🤖 Generated with Claude Code