From d544780da1706f00c329b0b309446ff7a7175a7d Mon Sep 17 00:00:00 2001 From: Marc Rousavy Date: Mon, 3 Aug 2026 14:30:55 +0200 Subject: [PATCH] feat: Automatically call `FullyDrawnReporter` to improve production PGOs --- .../ReactAndroid/api/ReactAndroid.api | 3 + .../facebook/react/ReactActivityDelegate.java | 18 ++++++ .../java/com/facebook/react/ReactDelegate.kt | 63 +++++++++++++++++++ 3 files changed, 84 insertions(+) diff --git a/packages/react-native/ReactAndroid/api/ReactAndroid.api b/packages/react-native/ReactAndroid/api/ReactAndroid.api index b2095f8b4290..bb195c2b583e 100644 --- a/packages/react-native/ReactAndroid/api/ReactAndroid.api +++ b/packages/react-native/ReactAndroid/api/ReactAndroid.api @@ -102,6 +102,7 @@ public class com/facebook/react/ReactActivityDelegate { public fun getReactInstanceManager ()Lcom/facebook/react/ReactInstanceManager; protected fun getReactNativeHost ()Lcom/facebook/react/ReactNativeHost; protected fun isFabricEnabled ()Z + protected fun isFullyDrawnReportingEnabled ()Z protected fun isWideColorGamutEnabled ()Z protected fun loadApp (Ljava/lang/String;)V public fun onActivityResult (IILandroid/content/Intent;)V @@ -138,6 +139,7 @@ public class com/facebook/react/ReactDelegate { public final fun getReactInstanceManager ()Lcom/facebook/react/ReactInstanceManager; public final fun getReactRootView ()Lcom/facebook/react/ReactRootView; protected final fun isFabricEnabled ()Z + public final fun isFullyDrawnReportingEnabled ()Z public final fun loadApp ()V public final fun loadApp (Ljava/lang/String;)V public final fun onActivityResult (IILandroid/content/Intent;Z)V @@ -152,6 +154,7 @@ public class com/facebook/react/ReactDelegate { public final fun onUserLeaveHint ()V public final fun onWindowFocusChanged (Z)V public final fun reload ()V + public final fun setFullyDrawnReportingEnabled (Z)V public final fun setReactRootView (Lcom/facebook/react/ReactRootView;)V public final fun setReactSurface (Lcom/facebook/react/interfaces/fabric/ReactSurface;)V public final fun shouldShowDevMenuOrReload (ILandroid/view/KeyEvent;)Z diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/ReactActivityDelegate.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/ReactActivityDelegate.java index 3644cdade8ec..f2b8b13503e7 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/ReactActivityDelegate.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/ReactActivityDelegate.java @@ -168,6 +168,7 @@ protected ReactRootView createRootView() { } }; } + mReactDelegate.setFullyDrawnReportingEnabled(isFullyDrawnReportingEnabled()); if (mainComponentName != null) { LocalNetworkPermissionUtil.requestLocalNetworkAccessIfNeeded( getPlainActivity(), () -> loadApp(mainComponentName)); @@ -322,4 +323,21 @@ protected boolean isFabricEnabled() { protected boolean isWideColorGamutEnabled() { return false; } + + /** + * Controls whether {@link Activity#reportFullyDrawn()} is automatically triggered once the + * initial content of the React surface has appeared, via the activity's {@code + * androidx.activity.FullyDrawnReporter}. Android uses this signal to bound the startup window for + * profile guided compilation (Android 12+) and to report time-to-fully-drawn in Android vitals. + * + *

Apps that consider themselves fully drawn only later (e.g. once their initial data has been + * rendered) can keep this enabled and additionally register their own reporter on the activity's + * {@code FullyDrawnReporter} before the content appears, or override this method to return false + * to opt out entirely. + * + * @return true if fully drawn reporting is enabled for this Activity, false otherwise. + */ + protected boolean isFullyDrawnReportingEnabled() { + return true; + } } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/ReactDelegate.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/ReactDelegate.kt index 39eb047a5a5b..1da3584345c6 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/ReactDelegate.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/ReactDelegate.kt @@ -12,7 +12,10 @@ import android.content.Intent import android.content.res.Configuration import android.os.Bundle import android.view.KeyEvent +import androidx.activity.ComponentActivity import com.facebook.react.bridge.ReactContext +import com.facebook.react.bridge.ReactMarker +import com.facebook.react.bridge.ReactMarkerConstants import com.facebook.react.bridge.UiThreadUtil.runOnUiThread import com.facebook.react.devsupport.DoubleTapReloadRecognizer import com.facebook.react.devsupport.ReleaseDevSupportManager @@ -51,6 +54,27 @@ public open class ReactDelegate { */ protected val isFabricEnabled: Boolean = true + /** + * Controls whether this delegate reports the host [Activity] as fully drawn once the initial + * content of the React surface has appeared. + * + * When enabled (the default) and the host [Activity] is a [ComponentActivity], a reporter is + * added to the activity's [androidx.activity.FullyDrawnReporter] when the surface starts loading + * and removed once the first view of the surface is mounted, which calls + * [Activity.reportFullyDrawn] after the next frame is drawn. Android uses this signal to bound + * the startup window for profile guided compilation (Android 12+) and to report + * time-to-fully-drawn in Android vitals. + * + * Apps that consider themselves fully drawn only later (e.g. once their initial data has been + * rendered) can keep this enabled and additionally register their own reporter on the activity's + * [androidx.activity.FullyDrawnReporter] before the content appears. + * + * Must be set before [loadApp] to take effect. + */ + public var isFullyDrawnReportingEnabled: Boolean = true + + private var fullyDrawnMarkerListener: ReactMarker.MarkerListener? = null + /** * Do not use this constructor as it's not accounting for New Architecture at all. You should use * [ReactDelegate(Activity, ReactNativeHost, String, Bundle, boolean)] as it's the constructor @@ -318,6 +342,9 @@ public open class ReactDelegate { * @param appKey The ID of the app to load into the surface. */ public fun loadApp(appKey: String) { + if (isFullyDrawnReportingEnabled) { + reportFullyDrawnWhenContentAppears(appKey) + } // With Bridgeless enabled, create and start the surface if (ReactNativeNewArchitectureFeatureFlags.enableBridgelessArchitecture()) { val reactHost = reactHost @@ -340,6 +367,7 @@ public open class ReactDelegate { /** Stop the React surface started with [ReactDelegate.loadApp]. */ public fun unloadApp() { + cancelFullyDrawnReporting() if (ReactNativeNewArchitectureFeatureFlags.enableBridgelessArchitecture()) { reactSurface?.stop() reactSurface = null @@ -351,6 +379,41 @@ public open class ReactDelegate { } } + private fun reportFullyDrawnWhenContentAppears(appKey: String) { + if (fullyDrawnMarkerListener != null) { + return + } + val fullyDrawnReporter = (activity as? ComponentActivity)?.fullyDrawnReporter ?: return + if (fullyDrawnReporter.isFullyDrawnReported) { + return + } + fullyDrawnReporter.addReporter() + val listener = + object : ReactMarker.MarkerListener { + override fun logMarker(name: ReactMarkerConstants, tag: String?, instanceKey: Int) { + if (name == ReactMarkerConstants.CONTENT_APPEARED && tag == appKey) { + // CONTENT_APPEARED is logged on the UI thread, so this cannot race with + // cancelFullyDrawnReporting or a second loadApp. + if (fullyDrawnMarkerListener !== this) { + return + } + fullyDrawnMarkerListener = null + ReactMarker.removeListener(this) + fullyDrawnReporter.removeReporter() + } + } + } + fullyDrawnMarkerListener = listener + ReactMarker.addListener(listener) + } + + private fun cancelFullyDrawnReporting() { + val listener = fullyDrawnMarkerListener ?: return + fullyDrawnMarkerListener = null + ReactMarker.removeListener(listener) + (activity as? ComponentActivity)?.fullyDrawnReporter?.removeReporter() + } + public fun setReactSurface(reactSurface: ReactSurface?) { this.reactSurface = reactSurface }