Skip to content

chore: replace @shopify/react-native-performance with RN's built-in startup timing#7424

Draft
janicduplessis wants to merge 1 commit intodevelopfrom
@janic/perf-rn-builtin
Draft

chore: replace @shopify/react-native-performance with RN's built-in startup timing#7424
janicduplessis wants to merge 1 commit intodevelopfrom
@janic/perf-rn-builtin

Conversation

@janicduplessis
Copy link
Copy Markdown
Contributor

@janicduplessis janicduplessis commented Apr 30, 2026

What changed (plus any additional context for devs)

Drops @shopify/react-native-performance (unmaintained since Aug 2022, has an open Kotlin override-variance compile bug on RN 0.81+ that we're patching around) and replaces it with React Native's built-in performance.rnStartupTiming API. The performance.report and Performance Wallet Initialize Time Amplitude events are preserved.

performance.rnStartupTiming.startTime is the same shape of signal we get today β€” a process-start timestamp captured natively before the JS bundle loads β€” but exposed via RN's own StartupLogger singleton instead of a third-party module + custom patch. RN itself doesn't fire the marker by default β€” the host app has to fire APP_STARTUP_START from native code, which is exactly what the Shopify lib's ReactNativePerformance.onAppStarted() was doing.

Native wiring

  • Android β€” MainApplication.kt: ReactMarker.logMarker(ReactMarkerConstants.APP_STARTUP_START) as the first line of onCreate. Pre-bridge calls are buffered in sNativeReactMarkerQueue and replayed once JNI loads (ReactMarker.java).
  • iOS β€” AppDelegate.swift: RCTPerformanceLogger().markStart(for: .appStartup) from application(_:didFinishLaunchingWithOptions:). markStart fires ReactMarker::logMarkerDone(APP_STARTUP_START) which writes to a static C++ singleton, so the throwaway logger doesn't need to be retained. The bridging header gains <React/RCTPLTag.h> and <React/RCTPerformanceLogger.h> so Swift can see the API.

JS

  • src/performance/start-time/index.ts reads performance.rnStartupTiming.startTime, falling back to performance.now() when absent (e.g. in jest, where the TurboModule isn't wired).
  • src/performance/tracking/index.ts switches Date.now() β†’ performance.now() everywhere reports anchor time. With the Shopify lib we were mixing wall-clock (Date.now() for segment math) and mach time (nativeStartupTimestamp from the lib's iOS captures) β€” that's a latent bug the new wiring fixes incidentally, since rnStartupTiming.startTime and performance.now() are both steady_clock on both platforms.
  • src/App.tsx drops <PerformanceProfiler> and the onReportPrepared callback.
  • WelcomeScreen and WalletScreen drop their <PerformanceMeasureView> wraps.

Where the "TTI" signal comes from now

The Shopify lib's <PerformanceProfiler> had a render-stability heuristic to detect "the screen has settled" and fire its onReportPrepared callback β€” that's where the tti segment of performance.report was logged. The replacement signal is splash-hide: useHideSplashScreen.ts already gates a once-only block on first hide; that's where we now log the tti segment, finish initialScreenInteractiveRender, and emit appStartup.

This is slightly earlier than the prior heuristic on WalletScreen (where <PerformanceMeasureView interactive={!isLoadingUserAssets}> waited for assets to load before reporting interactive). For an existing wallet, expect reported TTI to drop by the asset-load delta β€” closer to "first paint" than "fully populated screen". This was a deliberate trade-off: simpler signal, no third-party heuristic, easy to reason about. If we want the asset-load gating back later, that's one extra useEffect in the screen.

Build

  • @shopify/react-native-performance removed from package.json + yarn.lock.
  • patches/@shopify+react-native-performance+4.1.2.patch deleted.
  • ios/Podfile.lock regenerated. Drops ReactNativePerformance. Side-effect: picks up an RNFlashList 1.8.2 β†’ 1.8.3 and react-native-udp checksum drift that were already inconsistent between develop's package.json and Podfile.lock β€” pod install resolved the mismatch on the way through.

Screen recordings / screenshots

N/A β€” no visual changes.

What to test

  • Cold start with no wallet β†’ Welcome screen renders, animation plays, performance.report arrives in Amplitude.
  • Cold start with a wallet β†’ Wallet screen renders, asset list scrolls, performance.report arrives in Amplitude. (Expect TTI value to be slightly lower than baseline β€” see "Where the TTI signal comes from now" above.)
  • Onboarding: Welcome β†’ create wallet β†’ Wallet screen, no native crashes.
  • Splash hides on first WalletScreen / WelcomeScreen mount; poolboy easter-egg sound still plays once.
  • WalletConnect: deeplink connect + sign roundtrip.
  • Wallet import from seed phrase end-to-end.
  • Android builds cleanly (yarn android) β€” ReactMarker.logMarker(APP_STARTUP_START) is the load-bearing change here.
  • iOS: bundle exec pod install succeeds (drops react-native-performance), yarn ios succeeds. Confirm the bridging-header import resolves (<React/RCTPLTag.h>, <React/RCTPerformanceLogger.h>).
  • In Amplitude: confirm performance.report events still arrive, tti segment is non-zero and roughly tracks baseline (allowing for the <PerformanceMeasureView> semantic change above).

@janicduplessis janicduplessis force-pushed the @janic/perf-rn-builtin branch 2 times, most recently from 0dd50b3 to 7de3906 Compare April 30, 2026 20:29
…tartup timing

Drops the unmaintained `@shopify/react-native-performance` lib + its patch and
sources the cold-start anchor from React Native's built-in
`performance.rnStartupTiming.startTime` instead. Existing `performance.report`
Amplitude event shape is preserved.

Native wiring (replaces ReactNativePerformance.onAppStarted):
- Android: ReactMarker.logMarker(APP_STARTUP_START) at the top of
  MainApplication.onCreate. Pre-bridge calls are buffered in
  sNativeReactMarkerQueue and replayed once JNI loads.
- iOS: RCTPerformanceLogger().markStart(for: .appStartup) in
  application(_:didFinishLaunchingWithOptions:). markStart fires
  ReactMarker::logMarkerDone(APP_STARTUP_START), which writes to a static
  C++ singleton β€” no need to retain the logger instance.

JS:
- src/performance/start-time reads performance.rnStartupTiming.startTime
  (falls back to performance.now() if absent β€” e.g. in tests).
- src/performance/tracking switches Date.now() to performance.now() so all
  durations and segment timings are in the same monotonic clock domain as
  rnStartupTiming. (steady_clock on both platforms.)
- src/App.tsx drops <PerformanceProfiler> and onReportPrepared.
- WelcomeScreen / WalletScreen drop their <PerformanceMeasureView> wraps.

The "TTI" trigger that previously came from <PerformanceProfiler>'s
render-stability heuristic now fires from useHideSplashScreen on first
splash-hide β€” same once-only flag, fires the tti segment, finishes
initialScreenInteractiveRender, and emits the appStartup report.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant