diff --git a/packages/react-native/ReactCommon/react/renderer/viewtransition/ViewTransitionModule.cpp b/packages/react-native/ReactCommon/react/renderer/viewtransition/ViewTransitionModule.cpp index 39983aaa7895..672802ed7aa6 100644 --- a/packages/react-native/ReactCommon/react/renderer/viewtransition/ViewTransitionModule.cpp +++ b/packages/react-native/ReactCommon/react/renderer/viewtransition/ViewTransitionModule.cpp @@ -7,6 +7,7 @@ #include "ViewTransitionModule.h" +#include #include #include #include @@ -72,6 +73,7 @@ void ViewTransitionModule::applyViewTransitionName( const ShadowNode& shadowNode, const std::string& name, const std::string& /*className*/) { + TraceSection s("ViewTransitionModule::applyViewTransitionName", "name", name); auto tag = shadowNode.getTag(); auto surfaceId = shadowNode.getSurfaceId(); @@ -86,14 +88,22 @@ void ViewTransitionModule::applyViewTransitionName( .size = layoutMetrics.frame.size, .pointScaleFactor = layoutMetrics.pointScaleFactor}; - nameRegistry_[tag].insert(name); - - // If applyViewTransitionName is called after transition started, this is the - // "new" state (end snapshot). Otherwise, this is the "old" state (start - // snapshot) - if (!transitionStarted_) { + // Calls outside mutationCallback are from the before-mutation phase (old + // state for an upcoming transition). Assign a provisional next transition ID + // so startViewTransitionEnd cleanup preserves these entries. + auto currentTransitionId = + insideMutationCallback_ ? activeTransitionId_ : activeTransitionId_ + 1; + nameRegistry_[tag].names.insert(name); + nameRegistry_[tag].transitionId = currentTransitionId; + + // Old state: called outside mutationCallback (before-mutation phase). + // New state: called inside mutationCallback (after-mutation phase). + if (!insideMutationCallback_) { AnimationKeyFrameView oldView{ - .layoutMetrics = keyframeMetrics, .tag = tag, .surfaceId = surfaceId}; + .layoutMetrics = keyframeMetrics, + .tag = tag, + .surfaceId = surfaceId, + .transitionId = currentTransitionId}; oldLayout_[name] = oldView; // Request the platform to capture a bitmap snapshot of the old view @@ -101,6 +111,10 @@ void ViewTransitionModule::applyViewTransitionName( if (uiManager_ != nullptr) { auto* delegate = uiManager_->getDelegate(); if (delegate != nullptr) { + TraceSection snapshotSection( + "ViewTransitionModule::applyViewTransitionName - uiManagerDidCaptureViewSnapshot", + "name", + name); delegate->uiManagerDidCaptureViewSnapshot(tag, surfaceId); } } @@ -111,6 +125,11 @@ void ViewTransitionModule::applyViewTransitionName( auto& pseudoElementsBySourceTag = it->second; auto innerIt = pseudoElementsBySourceTag.find(tag); + TraceSection clonePseudoElementSection( + "ViewTransitionModule::applyViewTransitionName - maybeClonePseudoElement", + "name", + name); + if (innerIt != pseudoElementsBySourceTag.end()) { // Only clone the pseudo-element if the layout metrics changed // since it was last created/refreshed (e.g. due to scrolling or @@ -147,7 +166,10 @@ void ViewTransitionModule::applyViewTransitionName( } else { AnimationKeyFrameView newView{ - .layoutMetrics = keyframeMetrics, .tag = tag, .surfaceId = surfaceId}; + .layoutMetrics = keyframeMetrics, + .tag = tag, + .surfaceId = surfaceId, + .transitionId = activeTransitionId_}; newLayout_[name] = newView; } } @@ -155,6 +177,8 @@ void ViewTransitionModule::applyViewTransitionName( void ViewTransitionModule::createViewTransitionInstance( const std::string& name, Tag pseudoElementTag) { + TraceSection s( + "ViewTransitionModule::createViewTransitionInstance", "name", name); if (uiManager_ == nullptr) { return; } @@ -209,6 +233,7 @@ RootShadowNode::Unshared ViewTransitionModule::shadowTreeWillCommit( if (oldPseudoElementNodes_.empty()) { return newRootShadowNode; } + TraceSection s("ViewTransitionModule::shadowTreeWillCommit"); auto surfaceId = shadowTree.getSurfaceId(); @@ -316,12 +341,14 @@ void ViewTransitionModule::cancelViewTransitionName( void ViewTransitionModule::restoreViewTransitionName( const ShadowNode& shadowNode) { - nameRegistry_[shadowNode.getTag()].merge( + nameRegistry_[shadowNode.getTag()].names.merge( cancelledNameRegistry_[shadowNode.getTag()]); cancelledNameRegistry_.erase(shadowNode.getTag()); } void ViewTransitionModule::applySnapshotsOnPseudoElementShadowNodes() { + TraceSection s( + "ViewTransitionModule::applySnapshotsOnPseudoElementShadowNodes"); if (oldPseudoElementNodes_.empty() || uiManager_ == nullptr) { return; } @@ -342,6 +369,7 @@ void ViewTransitionModule::applySnapshotsOnPseudoElementShadowNodes() { LayoutMetrics ViewTransitionModule::captureLayoutMetricsFromRoot( const ShadowNode& shadowNode) { + TraceSection s("ViewTransitionModule::captureLayoutMetricsFromRoot"); if (uiManager_ == nullptr) { return EmptyLayoutMetrics; } @@ -387,13 +415,22 @@ void ViewTransitionModule::startViewTransition( // Mark transition as started transitionStarted_ = true; + activeTransitionId_ = ++transitionIdCounter_; + + TraceSection s( + "ViewTransitionModule::startViewTransition", + "transitionId", + activeTransitionId_); + pendingAnimationIds_.clear(); onCompleteCallback_ = onCompleteCallback; // Call mutation callback (including commitRoot, measureInstance, // applyViewTransitionName, createViewTransitionInstance for old & new) if (mutationCallback) { + insideMutationCallback_ = true; mutationCallback(); + insideMutationCallback_ = false; } applySnapshotsOnPseudoElementShadowNodes(); @@ -442,13 +479,32 @@ void ViewTransitionModule::suspendOnActiveViewTransition() { } void ViewTransitionModule::startViewTransitionEnd() { - for (const auto& [tag, names] : nameRegistry_) { - for (const auto& name : names) { - oldLayout_.erase(name); - newLayout_.erase(name); + TraceSection s( + "ViewTransitionModule::startViewTransitionEnd", + "transitionId", + activeTransitionId_); + auto finishedId = activeTransitionId_; + + // Only clear layout and registry entries belonging to the finished + // transition. A suspended transition's before-mutation phase may have + // already written entries with a newer transitionId — preserve those. + for (auto it = nameRegistry_.begin(); it != nameRegistry_.end();) { + if (it->second.transitionId == finishedId) { + for (const auto& name : it->second.names) { + if (auto oit = oldLayout_.find(name); + oit != oldLayout_.end() && oit->second.transitionId == finishedId) { + oldLayout_.erase(oit); + } + if (auto nit = newLayout_.find(name); + nit != newLayout_.end() && nit->second.transitionId == finishedId) { + newLayout_.erase(nit); + } + } + it = nameRegistry_.erase(it); + } else { + ++it; } } - nameRegistry_.clear(); oldPseudoElementNodes_.clear(); // Clear any pending bitmap snapshots that were captured but never consumed. diff --git a/packages/react-native/ReactCommon/react/renderer/viewtransition/ViewTransitionModule.h b/packages/react-native/ReactCommon/react/renderer/viewtransition/ViewTransitionModule.h index 28f7f2f86113..ea3583794881 100644 --- a/packages/react-native/ReactCommon/react/renderer/viewtransition/ViewTransitionModule.h +++ b/packages/react-native/ReactCommon/react/renderer/viewtransition/ViewTransitionModule.h @@ -98,17 +98,25 @@ class ViewTransitionModule : public UIManagerViewTransitionDelegate, AnimationKeyFrameViewLayoutMetrics layoutMetrics; Tag tag{0}; SurfaceId surfaceId{0}; + uint32_t transitionId{0}; }; private: + uint32_t transitionIdCounter_{0}; + uint32_t activeTransitionId_{0}; + // registry of layout of old/new views std::unordered_map oldLayout_{}; std::unordered_map newLayout_{}; - // tag -> names registry, populated during applyViewTransitionName + // tag -> (names, transitionId) registry, populated during applyViewTransitionName // Note that tag and name are not 1:1 mapping // - In some nested composition 2 names are mappped to the same tag // - tags of old and new views are mapped to the same name(s) - std::unordered_map> nameRegistry_{}; + struct NameRegistryEntry { + std::unordered_set names; + uint32_t transitionId{0}; + }; + std::unordered_map nameRegistry_{}; // used for cancel/restore viewTransitionName std::unordered_map> cancelledNameRegistry_{}; @@ -138,6 +146,8 @@ class ViewTransitionModule : public UIManagerViewTransitionDelegate, bool transitionStarted_{false}; + bool insideMutationCallback_{false}; + bool transitionReadyFinished_{false}; // When suspendNextTransition_ is true and a transition is active, the next diff --git a/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js b/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js index 0bf9d70039b6..a07eb630de84 100644 --- a/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js +++ b/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js @@ -981,6 +981,17 @@ const definitions: FeatureFlagDefinitions = { jsOnly: { ...testDefinitions.jsOnly, + animatedDeferStartOfTimingAnimations: { + defaultValue: false, + metadata: { + dateAdded: '2026-05-26', + description: + 'When enabled, native timing animations defer their first frame and re-anchor timing to prevent skipping initial frames when the UI thread is busy with layout work.', + expectedReleaseValue: true, + purpose: 'experimentation', + }, + ossReleaseStage: 'none', + }, animatedShouldDebounceQueueFlush: { defaultValue: false, metadata: { diff --git a/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js b/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js index 8b92e20f338b..a55d306356ee 100644 --- a/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js +++ b/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<<7f48f734cd7a098d04cb147980ef364a>> + * @generated SignedSource<> * @flow strict * @noformat */ @@ -29,6 +29,7 @@ import { export type ReactNativeFeatureFlagsJsOnly = $ReadOnly<{ jsOnlyTestFlag: Getter, + animatedDeferStartOfTimingAnimations: Getter, animatedShouldDebounceQueueFlush: Getter, animatedShouldUseSingleOp: Getter, deferFlatListFocusChangeRenderUpdate: Getter, @@ -140,6 +141,11 @@ export type ReactNativeFeatureFlags = $ReadOnly<{ */ export const jsOnlyTestFlag: Getter = createJavaScriptFlagGetter('jsOnlyTestFlag', false); +/** + * When enabled, native timing animations defer their first frame and re-anchor timing to prevent skipping initial frames when the UI thread is busy with layout work. + */ +export const animatedDeferStartOfTimingAnimations: Getter = createJavaScriptFlagGetter('animatedDeferStartOfTimingAnimations', false); + /** * Enables an experimental flush-queue debouncing in Animated.js. */ diff --git a/scripts/cxx-api/api-snapshots/ReactAndroidDebugCxx.api b/scripts/cxx-api/api-snapshots/ReactAndroidDebugCxx.api index 2b4fbb3a4986..5bbaffe84b69 100644 --- a/scripts/cxx-api/api-snapshots/ReactAndroidDebugCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAndroidDebugCxx.api @@ -5453,6 +5453,7 @@ struct facebook::react::ViewTransitionModule::AnimationKeyFrameView { public facebook::react::SurfaceId surfaceId; public facebook::react::Tag tag; public facebook::react::ViewTransitionModule::AnimationKeyFrameViewLayoutMetrics layoutMetrics; + public uint32_t transitionId; } struct facebook::react::ViewTransitionModule::AnimationKeyFrameViewLayoutMetrics { diff --git a/scripts/cxx-api/api-snapshots/ReactAndroidNewarchCxx.api b/scripts/cxx-api/api-snapshots/ReactAndroidNewarchCxx.api index 06d8694b4b82..f41430b9759f 100644 --- a/scripts/cxx-api/api-snapshots/ReactAndroidNewarchCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAndroidNewarchCxx.api @@ -5279,6 +5279,7 @@ struct facebook::react::ViewTransitionModule::AnimationKeyFrameView { public facebook::react::SurfaceId surfaceId; public facebook::react::Tag tag; public facebook::react::ViewTransitionModule::AnimationKeyFrameViewLayoutMetrics layoutMetrics; + public uint32_t transitionId; } struct facebook::react::ViewTransitionModule::AnimationKeyFrameViewLayoutMetrics { diff --git a/scripts/cxx-api/api-snapshots/ReactAndroidReleaseCxx.api b/scripts/cxx-api/api-snapshots/ReactAndroidReleaseCxx.api index 1f7567f39773..b70b7f31f3e5 100644 --- a/scripts/cxx-api/api-snapshots/ReactAndroidReleaseCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAndroidReleaseCxx.api @@ -5444,6 +5444,7 @@ struct facebook::react::ViewTransitionModule::AnimationKeyFrameView { public facebook::react::SurfaceId surfaceId; public facebook::react::Tag tag; public facebook::react::ViewTransitionModule::AnimationKeyFrameViewLayoutMetrics layoutMetrics; + public uint32_t transitionId; } struct facebook::react::ViewTransitionModule::AnimationKeyFrameViewLayoutMetrics { diff --git a/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api b/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api index d4f7c124a3cb..008787a98b0c 100644 --- a/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api @@ -8031,6 +8031,7 @@ struct facebook::react::ViewTransitionModule::AnimationKeyFrameView { public facebook::react::SurfaceId surfaceId; public facebook::react::Tag tag; public facebook::react::ViewTransitionModule::AnimationKeyFrameViewLayoutMetrics layoutMetrics; + public uint32_t transitionId; } struct facebook::react::ViewTransitionModule::AnimationKeyFrameViewLayoutMetrics { diff --git a/scripts/cxx-api/api-snapshots/ReactAppleNewarchCxx.api b/scripts/cxx-api/api-snapshots/ReactAppleNewarchCxx.api index cd89d347ccfa..c0dd7c1584c6 100644 --- a/scripts/cxx-api/api-snapshots/ReactAppleNewarchCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAppleNewarchCxx.api @@ -7506,6 +7506,7 @@ struct facebook::react::ViewTransitionModule::AnimationKeyFrameView { public facebook::react::SurfaceId surfaceId; public facebook::react::Tag tag; public facebook::react::ViewTransitionModule::AnimationKeyFrameViewLayoutMetrics layoutMetrics; + public uint32_t transitionId; } struct facebook::react::ViewTransitionModule::AnimationKeyFrameViewLayoutMetrics { diff --git a/scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api b/scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api index 8219222a7c4d..6cc424cc9d43 100644 --- a/scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api @@ -8022,6 +8022,7 @@ struct facebook::react::ViewTransitionModule::AnimationKeyFrameView { public facebook::react::SurfaceId surfaceId; public facebook::react::Tag tag; public facebook::react::ViewTransitionModule::AnimationKeyFrameViewLayoutMetrics layoutMetrics; + public uint32_t transitionId; } struct facebook::react::ViewTransitionModule::AnimationKeyFrameViewLayoutMetrics { diff --git a/scripts/cxx-api/api-snapshots/ReactCommonDebugCxx.api b/scripts/cxx-api/api-snapshots/ReactCommonDebugCxx.api index 5f68b22fb882..2e43b23d2fc1 100644 --- a/scripts/cxx-api/api-snapshots/ReactCommonDebugCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactCommonDebugCxx.api @@ -3892,6 +3892,7 @@ struct facebook::react::ViewTransitionModule::AnimationKeyFrameView { public facebook::react::SurfaceId surfaceId; public facebook::react::Tag tag; public facebook::react::ViewTransitionModule::AnimationKeyFrameViewLayoutMetrics layoutMetrics; + public uint32_t transitionId; } struct facebook::react::ViewTransitionModule::AnimationKeyFrameViewLayoutMetrics { diff --git a/scripts/cxx-api/api-snapshots/ReactCommonNewarchCxx.api b/scripts/cxx-api/api-snapshots/ReactCommonNewarchCxx.api index 81705abe51bd..e9700e2725df 100644 --- a/scripts/cxx-api/api-snapshots/ReactCommonNewarchCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactCommonNewarchCxx.api @@ -3758,6 +3758,7 @@ struct facebook::react::ViewTransitionModule::AnimationKeyFrameView { public facebook::react::SurfaceId surfaceId; public facebook::react::Tag tag; public facebook::react::ViewTransitionModule::AnimationKeyFrameViewLayoutMetrics layoutMetrics; + public uint32_t transitionId; } struct facebook::react::ViewTransitionModule::AnimationKeyFrameViewLayoutMetrics { diff --git a/scripts/cxx-api/api-snapshots/ReactCommonReleaseCxx.api b/scripts/cxx-api/api-snapshots/ReactCommonReleaseCxx.api index 29810fb16f09..bf1acdc6eb1a 100644 --- a/scripts/cxx-api/api-snapshots/ReactCommonReleaseCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactCommonReleaseCxx.api @@ -3883,6 +3883,7 @@ struct facebook::react::ViewTransitionModule::AnimationKeyFrameView { public facebook::react::SurfaceId surfaceId; public facebook::react::Tag tag; public facebook::react::ViewTransitionModule::AnimationKeyFrameViewLayoutMetrics layoutMetrics; + public uint32_t transitionId; } struct facebook::react::ViewTransitionModule::AnimationKeyFrameViewLayoutMetrics {