Skip to content

Commit 111c5aa

Browse files
Fix JS timers stalling after app lifecycle transitions on iOS
1 parent d54c4ff commit 111c5aa

1 file changed

Lines changed: 77 additions & 11 deletions

File tree

‎packages/react-native/React/CoreModules/RCTTiming.mm‎

Lines changed: 77 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,10 @@ @implementation RCTTiming {
9595
BOOL _sendIdleEvents;
9696
BOOL _inBackground;
9797
id<RCTTimingDelegate> _timingDelegate;
98+
// The run loop of the thread that creates timers (the JS thread). Every method that touches the
99+
// timer state runs there; app lifecycle notifications, posted on the main thread, are forwarded
100+
// to it (see -performOnTimingThread:).
101+
CFRunLoopRef _timingRunLoop;
98102
}
99103

100104
@synthesize paused = _paused;
@@ -119,11 +123,16 @@ - (void)setup
119123
_paused = YES;
120124
_timers = [NSMutableDictionary new];
121125
_inBackground = NO;
126+
__weak RCTTiming *weakSelf = self;
122127
RCTExecuteOnMainQueue(^{
123-
if (!self->_inBackground &&
124-
([RCTSharedApplication() applicationState] == UIApplicationStateBackground ||
125-
[UIDevice currentDevice].proximityState)) {
126-
[self appDidMoveToBackground];
128+
if ([RCTSharedApplication() applicationState] == UIApplicationStateBackground ||
129+
[UIDevice currentDevice].proximityState) {
130+
[weakSelf performOnTimingThread:^{
131+
RCTTiming *strongSelf = weakSelf;
132+
if (strongSelf && !strongSelf->_inBackground) {
133+
[strongSelf appDidMoveToBackground];
134+
}
135+
}];
127136
}
128137
});
129138

@@ -133,14 +142,14 @@ - (void)setup
133142
UIApplicationWillTerminateNotification
134143
]) {
135144
[[NSNotificationCenter defaultCenter] addObserver:self
136-
selector:@selector(appDidMoveToBackground)
145+
selector:@selector(appDidMoveToBackgroundNotification)
137146
name:name
138147
object:nil];
139148
}
140149

141150
for (NSString *name in @[ UIApplicationDidBecomeActiveNotification, UIApplicationWillEnterForegroundNotification ]) {
142151
[[NSNotificationCenter defaultCenter] addObserver:self
143-
selector:@selector(appDidMoveToForeground)
152+
selector:@selector(appDidMoveToForegroundNotification)
144153
name:name
145154
object:nil];
146155
}
@@ -154,6 +163,54 @@ - (void)setup
154163
- (void)dealloc
155164
{
156165
[_sleepTimer invalidate];
166+
if (_timingRunLoop) {
167+
CFRelease(_timingRunLoop);
168+
}
169+
}
170+
171+
/**
172+
* Runs `block` on the thread that creates timers.
173+
*
174+
* App lifecycle notifications are posted on the main thread, while the rest of this class runs on
175+
* the timers thread. Handling them inline made two threads write `_paused` and `_inBackground`
176+
* without synchronization, and installed the background NSTimer (-scheduleSleepTimer:) on the
177+
* main run loop in NSDefaultRunLoopMode. That timer does not fire while the main thread is busy
178+
* or in another run loop mode — as during a Face ID prompt — and the timers thread can then only
179+
* move its fire date, so every JS timer stalls. Forwarding keeps all timer state, and the sleep
180+
* timer, on one thread.
181+
*
182+
* Before the first timer is created the timers thread is unknown; there is no timer to race with
183+
* then, so the block runs in place.
184+
*/
185+
- (void)performOnTimingThread:(dispatch_block_t)block
186+
{
187+
CFRunLoopRef runLoop = NULL;
188+
@synchronized(self) {
189+
if (!_timingRunLoop || _timingRunLoop == CFRunLoopGetCurrent()) {
190+
block();
191+
return;
192+
}
193+
runLoop = (CFRunLoopRef)CFRetain(_timingRunLoop);
194+
}
195+
CFRunLoopPerformBlock(runLoop, kCFRunLoopCommonModes, block);
196+
CFRunLoopWakeUp(runLoop);
197+
CFRelease(runLoop);
198+
}
199+
200+
- (void)appDidMoveToBackgroundNotification
201+
{
202+
__weak RCTTiming *weakSelf = self;
203+
[self performOnTimingThread:^{
204+
[weakSelf appDidMoveToBackground];
205+
}];
206+
}
207+
208+
- (void)appDidMoveToForegroundNotification
209+
{
210+
__weak RCTTiming *weakSelf = self;
211+
[self performOnTimingThread:^{
212+
[weakSelf appDidMoveToForeground];
213+
}];
157214
}
158215

159216
- (void)invalidate
@@ -182,11 +239,14 @@ - (void)appDidMoveToForeground
182239
- (void)proximityChanged
183240
{
184241
BOOL isClose = [UIDevice currentDevice].proximityState;
185-
if (isClose) {
186-
[self appDidMoveToBackground];
187-
} else {
188-
[self appDidMoveToForeground];
189-
}
242+
__weak RCTTiming *weakSelf = self;
243+
[self performOnTimingThread:^{
244+
if (isClose) {
245+
[weakSelf appDidMoveToBackground];
246+
} else {
247+
[weakSelf appDidMoveToForeground];
248+
}
249+
}];
190250
}
191251

192252
- (void)stopTimers
@@ -329,6 +389,12 @@ - (void)createTimerForNextFrame:(nonnull NSNumber *)callbackID
329389
jsSchedulingTime:(NSDate *)jsSchedulingTime
330390
repeats:(BOOL)repeats
331391
{
392+
@synchronized(self) {
393+
if (!_timingRunLoop) {
394+
_timingRunLoop = (CFRunLoopRef)CFRetain(CFRunLoopGetCurrent());
395+
}
396+
}
397+
332398
NSTimeInterval jsSchedulingOverhead = MAX(-jsSchedulingTime.timeIntervalSinceNow, 0);
333399

334400
NSTimeInterval targetTime = jsDuration - jsSchedulingOverhead;

0 commit comments

Comments
 (0)