diff --git a/apps/common-app/src/apps/reanimated/examples/LayoutAnimations/FinalStateFirstBenchExample.tsx b/apps/common-app/src/apps/reanimated/examples/LayoutAnimations/FinalStateFirstBenchExample.tsx
new file mode 100644
index 000000000000..c22b40ba757a
--- /dev/null
+++ b/apps/common-app/src/apps/reanimated/examples/LayoutAnimations/FinalStateFirstBenchExample.tsx
@@ -0,0 +1,97 @@
+import { useState } from 'react';
+import { Alert, Button, Pressable, StyleSheet, Text, View } from 'react-native';
+
+// Final-state-first bench (Objective 06). The nativeID markers below make the
+// Legacy proxy enqueue controlled prepared native requests on example-app
+// builds with IOS_LAYOUT_ANIMATIONS_CORE_ANIMATION on; no production Layout
+// Animations run here. With the flag on, the box slides 5 s (taps land at the
+// final frame) and the delayed view holds invisible for 1 s, then fades in.
+// With the flag off, both views jump.
+
+const LAYOUT_BENCH_NATIVE_ID = 'fsf-bench-layout';
+const ENTERING_BENCH_NATIVE_ID = 'fsf-bench-entering';
+
+export default function FinalStateFirstBenchExample() {
+ const [moved, setMoved] = useState(false);
+ const [entered, setEntered] = useState(false);
+ const [lastTap, setLastTap] = useState('none');
+
+ return (
+
+ Final-state-first bench (Objective 06)
+
+ Layout: A → B over 5 s
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ padding: 16,
+ gap: 8,
+ },
+ header: {
+ fontSize: 18,
+ fontWeight: 'bold',
+ },
+ label: {
+ fontSize: 14,
+ },
+ arena: {
+ height: 160,
+ borderWidth: StyleSheet.hairlineWidth,
+ borderColor: 'gray',
+ justifyContent: 'flex-start',
+ alignItems: 'flex-start',
+ },
+ box: {
+ width: 100,
+ height: 60,
+ backgroundColor: 'navy',
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ boxMoved: {
+ marginLeft: 220,
+ marginTop: 80,
+ },
+ enteringBox: {
+ backgroundColor: 'darkgreen',
+ marginLeft: 40,
+ marginTop: 40,
+ },
+ boxLabel: {
+ color: 'white',
+ },
+});
diff --git a/apps/common-app/src/apps/reanimated/examples/index.ts b/apps/common-app/src/apps/reanimated/examples/index.ts
index 19f72d96b95c..f8ebbbfcb055 100644
--- a/apps/common-app/src/apps/reanimated/examples/index.ts
+++ b/apps/common-app/src/apps/reanimated/examples/index.ts
@@ -210,6 +210,10 @@ const FlatListExample: React.FC = () =>
React.createElement(
require('./SharedElementTransitions/FlatList').default as React.FC
);
+const FinalStateFirstBench: React.FC = () =>
+ React.createElement(
+ require('./LayoutAnimations/FinalStateFirstBenchExample').default
+ );
const FlatListSkipEnteringExiting: React.FC = () =>
React.createElement(
require('./LayoutAnimations/FlatListSkipEnteringExiting').default
@@ -1192,6 +1196,10 @@ export const EXAMPLES: Record = {
title: '[LA] Strict Mode Comparison',
screen: StrictModeComparison,
},
+ FinalStateFirstBench: {
+ title: '[LA] Final-state-first bench',
+ screen: FinalStateFirstBench,
+ },
ReactionsCounterExample: {
title: '[LA] Reactions counter',
screen: ReactionsCounterExample,
diff --git a/apps/fabric-example/ios/Podfile.lock b/apps/fabric-example/ios/Podfile.lock
index 6e2ce9fcc317..009383b31cd6 100644
--- a/apps/fabric-example/ios/Podfile.lock
+++ b/apps/fabric-example/ios/Podfile.lock
@@ -2441,7 +2441,7 @@ SPEC CHECKSUMS:
RNCClipboard: f2c861be2669e65f3efa4618c22c95ba1b6dadda
RNCMaskedView: d4884d6b3b256bd1830a211db3d5f551def82e36
RNGestureHandler: f09eca0b47053142f7543f5c71d183f215ba5d81
- RNReanimated: eafd4d6a9bb890ca2b498d42942d9c83c2f6f30e
+ RNReanimated: 7d2a92550e0e973bf4b64be1959e69772b410c00
RNScreens: a2103f13fa603c405092daf70ca4eb3a6291fd6d
RNSVG: 6c9bcbf53822153be6f6c1484f6b0b57c0040d21
RNWorklets: da0d13711190e9ecee4d78b1f16790502ccb5ba3
diff --git a/packages/react-native-reanimated/CHANGELOG.md b/packages/react-native-reanimated/CHANGELOG.md
index 521da6422588..09ab15da2705 100644
--- a/packages/react-native-reanimated/CHANGELOG.md
+++ b/packages/react-native-reanimated/CHANGELOG.md
@@ -9,6 +9,7 @@
### 🎉 New features
- Add feature-flagged Shared Native Animation Boundary for CSS Transitions and future Layout Animations ([#10276](https://github.com/software-mansion/react-native-reanimated/pull/10276) by [@pawicao](https://github.com/pawicao))
+- Add feature-flagged final-state-first mount delivery for future native Layout Animations on iOS (by [@pawicao](https://github.com/pawicao))
### 🐛 Bug fixes
diff --git a/packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxyCommon.cpp b/packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxyCommon.cpp
index aabf0187a8b4..11100fe2d8ed 100644
--- a/packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxyCommon.cpp
+++ b/packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxyCommon.cpp
@@ -16,6 +16,74 @@ size_t LayoutAnimationsProxyCommon::FrameDrivenLeaseKeyHash::operator()(const Fr
return facebook::react::hash_combine(key.surfaceId, key.tag);
}
+namespace {
+
+struct LayoutPendingViewSnapshot final : public PendingLayoutViewSnapshot {
+ LayoutPendingViewSnapshot(ShadowView oldView, ShadowView finalView)
+ : oldView(std::move(oldView)), finalView(std::move(finalView)) {}
+
+ ShadowView oldView;
+ ShadowView finalView;
+};
+
+} // namespace
+
+std::shared_ptr LayoutAnimationsProxyCommon::makePendingNativeLayoutStarts(
+ const std::shared_ptr &nativeAnimationService,
+ const std::shared_ptr &layoutMountBoundary) {
+ if (!useNativeLayoutAnimations() || !nativeAnimationService || !layoutMountBoundary) {
+ return nullptr;
+ }
+ auto pendingStarts = std::make_shared(nativeAnimationService, layoutMountBoundary);
+ layoutMountBoundary->setPostMountObserver(
+ [weakPendingStarts = std::weak_ptr(pendingStarts)](const SurfaceId surfaceId) {
+ if (const auto pendingStarts = weakPendingStarts.lock()) {
+ pendingStarts->drainAfterMount(surfaceId);
+ }
+ });
+ return pendingStarts;
+}
+
+void LayoutAnimationsProxyCommon::enqueueNativeLayoutStart(
+ const LayoutAnimationType type,
+ const ShadowView &oldView,
+ const ShadowView &finalView,
+ const MountingTransaction::Number transactionNumber,
+ native_animation::AnimationRequest request,
+ native_animation::AnimationCallbacks callbacks) const {
+ react_native_assert(pendingNativeStarts_ != nullptr && "native layout start delivery is not available");
+ const auto handle = request.handle;
+ pendingNativeStarts_->enqueue({
+ handle,
+ transactionNumber,
+ type,
+ finalView.layoutMetrics.frame,
+ // The snapshot below keeps this props object alive while pending.
+ reinterpret_cast(finalView.props.get()),
+ std::make_unique(oldView, finalView),
+ std::move(request),
+ std::move(callbacks),
+ });
+}
+
+void LayoutAnimationsProxyCommon::cancelNativeLayoutStart(const native_animation::AnimationHandle &handle) const {
+ if (pendingNativeStarts_) {
+ pendingNativeStarts_->cancel(handle);
+ }
+}
+
+void LayoutAnimationsProxyCommon::notifyNativeStartsSurfaceStarted(const SurfaceId surfaceId) const {
+ if (pendingNativeStarts_) {
+ pendingNativeStarts_->notifySurfaceStarted(surfaceId);
+ }
+}
+
+void LayoutAnimationsProxyCommon::cancelNativeStartsSurface(const SurfaceId surfaceId) const {
+ if (pendingNativeStarts_) {
+ pendingNativeStarts_->cancelSurface(surfaceId);
+ }
+}
+
std::optional
LayoutAnimationsProxyCommon::onTransitionProgress(int tag, double progress, bool isClosing, bool isGoingForward) {
return std::nullopt;
diff --git a/packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxyCommon.h b/packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxyCommon.h
index eec06a9f929c..9ee6ef83de7f 100644
--- a/packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxyCommon.h
+++ b/packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxyCommon.h
@@ -9,7 +9,9 @@
#include
#include
#include
+#include
#include
+#include
#include
#include
#include
@@ -78,6 +80,7 @@ class LayoutAnimationsProxyCommon : public facebook::react::MountingOverrideDele
jsi::Runtime &uiRuntime,
const std::shared_ptr &uiScheduler,
const std::shared_ptr &nativeAnimationService,
+ const std::shared_ptr &layoutMountBoundary,
const std::shared_ptr &uiManager
#ifdef ANDROID
,
@@ -98,7 +101,8 @@ class LayoutAnimationsProxyCommon : public facebook::react::MountingOverrideDele
[uiScheduler](const native_animation::CallbackOperation &operation) {
scheduleOnUI(uiScheduler, operation);
})
- : nullptr)
+ : nullptr),
+ pendingNativeStarts_(makePendingNativeLayoutStarts(nativeAnimationService, layoutMountBoundary))
#ifdef ANDROID
,
preserveMountedTags_(filterUnmountedTagsFunction),
@@ -125,7 +129,24 @@ class LayoutAnimationsProxyCommon : public facebook::react::MountingOverrideDele
void releaseFrameDrivenLayoutAnimation(SurfaceId surfaceId, Tag tag, native_animation::AnimationOutcome outcome)
const;
+ // Final-state-first delivery: stores an owned prepared request during
+ // interception; the pending store submits it after the intended mount.
+ void enqueueNativeLayoutStart(
+ LayoutAnimationType type,
+ const ShadowView &oldView,
+ const ShadowView &finalView,
+ MountingTransaction::Number transactionNumber,
+ native_animation::AnimationRequest request,
+ native_animation::AnimationCallbacks callbacks) const;
+ void cancelNativeLayoutStart(const native_animation::AnimationHandle &handle) const;
+ void notifyNativeStartsSurfaceStarted(SurfaceId surfaceId) const;
+ void cancelNativeStartsSurface(SurfaceId surfaceId) const;
+
protected:
+ static std::shared_ptr makePendingNativeLayoutStarts(
+ const std::shared_ptr &nativeAnimationService,
+ const std::shared_ptr &layoutMountBoundary);
+
void transferConfigFromNativeID(const std::string &nativeId, const int tag) const;
mutable std::unordered_set maybeSettledAnimationTags_;
@@ -137,6 +158,7 @@ class LayoutAnimationsProxyCommon : public facebook::react::MountingOverrideDele
const std::shared_ptr uiScheduler_;
std::shared_ptr uiManager_;
const std::shared_ptr nativeAnimationAdapter_;
+ const std::shared_ptr pendingNativeStarts_;
PreserveMountedTagsFunction preserveMountedTags_;
struct FrameDrivenLeaseKey {
diff --git a/packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Experimental.h b/packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Experimental.h
index 27804f331ba9..233024f02af7 100644
--- a/packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Experimental.h
+++ b/packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Experimental.h
@@ -69,6 +69,7 @@ struct LayoutAnimationsProxy_Experimental : public LayoutAnimationsProxyCommon,
jsi::Runtime &uiRuntime,
const std::shared_ptr &uiScheduler,
const std::shared_ptr &nativeAnimationService,
+ const std::shared_ptr &layoutMountBoundary,
const std::shared_ptr &uiManager
#ifdef ANDROID
,
@@ -83,6 +84,7 @@ struct LayoutAnimationsProxy_Experimental : public LayoutAnimationsProxyCommon,
uiRuntime,
uiScheduler,
nativeAnimationService,
+ layoutMountBoundary,
uiManager
#ifdef ANDROID
,
diff --git a/packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Legacy.cpp b/packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Legacy.cpp
index 0d3bc4b2ce9f..c572d7d99a61 100644
--- a/packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Legacy.cpp
+++ b/packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Legacy.cpp
@@ -9,6 +9,7 @@
#include
#include
#include
+#include
#include
#include
#include
@@ -34,6 +35,9 @@ std::optional LayoutAnimationsProxy_Legacy::pullTransaction
LOG(INFO) << "pullTransaction " << std::this_thread::get_id() << " " << surfaceId << std::endl;
#endif
auto lock = std::unique_lock(mutex);
+#if defined(IS_REANIMATED_EXAMPLE_APP)
+ benchTransactionNumber_ = transactionNumber;
+#endif
PropsParserContext propsParserContext{surfaceId, *contextContainer_};
ShadowViewMutationList filteredMutations;
auto &surfaceCtx = getSurfaceContext(surfaceId);
@@ -447,6 +451,12 @@ void LayoutAnimationsProxy_Legacy::handleUpdatesAndEnterings(
}
transferConfigFromNativeID(mutation.newChildShadowView.props->nativeId, mutation.newChildShadowView.tag);
+#if defined(IS_REANIMATED_EXAMPLE_APP)
+ if (maybeEnqueueFinalStateFirstBenchEntering(mutation)) {
+ filteredMutations.push_back(mutation);
+ continue;
+ }
+#endif
if (!layoutAnimationsManager_->hasLayoutAnimation(tag, LayoutAnimationType::ENTERING)) {
filteredMutations.push_back(mutation);
continue;
@@ -474,6 +484,12 @@ void LayoutAnimationsProxy_Legacy::handleUpdatesAndEnterings(
}
case ShadowViewMutation::Type::Update: {
+#if defined(IS_REANIMATED_EXAMPLE_APP)
+ if (maybeEnqueueFinalStateFirstBenchLayout(mutation)) {
+ filteredMutations.push_back(mutation);
+ continue;
+ }
+#endif
auto shouldAnimate = hasLayoutChanged(mutation);
if (!layoutAnimationsManager_->hasLayoutAnimation(tag, LayoutAnimationType::LAYOUT) ||
(!shouldAnimate && !layoutAnimations_.contains(tag))) {
@@ -1243,6 +1259,99 @@ inline bool MutationNode::isMutationNode() {
return true;
}
+#if defined(IS_REANIMATED_EXAMPLE_APP)
+
+// Final-state-first bench helpers. They never parse a Layout Animations
+// builder and never route production animations.
+
+namespace {
+
+constexpr std::string_view kFinalStateFirstBenchLayoutNativeId = "fsf-bench-layout";
+constexpr std::string_view kFinalStateFirstBenchEnteringNativeId = "fsf-bench-entering";
+
+bool hasBenchNativeId(const ShadowView &view, const std::string_view marker) {
+ return view.props != nullptr && view.props->nativeId == marker;
+}
+
+native_animation::AnimationPoint centerOfFrame(const facebook::react::Rect &frame) {
+ return {
+ frame.origin.x + frame.size.width / 2.0,
+ frame.origin.y + frame.size.height / 2.0,
+ };
+}
+
+} // namespace
+
+bool LayoutAnimationsProxy_Legacy::maybeEnqueueFinalStateFirstBenchLayout(const ShadowViewMutation &mutation) const {
+ if constexpr (!useNativeLayoutAnimations()) {
+ return false;
+ }
+ if (!pendingNativeStarts_ || !hasBenchNativeId(mutation.newChildShadowView, kFinalStateFirstBenchLayoutNativeId)) {
+ return false;
+ }
+ const auto &oldFrame = mutation.oldChildShadowView.layoutMetrics.frame;
+ const auto &finalFrame = mutation.newChildShadowView.layoutMetrics.frame;
+ if (oldFrame == finalFrame) {
+ return false;
+ }
+ const native_animation::AnimationHandle handle{
+ mutation.newChildShadowView.surfaceId,
+ mutation.newChildShadowView.tag,
+ native_animation::AnimationOwner::Layout,
+ ++frameDrivenGeneration_,
+ };
+ native_animation::AnimationTrack positionTrack{
+ {native_animation::AnimationTargetKind::Position},
+ native_animation::AnimationValue{centerOfFrame(oldFrame)},
+ {{1.0, native_animation::AnimationValue{centerOfFrame(finalFrame)}, native_animation::LinearTiming{}}},
+ 0,
+ 5000,
+ };
+ enqueueNativeLayoutStart(
+ LayoutAnimationType::LAYOUT,
+ mutation.oldChildShadowView,
+ mutation.newChildShadowView,
+ benchTransactionNumber_,
+ {handle, {{std::move(positionTrack)}}, native_animation::AnimationAdmissionMode::Normal},
+ {});
+ return true;
+}
+
+bool LayoutAnimationsProxy_Legacy::maybeEnqueueFinalStateFirstBenchEntering(const ShadowViewMutation &mutation) const {
+ if constexpr (!useNativeLayoutAnimations()) {
+ return false;
+ }
+ if (!pendingNativeStarts_ || !hasBenchNativeId(mutation.newChildShadowView, kFinalStateFirstBenchEnteringNativeId)) {
+ return false;
+ }
+ const auto &viewProps = static_cast(*mutation.newChildShadowView.props);
+ const native_animation::AnimationHandle handle{
+ mutation.newChildShadowView.surfaceId,
+ mutation.newChildShadowView.tag,
+ native_animation::AnimationOwner::Layout,
+ ++frameDrivenGeneration_,
+ };
+ native_animation::AnimationTrack opacityTrack{
+ {native_animation::AnimationTargetKind::Opacity},
+ native_animation::AnimationValue{0.0},
+ {{1.0,
+ native_animation::AnimationValue{static_cast(viewProps.opacity)},
+ native_animation::LinearTiming{}}},
+ 1000,
+ 2000,
+ };
+ enqueueNativeLayoutStart(
+ LayoutAnimationType::ENTERING,
+ mutation.newChildShadowView,
+ mutation.newChildShadowView,
+ benchTransactionNumber_,
+ {handle, {{std::move(opacityTrack)}}, native_animation::AnimationAdmissionMode::Normal},
+ {});
+ return true;
+}
+
+#endif // IS_REANIMATED_EXAMPLE_APP
+
void LayoutAnimationsProxy_Legacy::startSurface(const SurfaceId surfaceId) {
auto lock = std::unique_lock(mutex);
surfaceContext_.try_emplace(surfaceId);
diff --git a/packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Legacy.h b/packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Legacy.h
index bbd782e28861..5249f8153f39 100644
--- a/packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Legacy.h
+++ b/packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Legacy.h
@@ -131,6 +131,7 @@ struct LayoutAnimationsProxy_Legacy : public LayoutAnimationsProxyCommon,
jsi::Runtime &uiRuntime,
const std::shared_ptr &uiScheduler,
const std::shared_ptr &nativeAnimationService,
+ const std::shared_ptr &layoutMountBoundary,
const std::shared_ptr &uiManager
#ifdef ANDROID
,
@@ -145,6 +146,7 @@ struct LayoutAnimationsProxy_Legacy : public LayoutAnimationsProxyCommon,
uiRuntime,
uiScheduler,
nativeAnimationService,
+ layoutMountBoundary,
uiManager
#ifdef ANDROID
,
@@ -163,6 +165,14 @@ struct LayoutAnimationsProxy_Legacy : public LayoutAnimationsProxyCommon,
void startExitingAnimation(const int tag, ShadowViewMutation &mutation) const;
void startLayoutAnimation(const int tag, const ShadowViewMutation &mutation) const;
+#if defined(IS_REANIMATED_EXAMPLE_APP)
+ // Final-state-first bench: views with a bench nativeID get a controlled
+ // prepared request through the pending-start mechanism.
+ mutable MountingTransaction::Number benchTransactionNumber_{0};
+ bool maybeEnqueueFinalStateFirstBenchLayout(const ShadowViewMutation &mutation) const;
+ bool maybeEnqueueFinalStateFirstBenchEntering(const ShadowViewMutation &mutation) const;
+#endif
+
std::optional progressLayoutAnimation(int tag, const jsi::Object &newStyle) override;
std::optional endLayoutAnimation(int tag, bool shouldRemove) override;
void startSurface(const SurfaceId surfaceId) override;
diff --git a/packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/LayoutMountBoundary.h b/packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/LayoutMountBoundary.h
new file mode 100644
index 000000000000..189eb7f6e7e7
--- /dev/null
+++ b/packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/LayoutMountBoundary.h
@@ -0,0 +1,38 @@
+#pragma once
+
+#include
+#include
+
+#include
+#include
+#include
+
+namespace reanimated {
+
+using PostMountObserver = std::function;
+
+struct MountedViewState {
+ // Mounted host frame in the parent's coordinates, free of any animation
+ // transform.
+ facebook::react::Rect frame;
+ // Identity of the mounted props object; zero when the platform cannot
+ // report one.
+ uintptr_t propsToken{0};
+};
+
+// Layout-owned, platform-neutral mount contract. The platform calls the
+// registered observer on the UI thread after it applies one mounting
+// transaction for the surface and before the frame becomes visible — an
+// explicit mount signal, never queue order. `mountedState` also runs on the
+// UI thread and returns nullopt when the view is not mounted for the surface.
+class LayoutMountBoundary {
+ public:
+ virtual ~LayoutMountBoundary() = default;
+
+ virtual void setPostMountObserver(PostMountObserver observer) = 0;
+ virtual std::optional mountedState(
+ facebook::react::SurfaceId surfaceId,
+ facebook::react::Tag tag) = 0;
+};
+
+} // namespace reanimated
diff --git a/packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/PendingNativeLayoutStarts.cpp b/packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/PendingNativeLayoutStarts.cpp
new file mode 100644
index 000000000000..8e0277409826
--- /dev/null
+++ b/packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/PendingNativeLayoutStarts.cpp
@@ -0,0 +1,195 @@
+#include
+#include
+
+#include
+#include
+#include
+
+namespace reanimated {
+
+namespace {
+
+using namespace native_animation;
+
+// Host-state epsilon from the semantic contract.
+constexpr double kLayoutEpsilon = 0.01;
+
+bool layoutsMatch(const facebook::react::Rect &mounted, const facebook::react::Rect &expected) {
+ return std::abs(mounted.origin.x - expected.origin.x) <= kLayoutEpsilon &&
+ std::abs(mounted.origin.y - expected.origin.y) <= kLayoutEpsilon &&
+ std::abs(mounted.size.width - expected.size.width) <= kLayoutEpsilon &&
+ std::abs(mounted.size.height - expected.size.height) <= kLayoutEpsilon;
+}
+
+} // namespace
+
+PendingNativeLayoutStarts::PendingNativeLayoutStarts(
+ std::shared_ptr service,
+ std::shared_ptr mountBoundary
+#ifndef NDEBUG
+ ,
+ std::shared_ptr traceSink)
+#else
+ )
+#endif
+ : service_(std::move(service)),
+ mountBoundary_(std::move(mountBoundary))
+#ifndef NDEBUG
+ ,
+ traceSink_(std::move(traceSink))
+#endif
+{
+}
+
+void PendingNativeLayoutStarts::enqueue(PendingNativeLayoutStart record) {
+ auto droppedResult = AnimationResult{AnimationOutcome::SurfaceDestroyed, AnimationResultReason::None};
+ {
+ std::unique_lock lock(mutex_);
+ if (stoppedSurfaces_.contains(record.handle.surfaceId)) {
+ // droppedResult already says SurfaceDestroyed.
+ } else if (!pendingHandles_.insert(record.handle).second) {
+ // Handles are single-use.
+ droppedResult = {AnimationOutcome::Rejected, AnimationResultReason::StaleGeneration};
+ } else {
+ recordNativeAnimationTrace(
+ *traceSink_,
+ NativeAnimationTraceEventType::MountEnqueue,
+ record.handle,
+ std::nullopt,
+ std::nullopt,
+ std::nullopt,
+ 6);
+ pendingBySurface_[record.handle.surfaceId].push_back(std::move(record));
+ return;
+ }
+ }
+ deliverDropped(std::move(record), droppedResult);
+}
+
+void PendingNativeLayoutStarts::cancel(const AnimationHandle &handle) {
+ PendingNativeLayoutStart cancelled;
+ {
+ std::unique_lock lock(mutex_);
+ const auto surfaceIt = pendingBySurface_.find(handle.surfaceId);
+ if (surfaceIt == pendingBySurface_.end()) {
+ return;
+ }
+ auto &records = surfaceIt->second;
+ const auto recordIt =
+ std::ranges::find_if(records, [&](const PendingNativeLayoutStart &record) { return record.handle == handle; });
+ if (recordIt == records.end()) {
+ return;
+ }
+ cancelled = std::move(*recordIt);
+ records.erase(recordIt);
+ if (records.empty()) {
+ pendingBySurface_.erase(surfaceIt);
+ }
+ pendingHandles_.erase(handle);
+ }
+ deliverDropped(std::move(cancelled), {AnimationOutcome::Cancelled, AnimationResultReason::None});
+}
+
+void PendingNativeLayoutStarts::drainAfterMount(const facebook::react::SurfaceId surfaceId) {
+ ReanimatedSystraceSection section("PendingNativeLayoutStarts::drainAfterMount");
+ std::vector records;
+ {
+ std::unique_lock lock(mutex_);
+ const auto surfaceIt = pendingBySurface_.find(surfaceId);
+ if (surfaceIt == pendingBySurface_.end()) {
+ return;
+ }
+ records = std::move(surfaceIt->second);
+ pendingBySurface_.erase(surfaceIt);
+ for (const auto &record : records) {
+ pendingHandles_.erase(record.handle);
+ }
+ }
+ for (auto &record : records) {
+ const auto mountedState = mountBoundary_->mountedState(surfaceId, record.handle.tag);
+ if (!mountedState) {
+ deliverDropped(std::move(record), {AnimationOutcome::Rejected, AnimationResultReason::TargetUnavailable});
+ continue;
+ }
+ const bool propsAreStale = record.expectedFinalPropsToken != 0 && mountedState->propsToken != 0 &&
+ mountedState->propsToken != record.expectedFinalPropsToken;
+ if (propsAreStale || !layoutsMatch(mountedState->frame, record.expectedFinalLayout)) {
+ // A newer commit mounted past this record's expected final state.
+ deliverDropped(std::move(record), {AnimationOutcome::Rejected, AnimationResultReason::StaleGeneration});
+ continue;
+ }
+ recordNativeAnimationTrace(
+ *traceSink_,
+ NativeAnimationTraceEventType::MountDrain,
+ record.handle,
+ std::nullopt,
+ std::nullopt,
+ std::nullopt,
+ 6);
+ service_->schedule(std::move(record.request), std::move(record.callbacks));
+ }
+}
+
+void PendingNativeLayoutStarts::notifySurfaceStarted(const facebook::react::SurfaceId surfaceId) {
+ std::unique_lock lock(mutex_);
+ stoppedSurfaces_.erase(surfaceId);
+}
+
+void PendingNativeLayoutStarts::cancelSurface(const facebook::react::SurfaceId surfaceId) {
+ std::vector records;
+ {
+ std::unique_lock lock(mutex_);
+ stoppedSurfaces_.insert(surfaceId);
+ const auto surfaceIt = pendingBySurface_.find(surfaceId);
+ if (surfaceIt == pendingBySurface_.end()) {
+ return;
+ }
+ records = std::move(surfaceIt->second);
+ pendingBySurface_.erase(surfaceIt);
+ for (const auto &record : records) {
+ pendingHandles_.erase(record.handle);
+ }
+ }
+ for (auto &record : records) {
+ deliverDropped(std::move(record), {AnimationOutcome::SurfaceDestroyed, AnimationResultReason::None});
+ }
+}
+
+void PendingNativeLayoutStarts::deliverDropped(PendingNativeLayoutStart record, const AnimationResult result) const {
+ recordNativeAnimationTrace(
+ *traceSink_,
+ NativeAnimationTraceEventType::Rejection,
+ record.handle,
+ std::nullopt,
+ result.outcome,
+ result.reason,
+ 6);
+ recordNativeAnimationTrace(
+ *traceSink_,
+ NativeAnimationTraceEventType::Terminal,
+ record.handle,
+ std::nullopt,
+ result.outcome,
+ result.reason,
+ 6);
+ auto callbacks = std::move(record.callbacks);
+ if (!callbacks.onAdmission && !callbacks.onTerminal) {
+ return;
+ }
+ auto operation =
+ [admission = std::move(callbacks.onAdmission), terminal = std::move(callbacks.onTerminal), result]() mutable {
+ if (admission) {
+ admission({AnimationAdmissionStatus::Rejected, result.reason});
+ }
+ if (terminal) {
+ terminal(result);
+ }
+ };
+ if (callbacks.scheduler) {
+ callbacks.scheduler(std::move(operation));
+ } else {
+ operation();
+ }
+}
+
+} // namespace reanimated
diff --git a/packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/PendingNativeLayoutStarts.h b/packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/PendingNativeLayoutStarts.h
new file mode 100644
index 000000000000..d7be781d678e
--- /dev/null
+++ b/packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/PendingNativeLayoutStarts.h
@@ -0,0 +1,77 @@
+#pragma once
+
+#include
+#include
+#include
+#include
+
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace reanimated {
+
+// Opaque holder for the old and final ShadowView copies of a pending start.
+// Keeps this store free of renderer headers.
+class PendingLayoutViewSnapshot {
+ public:
+ virtual ~PendingLayoutViewSnapshot() = default;
+};
+
+struct PendingNativeLayoutStart {
+ native_animation::AnimationHandle handle;
+ // MountingTransaction::Number without its heavy header dependency.
+ int64_t transactionNumber{0};
+ LayoutAnimationType type{LayoutAnimationType::LAYOUT};
+ facebook::react::Rect expectedFinalLayout;
+ // Identity of the expected final props object; zero skips the props check.
+ uintptr_t expectedFinalPropsToken{0};
+ std::unique_ptr views;
+ // Opaque owned execution data; this store never reads the plan.
+ native_animation::AnimationRequest request;
+ native_animation::AnimationCallbacks callbacks;
+};
+
+// Layout-owned store that holds prepared native requests until the mount
+// boundary reports the intended Fabric mount, then validates and submits
+// them. Every record gets exactly one typed result. A mutex guards the state;
+// callbacks and submissions run outside of it.
+class PendingNativeLayoutStarts {
+ public:
+ PendingNativeLayoutStarts(
+ std::shared_ptr service,
+ std::shared_ptr mountBoundary
+#ifndef NDEBUG
+ ,
+ std::shared_ptr traceSink =
+ std::make_shared());
+#else
+ );
+#endif
+
+ void enqueue(PendingNativeLayoutStart record);
+ // Removes one exact pending handle; disjoint newer generations stay valid.
+ void cancel(const native_animation::AnimationHandle &handle);
+ void drainAfterMount(facebook::react::SurfaceId surfaceId);
+ void notifySurfaceStarted(facebook::react::SurfaceId surfaceId);
+ void cancelSurface(facebook::react::SurfaceId surfaceId);
+
+ private:
+ void deliverDropped(PendingNativeLayoutStart record, native_animation::AnimationResult result) const;
+
+ std::shared_ptr service_;
+ std::shared_ptr mountBoundary_;
+#ifndef NDEBUG
+ std::shared_ptr traceSink_;
+#endif
+
+ mutable std::mutex mutex_;
+ std::unordered_map> pendingBySurface_;
+ std::unordered_set pendingHandles_;
+ std::unordered_set stoppedSurfaces_;
+};
+
+} // namespace reanimated
diff --git a/packages/react-native-reanimated/Common/cpp/reanimated/NativeAnimations/NativeAnimationTrace.cpp b/packages/react-native-reanimated/Common/cpp/reanimated/NativeAnimations/NativeAnimationTrace.cpp
index 683151a6e4bb..ef55b1f76b11 100644
--- a/packages/react-native-reanimated/Common/cpp/reanimated/NativeAnimations/NativeAnimationTrace.cpp
+++ b/packages/react-native-reanimated/Common/cpp/reanimated/NativeAnimations/NativeAnimationTrace.cpp
@@ -12,13 +12,15 @@ void recordNativeAnimationTrace(
const AnimationHandle handle,
std::optional target,
std::optional outcome,
- std::optional reason) {
+ std::optional reason,
+ const uint8_t objective) {
const auto now = std::chrono::steady_clock::now().time_since_epoch();
sink.record(NativeAnimationTraceEvent{
.monotonicTimeMs = std::chrono::duration(now).count(),
.event = event,
.handle = handle,
.target = target,
+ .objective = objective,
.outcome = outcome,
.reason = reason,
});
diff --git a/packages/react-native-reanimated/Common/cpp/reanimated/NativeAnimations/NativeAnimationTrace.h b/packages/react-native-reanimated/Common/cpp/reanimated/NativeAnimations/NativeAnimationTrace.h
index 52091c7723d7..ef1036600f10 100644
--- a/packages/react-native-reanimated/Common/cpp/reanimated/NativeAnimations/NativeAnimationTrace.h
+++ b/packages/react-native-reanimated/Common/cpp/reanimated/NativeAnimations/NativeAnimationTrace.h
@@ -15,6 +15,9 @@ enum class NativeAnimationTraceEventType : uint8_t {
Claim,
Rejection,
Terminal,
+ // Objective 06 layout mount-order events.
+ MountEnqueue,
+ MountDrain,
};
struct NativeAnimationTraceEvent {
@@ -53,7 +56,8 @@ void recordNativeAnimationTrace(
AnimationHandle handle,
std::optional target = std::nullopt,
std::optional outcome = std::nullopt,
- std::optional reason = std::nullopt);
+ std::optional reason = std::nullopt,
+ uint8_t objective = 5);
#else
diff --git a/packages/react-native-reanimated/Common/cpp/reanimated/NativeAnimations/tests/CMakeLists.txt b/packages/react-native-reanimated/Common/cpp/reanimated/NativeAnimations/tests/CMakeLists.txt
index 7b9bd8264cfe..51766051903f 100644
--- a/packages/react-native-reanimated/Common/cpp/reanimated/NativeAnimations/tests/CMakeLists.txt
+++ b/packages/react-native-reanimated/Common/cpp/reanimated/NativeAnimations/tests/CMakeLists.txt
@@ -11,7 +11,9 @@ set(REACT_NATIVE_ROOT "${REANIMATED_PACKAGE_ROOT}/../../node_modules/react-nativ
add_executable(
native_animation_service_test
NativeAnimationServiceTest.cc
+ PendingNativeLayoutStartsTest.cc
../../LayoutAnimations/LayoutNativeAnimationAdapter.cpp
+ ../../LayoutAnimations/PendingNativeLayoutStarts.cpp
../NativeAnimationConflictPolicy.cpp
../NativeAnimationCoordinator.cpp
../NativeAnimationDispatcher.cpp
@@ -24,7 +26,11 @@ add_executable(
target_include_directories(
native_animation_service_test
- PRIVATE "${REANIMATED_PACKAGE_ROOT}/Common/cpp" "${REACT_NATIVE_ROOT}/ReactCommon")
+ PRIVATE
+ "${REANIMATED_PACKAGE_ROOT}/Common/cpp"
+ "${REACT_NATIVE_ROOT}/ReactCommon"
+ # Graphics primitives live outside the main ReactCommon include root.
+ "${REACT_NATIVE_ROOT}/ReactCommon/react/renderer/graphics/platform/cxx")
# In debug builds react_native_assert needs glog, which this standalone build
# does not have. This flag turns it into a no-op; the suite's own assert()
diff --git a/packages/react-native-reanimated/Common/cpp/reanimated/NativeAnimations/tests/NativeAnimationServiceTest.cc b/packages/react-native-reanimated/Common/cpp/reanimated/NativeAnimations/tests/NativeAnimationServiceTest.cc
index 39ca0ee7d08b..1a89619d55ce 100644
--- a/packages/react-native-reanimated/Common/cpp/reanimated/NativeAnimations/tests/NativeAnimationServiceTest.cc
+++ b/packages/react-native-reanimated/Common/cpp/reanimated/NativeAnimations/tests/NativeAnimationServiceTest.cc
@@ -13,6 +13,8 @@
using namespace reanimated::native_animation;
using namespace reanimated::native_animation::testing;
+void runPendingNativeLayoutStartsTests();
+
namespace {
CallbackScheduler immediateScheduler() {
@@ -799,4 +801,5 @@ int main() {
testCommonFixturesUseAppleAndAndroidExecutors();
testExternalTraceEnvelope();
std::cout << "NativeAnimationServiceTest passed\n";
+ runPendingNativeLayoutStartsTests();
}
diff --git a/packages/react-native-reanimated/Common/cpp/reanimated/NativeAnimations/tests/PendingNativeLayoutStartsTest.cc b/packages/react-native-reanimated/Common/cpp/reanimated/NativeAnimations/tests/PendingNativeLayoutStartsTest.cc
new file mode 100644
index 000000000000..bb4fa801fb2c
--- /dev/null
+++ b/packages/react-native-reanimated/Common/cpp/reanimated/NativeAnimations/tests/PendingNativeLayoutStartsTest.cc
@@ -0,0 +1,470 @@
+#include
+#include
+
+#include
+#include
+#include