From 111c5aae1eb9accd872969a9eba86e40a2b2da60 Mon Sep 17 00:00:00 2001 From: Gabriel Almeida Dias <79956936+almeidagabriel01@users.noreply.github.com> Date: Fri, 9 Oct 2026 13:12:51 -0300 Subject: [PATCH] Fix JS timers stalling after app lifecycle transitions on iOS --- .../React/CoreModules/RCTTiming.mm | 88 ++++++++++++++++--- 1 file changed, 77 insertions(+), 11 deletions(-) diff --git a/packages/react-native/React/CoreModules/RCTTiming.mm b/packages/react-native/React/CoreModules/RCTTiming.mm index d77719a4b5d6..a956f305bc76 100644 --- a/packages/react-native/React/CoreModules/RCTTiming.mm +++ b/packages/react-native/React/CoreModules/RCTTiming.mm @@ -95,6 +95,10 @@ @implementation RCTTiming { BOOL _sendIdleEvents; BOOL _inBackground; id _timingDelegate; + // The run loop of the thread that creates timers (the JS thread). Every method that touches the + // timer state runs there; app lifecycle notifications, posted on the main thread, are forwarded + // to it (see -performOnTimingThread:). + CFRunLoopRef _timingRunLoop; } @synthesize paused = _paused; @@ -119,11 +123,16 @@ - (void)setup _paused = YES; _timers = [NSMutableDictionary new]; _inBackground = NO; + __weak RCTTiming *weakSelf = self; RCTExecuteOnMainQueue(^{ - if (!self->_inBackground && - ([RCTSharedApplication() applicationState] == UIApplicationStateBackground || - [UIDevice currentDevice].proximityState)) { - [self appDidMoveToBackground]; + if ([RCTSharedApplication() applicationState] == UIApplicationStateBackground || + [UIDevice currentDevice].proximityState) { + [weakSelf performOnTimingThread:^{ + RCTTiming *strongSelf = weakSelf; + if (strongSelf && !strongSelf->_inBackground) { + [strongSelf appDidMoveToBackground]; + } + }]; } }); @@ -133,14 +142,14 @@ - (void)setup UIApplicationWillTerminateNotification ]) { [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(appDidMoveToBackground) + selector:@selector(appDidMoveToBackgroundNotification) name:name object:nil]; } for (NSString *name in @[ UIApplicationDidBecomeActiveNotification, UIApplicationWillEnterForegroundNotification ]) { [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(appDidMoveToForeground) + selector:@selector(appDidMoveToForegroundNotification) name:name object:nil]; } @@ -154,6 +163,54 @@ - (void)setup - (void)dealloc { [_sleepTimer invalidate]; + if (_timingRunLoop) { + CFRelease(_timingRunLoop); + } +} + +/** + * Runs `block` on the thread that creates timers. + * + * App lifecycle notifications are posted on the main thread, while the rest of this class runs on + * the timers thread. Handling them inline made two threads write `_paused` and `_inBackground` + * without synchronization, and installed the background NSTimer (-scheduleSleepTimer:) on the + * main run loop in NSDefaultRunLoopMode. That timer does not fire while the main thread is busy + * or in another run loop mode — as during a Face ID prompt — and the timers thread can then only + * move its fire date, so every JS timer stalls. Forwarding keeps all timer state, and the sleep + * timer, on one thread. + * + * Before the first timer is created the timers thread is unknown; there is no timer to race with + * then, so the block runs in place. + */ +- (void)performOnTimingThread:(dispatch_block_t)block +{ + CFRunLoopRef runLoop = NULL; + @synchronized(self) { + if (!_timingRunLoop || _timingRunLoop == CFRunLoopGetCurrent()) { + block(); + return; + } + runLoop = (CFRunLoopRef)CFRetain(_timingRunLoop); + } + CFRunLoopPerformBlock(runLoop, kCFRunLoopCommonModes, block); + CFRunLoopWakeUp(runLoop); + CFRelease(runLoop); +} + +- (void)appDidMoveToBackgroundNotification +{ + __weak RCTTiming *weakSelf = self; + [self performOnTimingThread:^{ + [weakSelf appDidMoveToBackground]; + }]; +} + +- (void)appDidMoveToForegroundNotification +{ + __weak RCTTiming *weakSelf = self; + [self performOnTimingThread:^{ + [weakSelf appDidMoveToForeground]; + }]; } - (void)invalidate @@ -182,11 +239,14 @@ - (void)appDidMoveToForeground - (void)proximityChanged { BOOL isClose = [UIDevice currentDevice].proximityState; - if (isClose) { - [self appDidMoveToBackground]; - } else { - [self appDidMoveToForeground]; - } + __weak RCTTiming *weakSelf = self; + [self performOnTimingThread:^{ + if (isClose) { + [weakSelf appDidMoveToBackground]; + } else { + [weakSelf appDidMoveToForeground]; + } + }]; } - (void)stopTimers @@ -329,6 +389,12 @@ - (void)createTimerForNextFrame:(nonnull NSNumber *)callbackID jsSchedulingTime:(NSDate *)jsSchedulingTime repeats:(BOOL)repeats { + @synchronized(self) { + if (!_timingRunLoop) { + _timingRunLoop = (CFRunLoopRef)CFRetain(CFRunLoopGetCurrent()); + } + } + NSTimeInterval jsSchedulingOverhead = MAX(-jsSchedulingTime.timeIntervalSinceNow, 0); NSTimeInterval targetTime = jsDuration - jsSchedulingOverhead;