Skip to content
Open
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
44 changes: 31 additions & 13 deletions packages/react-native/React/DevSupport/RCTFrameTimingsObserver.mm
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,14 @@ @implementation RCTFrameTimingsObserver {
std::optional<FrameData> _lastFrameData;

std::atomic<bool> _encodingInProgress;

// Offset (in nanoseconds) from mach_absolute_time (CLOCK_UPTIME_RAW) to
// steady_clock (CLOCK_MONOTONIC_RAW). CADisplayLink timestamps use
// mach_absolute_time which excludes sleep time, while HighResTimeStamp
// uses steady_clock which includes it. Without this correction, all
// CADisplayLink timestamps are shifted into the past by cumulative device
// sleep time.
int64_t _clockOffsetNanos;
}

- (instancetype)initWithScreenshotsEnabled:(BOOL)screenshotsEnabled callback:(RCTFrameTimingCallback)callback
Expand All @@ -79,14 +87,25 @@ - (void)start
_frameCounter = 0;
_lastScreenshotHash = 0;
_encodingInProgress.store(false, std::memory_order_relaxed);

// Compute the offset between steady_clock and mach_absolute_time once at
// start, so we can correctly convert CADisplayLink timestamps.
{
auto steadyNow = std::chrono::steady_clock::now();
CFTimeInterval machNow = CACurrentMediaTime();
auto steadyNanos = std::chrono::duration_cast<std::chrono::nanoseconds>(steadyNow.time_since_epoch()).count();
_clockOffsetNanos = steadyNanos - static_cast<int64_t>(machNow * 1e9);
}

{
std::lock_guard<std::mutex> lock(_lastFrameMutex);
_lastFrameData.reset();
}

// Emit initial frame event
// Emit initial render frame with screenshot
auto now = HighResTimeStamp::now();
[self _emitFrameTimingWithBeginTimestamp:now endTimestamp:now];
auto initialFrameEnd = now + HighResDuration::fromNanoseconds(16'600'000);
[self _emitFrameTimingWithBeginTimestamp:now endTimestamp:initialFrameEnd];

_displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(_displayLinkTick:)];
[_displayLink addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes];
Expand All @@ -105,11 +124,12 @@ - (void)stop

- (void)_displayLinkTick:(CADisplayLink *)sender
{
// CADisplayLink.timestamp and targetTimestamp are in the same timebase as
// CACurrentMediaTime() / mach_absolute_time(), which on Apple platforms maps
// to CLOCK_UPTIME_RAW — the same clock backing std::chrono::steady_clock.
auto beginNanos = static_cast<int64_t>(sender.timestamp * 1e9);
auto endNanos = static_cast<int64_t>(sender.targetTimestamp * 1e9);
// CADisplayLink.timestamp and targetTimestamp use CACurrentMediaTime() /
// mach_absolute_time() (CLOCK_UPTIME_RAW), which excludes system sleep time.
// Apply the offset computed at start to convert to the steady_clock
// (CLOCK_MONOTONIC_RAW) timebase used by HighResTimeStamp.
auto beginNanos = static_cast<int64_t>(sender.timestamp * 1e9) + _clockOffsetNanos;
auto endNanos = static_cast<int64_t>(sender.targetTimestamp * 1e9) + _clockOffsetNanos;

auto beginTimestamp = HighResTimeStamp::fromChronoSteadyClockTimePoint(
std::chrono::steady_clock::time_point(std::chrono::nanoseconds(beginNanos)));
Expand All @@ -136,12 +156,10 @@ - (void)_emitFrameTimingWithBeginTimestamp:(HighResTimeStamp)beginTimestamp endT

UIImage *image = [self _captureScreenshot];
if (image == nil) {
// Failed to capture (e.g. no window, duplicate hash) - emit without screenshot
[self _emitFrameEventWithFrameId:frameId
threadId:threadId
beginTimestamp:beginTimestamp
endTimestamp:endTimestamp
screenshot:std::nullopt];
// Screenshot unchanged (duplicate hash) or capture failed — don't emit
// a frame event. The serializer will fill the resulting gap with an idle
// frame, matching Chrome's native behavior where idle = vsync with no
// new rendering.
return;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,9 @@ internal class FrameTimingsObserver(
lastFrameBuffer.set(null)
isTracing = true

// Emit initial frame event
// Emit initial render frame with screenshot
val timestamp = System.nanoTime()
emitFrameTiming(timestamp, timestamp)
emitFrameTiming(timestamp, timestamp + INITIAL_FRAME_DURATION_NS)

currentWindow?.addOnFrameMetricsAvailableListener(frameMetricsListener, mainHandler)
}
Expand Down Expand Up @@ -263,6 +263,9 @@ internal class FrameTimingsObserver(
}

companion object {
// Spans ~one vsync at 60Hz.
private const val INITIAL_FRAME_DURATION_NS = 16_600_000L

private const val SCREENSHOT_SCALE_FACTOR = 1.0f
private const val SCREENSHOT_QUALITY = 80

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @generated SignedSource<<6348c0cc9285f2bac6df9986155b584f>>
* @generated SignedSource<<706d55fd8d83179f0aa3a33fc5fe6b7d>>
*/

/**
Expand Down Expand Up @@ -145,7 +145,7 @@ public open class ReactNativeFeatureFlagsDefaults : ReactNativeFeatureFlagsProvi

override fun fuseboxEnabledRelease(): Boolean = false

override fun fuseboxFrameRecordingEnabled(): Boolean = false
override fun fuseboxFrameRecordingEnabled(): Boolean = true

override fun fuseboxNetworkInspectionEnabled(): Boolean = true

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
#include "TraceEventGenerator.h"
#include "TraceEventSerializer.h"

#include <algorithm>

namespace facebook::react::jsinspector_modern::tracing {

namespace {
Expand All @@ -20,6 +22,11 @@ namespace {
*/
constexpr int FALLBACK_LAYER_TREE_ID = 1;

// Minimum gap between consecutive frames before emitting an idle frame.
// One 60 Hz vsync interval — smaller gaps are normal inter-frame time, not
// genuine idle periods (e.g. Android TOTAL_DURATION < vsync interval).
const auto MIN_IDLE_GAP = HighResDuration::fromNanoseconds(16'667'000);

} // namespace

/* static */ void HostTracingProfileSerializer::emitAsDataCollectedChunks(
Expand Down Expand Up @@ -104,11 +111,83 @@ constexpr int FALLBACK_LAYER_TREE_ID = 1;
TraceEventSerializer::estimateJsonSize(serializedSetLayerTreeId);
chunk.push_back(std::move(serializedSetLayerTreeId));

// Filter out frames that started before recording began. On Android,
// FrameMetrics may deliver frames from app startup that predate the recording
// session; on iOS the first CADisplayLink callback reports sender.timestamp
// (the previous vsync) which can be before the recording start. These would
// otherwise appear as large pre-recording render frames in the timeline.
frameTimings.erase(
std::remove_if(
frameTimings.begin(),
frameTimings.end(),
[&recordingStartTimestamp](const FrameTimingSequence& ft) {
return ft.beginTimestamp < recordingStartTimestamp;
}),
frameTimings.end());

if (frameTimings.empty()) {
chunkCallback(std::move(chunk));
return;
}

// Sort frames by beginTimestamp to handle out-of-order arrivals caused by
// async screenshot encoding. The initial synthetic frame may arrive in the
// buffer after real frames because its screenshot encoding takes longer than
// one vsync (~16ms). Sorting ensures the idle-gap detection loop below sees
// frames in chronological order.
std::sort(
frameTimings.begin(),
frameTimings.end(),
[](const FrameTimingSequence& a, const FrameTimingSequence& b) {
return a.beginTimestamp < b.beginTimestamp;
});

// Compute the next available sequence ID for synthetic idle frames.
FrameSequenceId nextIdleSeqId = 0;
for (const auto& ft : frameTimings) {
nextIdleSeqId = std::max(nextIdleSeqId, ft.id + 1);
}

std::optional<HighResTimeStamp> prevEndTimestamp;

for (auto&& frameTimingSequence : frameTimings) {
// Serialize all events for this frame.
folly::dynamic frameEvents = folly::dynamic::array();
size_t totalFrameBytes = 0;

// Detect idle period: gap between previous frame's end and this frame's
// begin. Emit NeedsBeginFrameChanged + BeginFrame to fill the gap.
// Chrome DevTools renders a BeginFrame without a corresponding DrawFrame
// as an "Idle frame" in the Frames track.
if (prevEndTimestamp.has_value() &&
(frameTimingSequence.beginTimestamp - *prevEndTimestamp) >
MIN_IDLE_GAP) {
auto needsBeginFrameEvent =
TraceEventGenerator::createNeedsBeginFrameChangedEvent(
FALLBACK_LAYER_TREE_ID,
*prevEndTimestamp,
processId,
frameTimingSequence.threadId);
auto serializedNeedsBeginFrame =
TraceEventSerializer::serialize(std::move(needsBeginFrameEvent));
totalFrameBytes +=
TraceEventSerializer::estimateJsonSize(serializedNeedsBeginFrame);
frameEvents.push_back(std::move(serializedNeedsBeginFrame));

auto idleBeginEvent = TraceEventGenerator::createIdleBeginFrameEvent(
nextIdleSeqId++,
FALLBACK_LAYER_TREE_ID,
*prevEndTimestamp,
processId,
frameTimingSequence.threadId);

auto serializedIdleBegin =
TraceEventSerializer::serialize(std::move(idleBeginEvent));
totalFrameBytes +=
TraceEventSerializer::estimateJsonSize(serializedIdleBegin);
frameEvents.push_back(std::move(serializedIdleBegin));
}

auto [beginDrawingEvent, endDrawingEvent] =
TraceEventGenerator::createFrameTimingsEvents(
frameTimingSequence.id,
Expand Down Expand Up @@ -155,6 +234,8 @@ constexpr int FALLBACK_LAYER_TREE_ID = 1;
chunk.push_back(std::move(frameEvent));
}
currentChunkBytes += totalFrameBytes;

prevEndTimestamp = frameTimingSequence.endTimestamp;
}

if (!chunk.empty()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,47 @@ namespace facebook::react::jsinspector_modern::tracing {
};
}

/* static */ TraceEvent TraceEventGenerator::createNeedsBeginFrameChangedEvent(
int layerTreeId,
HighResTimeStamp timestamp,
ProcessId processId,
ThreadId threadId) {
folly::dynamic data = folly::dynamic::object("needsBeginFrame", 1);

return TraceEvent{
.name = "NeedsBeginFrameChanged",
.cat = {Category::Frame},
.ph = 'I',
.ts = timestamp,
.pid = processId,
.s = 't',
.tid = threadId,
.args = folly::dynamic::object("layerTreeId", layerTreeId)(
"data", std::move(data)),
};
}

/* static */ TraceEvent TraceEventGenerator::createIdleBeginFrameEvent(
FrameSequenceId sequenceId,
int layerTreeId,
HighResTimeStamp timestamp,
ProcessId processId,
ThreadId threadId) {
folly::dynamic args = folly::dynamic::object("frameSeqId", sequenceId)(
"layerTreeId", layerTreeId);

return TraceEvent{
.name = "BeginFrame",
.cat = {Category::Frame},
.ph = 'I',
.ts = timestamp,
.pid = processId,
.s = 't',
.tid = threadId,
.args = std::move(args),
};
}

/* static */ std::pair<TraceEvent, TraceEvent>
TraceEventGenerator::createFrameTimingsEvents(
uint64_t sequenceId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,29 @@ class TraceEventGenerator {
HighResTimeStamp timestamp);

/**
* Creates canonical "BeginFrame", "Commit", "DrawFrame" trace events.
* Creates a "NeedsBeginFrameChanged" trace event to mark the start of an
* idle frame period.
*/
static TraceEvent createNeedsBeginFrameChangedEvent(
int layerTreeId,
HighResTimeStamp timestamp,
ProcessId processId,
ThreadId threadId);

/**
* Creates a single "BeginFrame" trace event for an idle frame (no
* DrawFrame). Chrome DevTools renders a BeginFrame without a corresponding
* DrawFrame as an "Idle frame" in the Frames track.
*/
static TraceEvent createIdleBeginFrameEvent(
FrameSequenceId sequenceId,
int layerTreeId,
HighResTimeStamp timestamp,
ProcessId processId,
ThreadId threadId);

/**
* Creates canonical "BeginFrame", "DrawFrame" trace events.
*/
static std::pair<TraceEvent, TraceEvent> createFrameTimingsEvents(
FrameSequenceId sequenceId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @generated SignedSource<<21c6a4ee6b9c64c4b136798e558dff8f>>
* @generated SignedSource<<c38dee9e22f663c3ffb5c9d8c7671e99>>
*/

/**
Expand Down Expand Up @@ -272,7 +272,7 @@ class ReactNativeFeatureFlagsDefaults : public ReactNativeFeatureFlagsProvider {
}

bool fuseboxFrameRecordingEnabled() override {
return false;
return true;
}

bool fuseboxNetworkInspectionEnabled() override {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -699,7 +699,7 @@ const definitions: FeatureFlagDefinitions = {
ossReleaseStage: 'none',
},
fuseboxFrameRecordingEnabled: {
defaultValue: false,
defaultValue: true,
metadata: {
dateAdded: '2026-03-05',
description:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @generated SignedSource<<0d7e830593d2da938af59f9a215e2380>>
* @generated SignedSource<<4027f88532d5b7d8bc23d069f494729f>>
* @flow strict
* @noformat
*/
Expand Down Expand Up @@ -444,7 +444,7 @@ export const fuseboxEnabledRelease: Getter<boolean> = createNativeFlagGetter('fu
/**
* Enable frame timings and screenshots support in the React Native DevTools CDP backend. This flag is global and should not be changed across React Host lifetimes.
*/
export const fuseboxFrameRecordingEnabled: Getter<boolean> = createNativeFlagGetter('fuseboxFrameRecordingEnabled', false);
export const fuseboxFrameRecordingEnabled: Getter<boolean> = createNativeFlagGetter('fuseboxFrameRecordingEnabled', true);
/**
* Enable network inspection support in the React Native DevTools CDP backend. Requires `enableBridgelessArchitecture`. This flag is global and should not be changed across React Host lifetimes.
*/
Expand Down
Loading