From c29cfeb3011198561138062c388395d40c2aa6e9 Mon Sep 17 00:00:00 2001 From: Zeya Peng Date: Thu, 3 Sep 2026 05:59:28 -0700 Subject: [PATCH] Add an internal option to skip Native Animated frames while inactive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: This is to investigate a crash on ios in C++ Animated rollout. On iOS, Native Animated drives frames from a `CADisplayLink` on the main run loop. While the app is inactive — which includes the whole `UIApplicationWillEnterForeground` → `UIApplicationDidBecomeActive` transition — it is not presenting, so a frame rendered then is never seen. Its commit and synchronous per-view updates still run on the main thread though, competing with the work the app must complete to become responsive. Adds `initWithSkipFramesDuringForegroundTransition:` to `RCTAnimatedModuleProvider`, declared in a new `RCTAnimatedModuleProvider+Private.h`. When YES, `_onDisplayLinkTick` returns early while `applicationState == UIApplicationStateInactive`. **The public API is unchanged.** `RCTAnimatedModuleProvider.h` is untouched and the C++ API snapshots have no delta — `+Private.h` is in the ReactApple `exclude_patterns`. Plain `init` still exists and defaults to NO, so every existing caller is unaffected; only hosts that opt in via the private header see different behaviour. Two properties worth being explicit about: - **The clock is not stopped, only the frame is skipped.** `AnimationDriver` computes progress from a timestamp (`timeDeltaMs = frameTimeMs - startFrameTimeMs_`), so the first frame after activation resolves to the value the animation should have reached rather than resuming from where it was suspended. - **Frames are not skipped while backgrounded** — `Background` is not `Inactive`. Completion handlers, and any app logic they drive, are therefore delayed by at most the length of the transition, not by the time spent in the background. Reading `applicationState` rather than tracking lifecycle notifications also avoids a failure mode: a mirrored flag must be cleared on every path out of the transition, including an abandoned foregrounding (a `willEnterForeground` with no following `didBecomeActive`), or frames are skipped indefinitely. There is no such state to get stuck here. `UIApplicationStateInactive` also covers other non-presenting moments — Control Center, the app switcher, an incoming call banner. Skipping frames there is harmless for the same reason: the clock keeps running and the first frame after activation is correct. The check sits inside the file's existing `TARGET_OS_OSX` guard, since `UIApplication` is iOS-only and this translation unit also builds for macOS. Changelog: [Internal] Reviewed By: javache, christophpurrer Differential Revision: D118188111 --- .../RCTAnimatedModuleProvider+Private.h | 20 ++++++++++++++ .../RCTAnimatedModuleProvider.mm | 27 +++++++++++++++++++ 2 files changed, 47 insertions(+) create mode 100644 packages/react-native/ReactApple/RCTAnimatedModuleProvider/RCTAnimatedModuleProvider+Private.h diff --git a/packages/react-native/ReactApple/RCTAnimatedModuleProvider/RCTAnimatedModuleProvider+Private.h b/packages/react-native/ReactApple/RCTAnimatedModuleProvider/RCTAnimatedModuleProvider+Private.h new file mode 100644 index 000000000000..062cd4e106e0 --- /dev/null +++ b/packages/react-native/ReactApple/RCTAnimatedModuleProvider/RCTAnimatedModuleProvider+Private.h @@ -0,0 +1,20 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#pragma once + +#import + +@interface RCTAnimatedModuleProvider (Private) + +/** + * When true, animated frames are skipped while the application is inactive (between + * `UIApplicationWillEnterForeground` -> `UIApplicationDidBecomeActive`), default is false. + */ +- (instancetype)initWithSkipFramesDuringForegroundTransition:(BOOL)skipFramesDuringForegroundTransition; + +@end diff --git a/packages/react-native/ReactApple/RCTAnimatedModuleProvider/RCTAnimatedModuleProvider.mm b/packages/react-native/ReactApple/RCTAnimatedModuleProvider/RCTAnimatedModuleProvider.mm index 17914749a512..71a3c8d9e147 100644 --- a/packages/react-native/ReactApple/RCTAnimatedModuleProvider/RCTAnimatedModuleProvider.mm +++ b/packages/react-native/ReactApple/RCTAnimatedModuleProvider/RCTAnimatedModuleProvider.mm @@ -7,12 +7,15 @@ #import "RCTAnimatedModuleProvider.h" +#import "RCTAnimatedModuleProvider+Private.h" + #import #if TARGET_OS_OSX #import #else #import +#import #endif #import @@ -28,6 +31,21 @@ @implementation RCTAnimatedModuleProvider { std::function _onRender; std::weak_ptr _nativeAnimatedNodesManagerProvider; + + BOOL _skipFramesDuringForegroundTransition; +} + +- (instancetype)init +{ + return [self initWithSkipFramesDuringForegroundTransition:NO]; +} + +- (instancetype)initWithSkipFramesDuringForegroundTransition:(BOOL)skipFramesDuringForegroundTransition +{ + if (self = [super init]) { + _skipFramesDuringForegroundTransition = skipFramesDuringForegroundTransition; + } + return self; } - (void)dealloc @@ -61,6 +79,15 @@ - (void)_onDisplayLinkTick // use-after-free during hot reload. The provider must remain alive for the // entire duration of _onRender() since it holds references to the animation // nodes manager and related data structures. +#if !TARGET_OS_OSX + // See initWithSkipFramesDuringForegroundTransition: the frame is skipped, + // never the clock. + if (_skipFramesDuringForegroundTransition && + UIApplication.sharedApplication.applicationState == UIApplicationStateInactive) { + return; + } +#endif + auto strongProvider = _nativeAnimatedNodesManagerProvider.lock(); if (strongProvider != nullptr && _displayLink != nullptr && _onRender != nullptr) { _onRender();