Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 46 additions & 26 deletions packages/react-native/React/CoreModules/RCTTiming.mm
Original file line number Diff line number Diff line change
Expand Up @@ -7,22 +7,32 @@

#import "RCTTiming.h"

#include <math.h>
#include <chrono>

#import <React/RCTAssert.h>
#import <React/RCTConvert.h>
#import <React/RCTLog.h>
#import <React/RCTUtils.h>

static const NSTimeInterval kMinimumSleepInterval = 1;
static const NSTimeInterval kNoTimerTarget = INFINITY;

// These timing contants should be kept in sync with the ones in `JSTimers.js`.
// The duration of a frame. This assumes that we want to run at 60 fps.
static const NSTimeInterval kFrameDuration = 1.0 / 60.0;
// The minimum time left in a frame to trigger the idle callback.
static const NSTimeInterval kIdleCallbackFrameDeadline = 0.001;

static NSTimeInterval RCTMonotonicTime()
{
// Apple libc++ uses CLOCK_MONOTONIC_RAW, which includes time spent asleep.
return std::chrono::duration<NSTimeInterval>(std::chrono::steady_clock::now().time_since_epoch()).count();
}

@interface _RCTTimer : NSObject

@property (nonatomic, strong, readonly) NSDate *target;
@property (nonatomic, assign, readonly) NSTimeInterval target;
@property (nonatomic, assign, readonly) BOOL repeats;
@property (nonatomic, copy, readonly) NSNumber *callbackID;
@property (nonatomic, assign, readonly) NSTimeInterval interval;
Expand All @@ -40,26 +50,23 @@ - (instancetype)initWithCallbackID:(NSNumber *)callbackID
_interval = interval;
_repeats = repeats;
_callbackID = callbackID;
_target = [NSDate dateWithTimeIntervalSinceNow:targetTime];
_target = RCTMonotonicTime() + targetTime;
}
return self;
}

/**
* Returns `YES` if we should invoke the JS callback.
*/
- (BOOL)shouldFire:(NSDate *)now
- (BOOL)shouldFire:(NSTimeInterval)now
{
if (_target && [_target timeIntervalSinceDate:now] <= 0) {
return YES;
}
return NO;
return _target <= now;
}

- (void)reschedule
{
// The JS Timers will do fine grained calculating of expired timeouts.
_target = [NSDate dateWithTimeIntervalSinceNow:_interval];
_target = RCTMonotonicTime() + _interval;
}

@end
Expand Down Expand Up @@ -92,6 +99,7 @@ - (void)timerDidFire
@implementation RCTTiming {
NSMutableDictionary<NSNumber *, _RCTTimer *> *_timers;
NSTimer *_sleepTimer;
NSTimeInterval _sleepTimerTarget;
BOOL _sendIdleEvents;
BOOL _inBackground;
id<RCTTimingDelegate> _timingDelegate;
Expand Down Expand Up @@ -226,31 +234,39 @@ - (BOOL)hasPendingTimers

- (void)didUpdateFrame:(RCTFrameUpdate *)update
{
NSDate *nextScheduledTarget = [NSDate distantFuture];
NSTimeInterval nextScheduledTarget = kNoTimerTarget;
NSMutableArray<_RCTTimer *> *timersToCall = [NSMutableArray new];
NSDate *now = [NSDate date]; // compare all the timers to the same base time
NSTimeInterval now = RCTMonotonicTime();
@synchronized(_timers) {
for (_RCTTimer *timer in _timers.allValues) {
if ([timer shouldFire:now]) {
[timersToCall addObject:timer];
} else {
nextScheduledTarget = [nextScheduledTarget earlierDate:timer.target];
} else if (timer.target < nextScheduledTarget) {
nextScheduledTarget = timer.target;
}
}
}

// Call timers that need to be called
if (timersToCall.count > 0) {
NSArray<NSNumber *> *sortedTimers = [[timersToCall sortedArrayUsingComparator:^(_RCTTimer *a, _RCTTimer *b) {
return [a.target compare:b.target];
if (a.target < b.target) {
return NSOrderedAscending;
}
if (a.target > b.target) {
return NSOrderedDescending;
}
return NSOrderedSame;
}] valueForKey:@"callbackID"];
[_timingDelegate callTimers:sortedTimers];
}

for (_RCTTimer *timer in timersToCall) {
if (timer.repeats) {
[timer reschedule];
nextScheduledTarget = [nextScheduledTarget earlierDate:timer.target];
if (timer.target < nextScheduledTarget) {
nextScheduledTarget = timer.target;
}
} else {
@synchronized(_timers) {
[_timers removeObjectForKey:timer.callbackID];
Expand All @@ -275,7 +291,7 @@ - (void)didUpdateFrame:(RCTFrameUpdate *)update
timerCount = _timers.count;
}
if (_inBackground) {
if (timerCount != 0u) {
if (nextScheduledTarget != kNoTimerTarget) {
[self scheduleSleepTimer:nextScheduledTarget];
}
} else if (!_sendIdleEvents && timersToCall.count == 0) {
Expand All @@ -285,26 +301,30 @@ - (void)didUpdateFrame:(RCTFrameUpdate *)update
_paused = YES;
}
// If the next timer is more than 1 second out, pause and schedule an NSTimer;
else if ([nextScheduledTarget timeIntervalSinceNow] > kMinimumSleepInterval) {
else if (
nextScheduledTarget != kNoTimerTarget && nextScheduledTarget - RCTMonotonicTime() > kMinimumSleepInterval) {
[self scheduleSleepTimer:nextScheduledTarget];
_paused = YES;
}
}
}

- (void)scheduleSleepTimer:(NSDate *)sleepTarget
- (void)scheduleSleepTimer:(NSTimeInterval)sleepTarget
{
@synchronized(self) {
NSTimeInterval delay = MAX(sleepTarget - RCTMonotonicTime(), 0);
if ((_sleepTimer == nil) || !_sleepTimer.valid) {
_sleepTimer = [[NSTimer alloc] initWithFireDate:sleepTarget
interval:0
target:[_RCTTimingProxy proxyWithTarget:self]
selector:@selector(timerDidFire)
userInfo:nil
repeats:NO];
_sleepTimer = [NSTimer timerWithTimeInterval:delay
target:[_RCTTimingProxy proxyWithTarget:self]
selector:@selector(timerDidFire)
userInfo:nil
repeats:NO];
_sleepTimerTarget = sleepTarget;
[[NSRunLoop currentRunLoop] addTimer:_sleepTimer forMode:NSDefaultRunLoopMode];
} else {
_sleepTimer.fireDate = [_sleepTimer.fireDate earlierDate:sleepTarget];
} else if (sleepTarget <= _sleepTimerTarget) {
// Convert to a date only when setting the run loop's wakeup.
_sleepTimer.fireDate = [NSDate dateWithTimeIntervalSinceNow:delay];
_sleepTimerTarget = sleepTarget;
}
}
}
Expand Down Expand Up @@ -347,7 +367,7 @@ - (void)createTimerForNextFrame:(nonnull NSNumber *)callbackID
if (_inBackground) {
[self scheduleSleepTimer:timer.target];
} else if (_paused) {
if ([timer.target timeIntervalSinceNow] > kMinimumSleepInterval) {
if (timer.target - RCTMonotonicTime() > kMinimumSleepInterval) {
[self scheduleSleepTimer:timer.target];
} else {
[self startTimers];
Expand Down
4 changes: 4 additions & 0 deletions packages/rn-tester/RNTesterPods.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
602160B9478DAFF2BAB56595 /* RCTUtilsTests.m in Sources */ = {isa = PBXBuildFile; fileRef = D5D13FB1445C5C19639921CC /* RCTUtilsTests.m */; };
79B29C2E2E607A99007612A5 /* SceneDelegate.mm in Sources */ = {isa = PBXBuildFile; fileRef = 79B29C2D2E607A99007612A5 /* SceneDelegate.mm */; };
7F806D4D4D97AD6403558B35 /* RCTStatusBarAppearanceTests.mm in Sources */ = {isa = PBXBuildFile; fileRef = 67B0814814F7C07E196BC295 /* RCTStatusBarAppearanceTests.mm */; };
A8717727467541429C262C63 /* RCTTimingClockChangeTests.mm in Sources */ = {isa = PBXBuildFile; fileRef = A8717727467541429C262C64 /* RCTTimingClockChangeTests.mm */; };
8145AE06241172D900A3F8DA /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 8145AE05241172D900A3F8DA /* LaunchScreen.storyboard */; };
832F45BB2A8A6E1F0097B4E6 /* SwiftTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 832F45BA2A8A6E1F0097B4E6 /* SwiftTest.swift */; };
A975CA6C2C05EADF0043F72A /* RCTNetworkTaskTests.m in Sources */ = {isa = PBXBuildFile; fileRef = A975CA6B2C05EADE0043F72A /* RCTNetworkTaskTests.m */; };
Expand Down Expand Up @@ -89,6 +90,7 @@
51BC9297B6C3163C14532020 /* Pods-RNTester.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RNTester.release.xcconfig"; path = "Target Support Files/Pods-RNTester/Pods-RNTester.release.xcconfig"; sourceTree = "<group>"; };
5C60EB1B226440DB0018C04F /* AppDelegate.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; name = AppDelegate.mm; path = RNTester/AppDelegate.mm; sourceTree = "<group>"; };
67B0814814F7C07E196BC295 /* RCTStatusBarAppearanceTests.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; path = RCTStatusBarAppearanceTests.mm; sourceTree = "<group>"; };
A8717727467541429C262C64 /* RCTTimingClockChangeTests.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; path = RCTTimingClockChangeTests.mm; sourceTree = "<group>"; };
79B29C2C2E607A99007612A5 /* SceneDelegate.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = SceneDelegate.h; path = RNTester/SceneDelegate.h; sourceTree = "<group>"; };
79B29C2D2E607A99007612A5 /* SceneDelegate.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; name = SceneDelegate.mm; path = RNTester/SceneDelegate.mm; sourceTree = "<group>"; };
8145AE05241172D900A3F8DA /* LaunchScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = LaunchScreen.storyboard; path = RNTester/LaunchScreen.storyboard; sourceTree = "<group>"; };
Expand Down Expand Up @@ -318,6 +320,7 @@
D5D13FB1445C5C19639921CC /* RCTUtilsTests.m */,
E7DB20B322B2BAA4005AC45F /* RNTesterUnitTestsBundle.js */,
67B0814814F7C07E196BC295 /* RCTStatusBarAppearanceTests.mm */,
A8717727467541429C262C64 /* RCTTimingClockChangeTests.mm */,
);
path = RNTesterUnitTests;
sourceTree = "<group>";
Expand Down Expand Up @@ -637,6 +640,7 @@
E7DB20D822B2BAA6005AC45F /* RCTJSONTests.m in Sources */,
602160B9478DAFF2BAB56595 /* RCTUtilsTests.m in Sources */,
7F806D4D4D97AD6403558B35 /* RCTStatusBarAppearanceTests.mm in Sources */,
A8717727467541429C262C63 /* RCTTimingClockChangeTests.mm in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down
118 changes: 118 additions & 0 deletions packages/rn-tester/RNTesterUnitTests/RCTTimingClockChangeTests.mm
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
/*
* 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.
*/

#import <XCTest/XCTest.h>
#import <objc/runtime.h>

#import <React/RCTTiming.h>

static constexpr NSTimeInterval kClockJump = 60 * 60;
static constexpr NSTimeInterval kTimerDelay = 0.05;
static thread_local NSTimeInterval wallClockOffset;

static Method dateMethod()
{
return class_getClassMethod(NSDate.class, @selector(date));
}

static auto originalDateImplementation()
{
static const auto implementation = method_getImplementation(dateMethod());
return implementation;
}

static NSDate *dateWithClockOffset(id receiver, SEL selector)
{
auto originalDate = reinterpret_cast<NSDate *(*)(id, SEL)>(originalDateImplementation());
NSDate *now = originalDate(receiver, selector);
return wallClockOffset == 0 ? now : [now dateByAddingTimeInterval:wallClockOffset];
}

@interface RCTTimingClockChangeTests : XCTestCase <RCTTimingDelegate>
@end

@implementation RCTTimingClockChangeTests {
RCTTiming *_timing;
NSMutableArray<NSNumber *> *_callbacks;
}

- (void)setUp
{
[super setUp];
_callbacks = [NSMutableArray new];
_timing = [[RCTTiming alloc] initWithDelegate:self];
}

- (void)tearDown
{
[_timing invalidate];
_timing = nil;
[super tearDown];
}

- (void)callTimers:(NSArray<NSNumber *> *)timers
{
[_callbacks addObjectsFromArray:timers];
}

- (void)immediatelyCallTimer:(NSNumber *)callbackID
{
[_callbacks addObject:callbackID];
}

- (void)callIdleCallbacks:(NSNumber *)absoluteFrameStartMS
{
}

- (void)updateFrameWithClockOffset:(NSTimeInterval)offset
{
// Simulate a clock change after scheduling. Only this thread sees the
// shifted time, and only while checking which timers are due.
IMP original = originalDateImplementation();
wallClockOffset = offset;
method_setImplementation(dateMethod(), reinterpret_cast<IMP>(dateWithClockOffset));
@try {
[_timing didUpdateFrame:nil];
} @finally {
method_setImplementation(dateMethod(), original);
wallClockOffset = 0;
}
}

- (void)testClockMovingForwardDoesNotFirePendingTimeoutEarly
{
[_timing createTimerForNextFrame:@1 duration:60 jsSchedulingTime:nil repeats:NO];

[self updateFrameWithClockOffset:kClockJump];

XCTAssertEqual(_callbacks.count, 0u);
}

- (void)testClockMovingBackwardDoesNotDelayDueTimeout
{
[_timing createTimerForNextFrame:@1 duration:kTimerDelay jsSchedulingTime:nil repeats:NO];
[NSThread sleepForTimeInterval:2 * kTimerDelay];

[self updateFrameWithClockOffset:-kClockJump];

XCTAssertEqualObjects(_callbacks, (@[ @1 ]));
}

- (void)testClockMovingBackwardDoesNotDelayRescheduledInterval
{
[_timing createTimerForNextFrame:@1 duration:kTimerDelay jsSchedulingTime:nil repeats:YES];
[NSThread sleepForTimeInterval:2 * kTimerDelay];
[_timing didUpdateFrame:nil];
XCTAssertEqualObjects(_callbacks, (@[ @1 ]));

[NSThread sleepForTimeInterval:2 * kTimerDelay];
[self updateFrameWithClockOffset:-kClockJump];

XCTAssertEqualObjects(_callbacks, (@[ @1, @1 ]));
}

@end
Loading