Skip to content

runJsBundleStart performance marker dropped on JS reload due to APP_STARTUP_START reset cascade in ReactInstance::loadScript (regression in 0.83) #56339

@allenzhaoyijia111

Description

@allenzhaoyijia111

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

  1. Create or upgrade an app to React Native ≥0.83.0 with the new architecture enabled.
  2. 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).
  3. Subscribe to the runJsBundleStart and runJsBundleEnd marks and call performance.measure(...) between them when runJsBundleEnd arrives. 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 });
}, []);
  1. Build and launch on iOS — the metric is captured correctly. ✅
  2. 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_START was called in loadScript; 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    Needs: AttentionIssues where the author has responded to feedback.Needs: ReproThis issue could be improved with a clear list of steps to reproduce the issue.

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions