From 21c1fde1f26ce476166074af71673dfe888ac358 Mon Sep 17 00:00:00 2001 From: datadog-bits <263423550+datadog-bits@users.noreply.github.com> Date: Thu, 29 Oct 2026 17:24:30 +0000 Subject: [PATCH] [iOS] Pause JS refresh rate listener while the app is inactive The CADisplayLink used to measure JS frame times stops ticking while the app is in the background, but lastFrameTimestamp was never reset, so the first frame after resuming was reported as a JS long task spanning the whole background duration. JSRefreshRateMonitor already had appWillResignActive/appDidBecomeActive hooks, but nothing called them. Observe UIApplication willResignActive/didBecomeActive to stop/restart the listener, and reset lastFrameTimestamp on start, matching Android's FrameRateProvider and the native iOS SDK. Fixes #1523 Co-Authored-By: Claude Opus 5.5 (1M context) Co-authored-by: mariedm <29802155+mariedm@users.noreply.github.com> --- .../ios/Sources/JSRefreshRateListener.swift | 66 +++++++++++--- .../ios/Tests/JSRefreshRateMonitorTests.swift | 88 +++++++++++++++++++ 2 files changed, 141 insertions(+), 13 deletions(-) create mode 100644 packages/core/ios/Tests/JSRefreshRateMonitorTests.swift diff --git a/packages/core/ios/Sources/JSRefreshRateListener.swift b/packages/core/ios/Sources/JSRefreshRateListener.swift index cc084298f..708ecc034 100644 --- a/packages/core/ios/Sources/JSRefreshRateListener.swift +++ b/packages/core/ios/Sources/JSRefreshRateListener.swift @@ -5,6 +5,7 @@ */ import Foundation +import UIKit typealias frame_time_callback = (_ frameTime: Double, _ displayFps: Double) -> Void @@ -17,25 +18,59 @@ internal protocol RefreshRateMonitor { func startMonitoring(jsQueue: DispatchQueueType, frameTimeCallback: @escaping frame_time_callback) } +typealias refresh_rate_listener_factory = ( + _ jsQueue: DispatchQueueType, _ frameTimeCallback: @escaping frame_time_callback +) -> RefreshRateListener + internal final class JSRefreshRateMonitor: RefreshRateMonitor { private var refreshRateListener: RefreshRateListener - - init() { + private let notificationCenter: NotificationCenter + private let queue: DispatchQueueType + private let listenerFactory: refresh_rate_listener_factory + + init( + notificationCenter: NotificationCenter = .default, + queue: DispatchQueueType = sharedQueue, + listenerFactory: @escaping refresh_rate_listener_factory = { + JSRefreshRateListener(jsQueue: $0, frameTimeCallback: $1) + } + ) { self.refreshRateListener = NoOpRefreshRateListener() + self.notificationCenter = notificationCenter + self.queue = queue + self.listenerFactory = listenerFactory + + // CADisplayLink does not tick while the app is in the background, so the listener must be + // paused, otherwise the whole background duration is reported as a single JS frame. + notificationCenter.addObserver( + self, selector: #selector(appWillResignActive), + name: UIApplication.willResignActiveNotification, object: nil) + notificationCenter.addObserver( + self, selector: #selector(appDidBecomeActive), + name: UIApplication.didBecomeActiveNotification, object: nil) } - + + deinit { + notificationCenter.removeObserver(self) + } + public func startMonitoring(jsQueue: DispatchQueueType, frameTimeCallback: @escaping frame_time_callback) { self.refreshRateListener.stop() - self.refreshRateListener = JSRefreshRateListener(jsQueue: jsQueue, frameTimeCallback: frameTimeCallback) + self.refreshRateListener = listenerFactory(jsQueue, frameTimeCallback) self.refreshRateListener.start() } - - func appWillResignActive() { - self.refreshRateListener.stop() + + // Notifications are posted on the main thread, while `startMonitoring` runs on the shared queue. + @objc func appWillResignActive() { + queue.async { + self.refreshRateListener.stop() + } } - func appDidBecomeActive() { - self.refreshRateListener.start() + @objc func appDidBecomeActive() { + queue.async { + self.refreshRateListener.start() + } } class func requiresMainQueueSetup() -> Bool { @@ -50,7 +85,7 @@ private final class NoOpRefreshRateListener: RefreshRateListener { } -private final class JSRefreshRateListener: RefreshRateListener { +internal final class JSRefreshRateListener: RefreshRateListener { private var jsQueue: DispatchQueueType private var frameTimeCallback: frame_time_callback private var lastFrameTimestamp: TimeInterval = -1 @@ -65,6 +100,8 @@ private final class JSRefreshRateListener: RefreshRateListener { public func start() { jsQueue.async { self.jsDisplayLink?.invalidate() + // The first frame after (re)starting must not be measured against a frame from before the pause. + self.lastFrameTimestamp = -1 self.jsDisplayLink = CADisplayLink(target: self, selector: #selector(self.onFrameTick)) self.jsDisplayLink?.add(to: .current, forMode: .common) } @@ -78,14 +115,17 @@ private final class JSRefreshRateListener: RefreshRateListener { } @objc func onFrameTick(displayLink: CADisplayLink) { - let frameTimestamp = displayLink.timestamp + onFrame(timestamp: displayLink.timestamp, targetTimestamp: displayLink.targetTimestamp) + } + + func onFrame(timestamp frameTimestamp: TimeInterval, targetTimestamp: TimeInterval) { if lastFrameTimestamp != -1 { let frameDuration = frameTimestamp - lastFrameTimestamp frameTimeCallback(frameDuration, displayFps) } lastFrameTimestamp = frameTimestamp - + // Refresh rate the display is currently running at - displayFps = 1.0 / (displayLink.targetTimestamp - frameTimestamp) + displayFps = 1.0 / (targetTimestamp - frameTimestamp) } } diff --git a/packages/core/ios/Tests/JSRefreshRateMonitorTests.swift b/packages/core/ios/Tests/JSRefreshRateMonitorTests.swift new file mode 100644 index 000000000..d0bd3b356 --- /dev/null +++ b/packages/core/ios/Tests/JSRefreshRateMonitorTests.swift @@ -0,0 +1,88 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +import UIKit +import XCTest + +@testable import DatadogSDKReactNative + +final class JSRefreshRateMonitorTests: XCTestCase { + func testListenerIsStoppedWhenAppResignsActiveAndRestartedWhenItBecomesActive() { + let notificationCenter = NotificationCenter() + let listener = MockRefreshRateListener() + let monitor = JSRefreshRateMonitor( + notificationCenter: notificationCenter, + queue: DispatchQueueMock(), + listenerFactory: { _, _ in listener } + ) + + monitor.startMonitoring(jsQueue: DispatchQueueMock(), frameTimeCallback: { _, _ in }) + XCTAssertEqual(listener.startCount, 1) + XCTAssertEqual(listener.stopCount, 0) + + notificationCenter.post(name: UIApplication.willResignActiveNotification, object: nil) + XCTAssertEqual(listener.startCount, 1) + XCTAssertEqual(listener.stopCount, 1) + + notificationCenter.post(name: UIApplication.didBecomeActiveNotification, object: nil) + XCTAssertEqual(listener.startCount, 2) + XCTAssertEqual(listener.stopCount, 1) + } + + func testAppActivityNotificationsAreIgnoredBeforeMonitoringStarts() { + let notificationCenter = NotificationCenter() + let listener = MockRefreshRateListener() + let monitor = JSRefreshRateMonitor( + notificationCenter: notificationCenter, + queue: DispatchQueueMock(), + listenerFactory: { _, _ in listener } + ) + + withExtendedLifetime(monitor) { + notificationCenter.post(name: UIApplication.willResignActiveNotification, object: nil) + notificationCenter.post(name: UIApplication.didBecomeActiveNotification, object: nil) + } + + XCTAssertEqual(listener.startCount, 0) + XCTAssertEqual(listener.stopCount, 0) + } + + func testFirstFrameAfterRestartIsNotMeasuredAgainstLastFrameBeforePause() { + var frameTimes: [Double] = [] + let listener = JSRefreshRateListener( + jsQueue: DispatchQueueMock(), + frameTimeCallback: { frameTime, _ in frameTimes.append(frameTime) } + ) + defer { listener.stop() } + + listener.start() + listener.onFrame(timestamp: 10.0, targetTimestamp: 10.016) + listener.onFrame(timestamp: 10.016, targetTimestamp: 10.032) + + // App goes to the background for a minute + listener.stop() + listener.start() + listener.onFrame(timestamp: 70.0, targetTimestamp: 70.016) + listener.onFrame(timestamp: 70.016, targetTimestamp: 70.032) + + XCTAssertEqual(frameTimes.count, 2) + XCTAssertEqual(frameTimes[0], 0.016, accuracy: 0.0001) + XCTAssertEqual(frameTimes[1], 0.016, accuracy: 0.0001) + } +} + +private final class MockRefreshRateListener: RefreshRateListener { + private(set) var startCount = 0 + private(set) var stopCount = 0 + + func start() { + startCount += 1 + } + + func stop() { + stopCount += 1 + } +}