Skip to content

new NativeEventEmitter() crashes on bridgeless cold start (New Arch) #479

Description

@jonstuebe

Summary

On the New Architecture (bridgeless), importing react-native-blob-util can intermittently crash at startup with:

Invariant Violation: new NativeEventEmitter() requires a non-null argument.

The package constructs a NativeEventEmitter at module top-level in two files, passing the TurboModule spec resolved via the nullable TurboModuleRegistry.get('ReactNativeBlobUtil'). During a bridgeless cold start, the module can be imported before that TurboModule has registered, so the spec is null and the eager new NativeEventEmitter(null) trips the iOS-only invariant.

Environment

  • react-native-blob-util: 0.24.10 (also present on master)
  • React Native: 0.81+ with New Architecture / bridgeless enabled (repro'd on RN 0.86 / Expo SDK 57)
  • Platform: iOS

Root cause

Two module-scope constructions, both unguarded:

  • fetch.js:10const eventEmitter = new NativeEventEmitter(ReactNativeBlobUtil);
  • class/ReactNativeBlobUtilReadStream.js:10const emitter = new NativeEventEmitter(ReactNativeBlobUtil);

where ReactNativeBlobUtil is:

  • codegenSpecs/NativeBlobUtils.js:84export default (TurboModuleRegistry.get<Spec>('ReactNativeBlobUtil'): ?Spec);

TurboModuleRegistry.get() (unlike getEnforcing()) returns null when the module isn't registered yet. Because both constructions run at import time and fs.js/fetch.js (and therefore index.js) pull in ReactNativeBlobUtilReadStream, simply importing the package during the cold-start window is enough to crash. It's a race, so it's intermittent — more likely on slower devices / heavier startups.

This is the same class of bug the React Native team fixed in core Keyboard.js by deferring the emitter construction.

Reproduction

  1. New Arch (bridgeless) app on iOS.
  2. Import react-native-blob-util early in startup (e.g. transitively, before first render).
  3. Cold-launch repeatedly — a fraction of launches throw the invariant during module init.

Proposed fix

Construct each NativeEventEmitter lazily on first use behind a small getter, so it happens after the TurboModule has registered. Behaviour is unchanged for real usage — the emitters are only used to addListener inside fetch() and the read-stream constructor. Sketch:

// fetch.js
let eventEmitter = null;
function getEventEmitter() {
  if (eventEmitter == null) {
   eventEmitter = new NativeEventEmitter(ReactNativeBlobUtil);
   eventEmitter.addListener('ReactNativeBlobUtilMessage', /* … */);
  }
  return eventEmitter;
}
// …then getEventEmitter().addListener(…) at each call site.

Same treatment for ReactNativeBlobUtilReadStream.js.

One behaviour note worth a maintainer decision: the global ReactNativeBlobUtilMessage diagnostic listener currently registers at import; deferring it to first fetch() means a process that only uses the fs/file:// APIs wouldn't receive that global diagnostic (per-operation handling is unaffected). If that matters, the message listener could be attached from a shared first-use hook covering both the HTTP and fs paths.

I'm running this as a local patch and happy to open a PR if the approach looks good.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions