-
Notifications
You must be signed in to change notification settings - Fork 25.1k
runJsBundleStart performance marker dropped on JS reload due to APP_STARTUP_START reset cascade in ReactInstance::loadScript (regression in 0.83) #56339
Description
Description
After commit 796d182 (PR #54255 "Add missing INIT_REACT_RUNTIME_START and APP_STARTUP_START in loadScript"), ReactInstance::loadScript triggers an unintended StartupLogger::reset() cascade on every JS reload. This causes runJSBundleStartTime (and initReactRuntimeStartTime) to be NaN-cleared after their NaN guards have already rejected the new value, while the corresponding STOP markers fire after the reset and successfully populate fresh values.
The asymmetric outcome is that on every JS reload (press R, dev menu reload, fast refresh), runJSBundleStartTime ends as NaN while runJSBundleEndTime ends as a valid timestamp. Any consumer that pairs them — notably react-native-performance@5.1.4's iOS native module — silently drops the runJsBundleStart mark (its int64_t cast of NaN becomes 0, which hits the if (mediaTime == 0) return; guard in RNPerformanceManager.mm::emitMarkNamed:withMediaTime:).
For applications calling performance.measure('runJsBundle', 'runJsBundleStart', 'runJsBundleEnd'), this surfaces as:
Failed to execute 'measure' on 'Performance': The mark 'runJsBundleStart' does not exist.
The bug only manifests on JS reload, not cold launch. Production cold-start telemetry is unaffected, so most teams won't notice — but it breaks dev iteration with a LogBox error on every refresh, and breaks any second-instance metrics for libraries that rely on StartupLogger.
Root cause walkthrough
StartupLogger::logStartupEvent in packages/react-native/ReactCommon/cxxreact/ReactMarker.cpp has a hidden side-effect on APP_STARTUP_START: if appStartupStartTime is already set, it calls reset() which NaN-clears all six startup fields. This was originally designed for warm-restart scenarios (the process survives but the app reopens), not for being triggered inside loadScript.
After PR #54255, ReactInstance::loadScript now calls APP_STARTUP_START from inside the bridgeless reload path. On a JS-only reload, the resulting sequence is:
// In ReactInstance::loadScript (current main, lines 240-246)
if (hasLogger) {
ReactMarker::logTaggedMarkerBridgeless(
ReactMarker::RUN_JS_BUNDLE_START, scriptName.c_str()); // step 1
ReactMarker::logMarkerBridgeless(ReactMarker::INIT_REACT_RUNTIME_START); // step 2
ReactMarker::logMarkerBridgeless(ReactMarker::APP_STARTUP_START); // step 3
}| Step | Call | Effect on StartupLogger (on second loadScript invocation) |
|---|---|---|
| 1 | RUN_JS_BUNDLE_START |
runJSBundleStartTime already non-NaN → no-op (still cold-start value) |
| 2 | INIT_REACT_RUNTIME_START |
initReactRuntimeStartTime already non-NaN → no-op |
| 3 | APP_STARTUP_START |
appStartupStartTime non-NaN → reset() NaN-clears all six fields, then sets appStartupStartTime = now |
| 4 | runtime.evaluateJavaScript(...) |
(bundle executes) |
| 5 | RUN_JS_BUNDLE_STOP |
runJSBundleEndTime is now NaN (cleared in step 3) → populated with fresh time ✅ |
| 6 | INIT_REACT_RUNTIME_STOP |
NaN → populated ✅ |
| 7 | APP_STARTUP_STOP |
NaN → populated ✅ |
Final state on reload:
runJSBundleStartTime = NaN ← LOST (rejected by guard in step 1, then wiped in step 3)
runJSBundleEndTime = valid (new)
initReactRuntimeStart = NaN ← LOST (same reason)
initReactRuntimeEnd = valid (new)
appStartupStartTime = valid (new)
appStartupEndTime = valid (new)
Asymmetric outcome: STARTs get wiped after their guard rejects them; STOPs get wiped before their guard accepts them.
Steps to reproduce
- Create or upgrade an app to React Native ≥0.83.0 with the new architecture enabled.
- Install
react-native-performance@5.1.4(or any version since Adds bridging header helper to facilitate importing to swift #115 added the StartupLogger reads). - Subscribe to the
runJsBundleStartandrunJsBundleEndmarks and callperformance.measure(...)between them whenrunJsBundleEndarrives. Minimal example:
import { useEffect } from 'react';
import performance, { PerformanceObserver } from 'react-native-performance';
useEffect(() => {
new PerformanceObserver((list) => {
if (list.getEntries().some((e) => e.name === 'runJsBundleEnd')) {
performance.measure('runJsBundle', 'runJsBundleStart', 'runJsBundleEnd');
}
}).observe({ type: 'react-native-mark', buffered: true });
}, []);- Build and launch on iOS — the metric is captured correctly. ✅
- Press R in the dev menu to reload JS — the LogBox shows
Failed to execute 'measure' on 'Performance': The mark 'runJsBundleStart' does not exist.❌
You can also confirm the silent drop by checking Xcode console for Ignoring mark named runJsBundleStart as timestamp is not set from RNPerformanceManager.mm.
Happy to provide a minimal reproducer repo if needed.
React Native Version
0.83.4 (also reproduced in 0.84.1; same code in 0.85.0-rc.7 and main)
Affected Platforms
Runtime - iOS (new architecture / bridgeless)
Affected versions
- ✅ 0.81.x: not affected (only
RUN_JS_BUNDLE_STARTwas called inloadScript; the reset cascade was never triggered) - ❌ 0.83.0 - 0.83.4
- ❌ 0.84.0 - 0.84.1
- ❌ 0.85.0-rc.7
- ❌ main (0.86 nightly)
Suggested fix
Call StartupLogger::reset() explicitly at the top of the lambda in ReactInstance::loadScript, before the if (hasLogger) block. This makes the reset intent explicit rather than relying on the APP_STARTUP_START handler's side-effect, and ensures every loadScript call (including reloads) starts with a clean StartupLogger:
diff --git a/packages/react-native/ReactCommon/react/runtime/ReactInstance.cpp b/packages/react-native/ReactCommon/react/runtime/ReactInstance.cpp
@@ -237,6 +237,7 @@ void ReactInstance::loadScript(
if (beforeLoad) {
beforeLoad(runtime);
}
TraceSection s("ReactInstance::loadScript");
+ ReactMarker::StartupLogger::getInstance().reset();
bool hasLogger(ReactMarker::logTaggedMarkerBridgelessImpl != nullptr);
if (hasLogger) {
ReactMarker::logTaggedMarkerBridgeless(
ReactMarker::RUN_JS_BUNDLE_START, scriptName.c_str());A cleaner alternative would be to remove the auto-reset side effect from the APP_STARTUP_START handler in StartupLogger::logStartupEvent and require callers to invoke reset() explicitly.
Workaround for affected apps
Until this is fixed upstream, apps can defensively guard the measure() call:
if (performance.getEntriesByName('runJsBundleStart').length === 0) {
return; // skip metric on dev refresh
}
performance.measure('runJsBundle', 'runJsBundleStart', 'runJsBundleEnd');Or apply the upstream fix as a local patch via patch-package / pnpm patch until a release contains it.
Related
- PR that introduced the regression: Add missing INIT_REACT_RUNTIME_START and APP_STARTUP_START in loadScript #54255
- Issue that motivated Add missing INIT_REACT_RUNTIME_START and APP_STARTUP_START in loadScript #54255: Unbalanced calls start/end for tag 20 (and tag 19) #53818
react-native-performancePR that added the bridgelessStartupLoggerreads: Fix bridgeless mode on iOS oblador/react-native-performance#115