diff --git a/packages/react-native/React/DevSupport/RCTFrameTimingsObserver.mm b/packages/react-native/React/DevSupport/RCTFrameTimingsObserver.mm index ec9a0ae01fba..f1832732c6c4 100644 --- a/packages/react-native/React/DevSupport/RCTFrameTimingsObserver.mm +++ b/packages/react-native/React/DevSupport/RCTFrameTimingsObserver.mm @@ -57,6 +57,14 @@ @implementation RCTFrameTimingsObserver { std::optional _lastFrameData; std::atomic _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 @@ -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(steadyNow.time_since_epoch()).count(); + _clockOffsetNanos = steadyNanos - static_cast(machNow * 1e9); + } + { std::lock_guard 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]; @@ -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(sender.timestamp * 1e9); - auto endNanos = static_cast(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(sender.timestamp * 1e9) + _clockOffsetNanos; + auto endNanos = static_cast(sender.targetTimestamp * 1e9) + _clockOffsetNanos; auto beginTimestamp = HighResTimeStamp::fromChronoSteadyClockTimePoint( std::chrono::steady_clock::time_point(std::chrono::nanoseconds(beginNanos))); @@ -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; } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/inspector/FrameTimingsObserver.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/inspector/FrameTimingsObserver.kt index a88ef73aa95b..c8ef781a3c8c 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/inspector/FrameTimingsObserver.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/inspector/FrameTimingsObserver.kt @@ -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) } @@ -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 diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsDefaults.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsDefaults.kt index d453d31e9991..fc489a95957e 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsDefaults.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsDefaults.kt @@ -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>> */ /** @@ -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 diff --git a/packages/react-native/ReactCommon/jsinspector-modern/tracing/HostTracingProfileSerializer.cpp b/packages/react-native/ReactCommon/jsinspector-modern/tracing/HostTracingProfileSerializer.cpp index 1053790a3532..87524940b6d2 100644 --- a/packages/react-native/ReactCommon/jsinspector-modern/tracing/HostTracingProfileSerializer.cpp +++ b/packages/react-native/ReactCommon/jsinspector-modern/tracing/HostTracingProfileSerializer.cpp @@ -10,6 +10,8 @@ #include "TraceEventGenerator.h" #include "TraceEventSerializer.h" +#include + namespace facebook::react::jsinspector_modern::tracing { namespace { @@ -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( @@ -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 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, @@ -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()) { diff --git a/packages/react-native/ReactCommon/jsinspector-modern/tracing/TraceEventGenerator.cpp b/packages/react-native/ReactCommon/jsinspector-modern/tracing/TraceEventGenerator.cpp index 215c25e1bd8a..17b4db5343ad 100644 --- a/packages/react-native/ReactCommon/jsinspector-modern/tracing/TraceEventGenerator.cpp +++ b/packages/react-native/ReactCommon/jsinspector-modern/tracing/TraceEventGenerator.cpp @@ -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 TraceEventGenerator::createFrameTimingsEvents( uint64_t sequenceId, diff --git a/packages/react-native/ReactCommon/jsinspector-modern/tracing/TraceEventGenerator.h b/packages/react-native/ReactCommon/jsinspector-modern/tracing/TraceEventGenerator.h index 898fba6ef729..1eaa959166c9 100644 --- a/packages/react-native/ReactCommon/jsinspector-modern/tracing/TraceEventGenerator.h +++ b/packages/react-native/ReactCommon/jsinspector-modern/tracing/TraceEventGenerator.h @@ -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 createFrameTimingsEvents( FrameSequenceId sequenceId, diff --git a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsDefaults.h b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsDefaults.h index f63db0e1490e..97d7854e1f92 100644 --- a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsDefaults.h +++ b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsDefaults.h @@ -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<> */ /** @@ -272,7 +272,7 @@ class ReactNativeFeatureFlagsDefaults : public ReactNativeFeatureFlagsProvider { } bool fuseboxFrameRecordingEnabled() override { - return false; + return true; } bool fuseboxNetworkInspectionEnabled() override { diff --git a/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js b/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js index bdce603658c0..d9f348b711bc 100644 --- a/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js +++ b/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js @@ -699,7 +699,7 @@ const definitions: FeatureFlagDefinitions = { ossReleaseStage: 'none', }, fuseboxFrameRecordingEnabled: { - defaultValue: false, + defaultValue: true, metadata: { dateAdded: '2026-03-05', description: diff --git a/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js b/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js index a59b9a32297b..20083e6079e9 100644 --- a/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js +++ b/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js @@ -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 */ @@ -444,7 +444,7 @@ export const fuseboxEnabledRelease: Getter = 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 = createNativeFlagGetter('fuseboxFrameRecordingEnabled', false); +export const fuseboxFrameRecordingEnabled: Getter = 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. */