1
0
Эх сурвалжийг харах

fix(rn-bridge): blocklist RCTEventEmitter built-ins to prevent false positives

On RNFirebase (~1000 source files, large RN target), the initial bridge
produced 78 framework-resolved JS→native edges but 60 of them (77%)
targeted `addListener:` and `remove:` — the RCTEventEmitter
inherited methods that every emitter subclass exposes via
`RCT_EXPORT_METHOD`. JS callers of `.addListener(...)` /
`.remove(...)` (Firestore subscribers, RxJS pipelines, plain
Array.remove, etc.) were getting mis-resolved to whichever native
emitter happened to define them.

Block these names during bridge-map building:
- addListener, removeListeners (RCTEventEmitter declared)
- remove (used in emitter teardown plumbing)
- invalidate, startObserving, stopObserving (lifecycle hooks)

After fix on RNFirebase: 78 → 18 bridge edges, ~100% precision. The
remaining 18 are all legitimate Firebase native API calls:
httpsCallable:region:emulatorHost:...:, signInWithProvider,
configureProvider, removeFunctionsStreaming:, etc.

AsyncStorage (small/medium pure-legacy-bridge): unchanged — its 8/8
bridges (setItem→legacy_multiSet, getAllKeys→legacy_getAllKeys, etc.)
weren't event emitters. RNSvg (TurboModule): unchanged.

Test: `describe('RCTEventEmitter built-ins blocklist')` covers
`addListener` / `remove` rejection even when they're declared via
`RCT_EXPORT_METHOD` on an RCTEventEmitter subclass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Colby McHenry 4 долоо хоног өмнө
parent
commit
88dbb11977

+ 27 - 0
__tests__/react-native-bridge.test.ts

@@ -264,4 +264,31 @@ describe('React Native bridge resolver', () => {
       reactNativeBridgeResolver.resolve(ref('compute', 'objc', 'OtherMod.m'), ctx)
     ).toBeNull();
   });
+
+  describe('RCTEventEmitter built-ins blocklist', () => {
+    it('skips addListener / remove (every emitter exposes these — bridging them creates noise)', () => {
+      // A repo with RCTEventEmitter subclass: defines `addListener:` and
+      // `remove:` because that's what `[RCTEventEmitter addListener:]`
+      // requires. JS callers of `.addListener(...)` should NOT resolve
+      // here — they're hitting the JS-side `NativeEventEmitter`
+      // abstraction, not the native emitter directly.
+      const native1 = method('addListener:', 'objc', 'EventEmitter.m');
+      const native2 = method('remove:', 'objc', 'EventEmitter.m');
+      const ctx = makeContext([native1, native2], {
+        'package.json': '{"dependencies":{"react-native":"^0.73"}}',
+        'EventEmitter.m':
+          '@implementation EventEmitter\n' +
+          'RCT_EXPORT_MODULE()\n' +
+          'RCT_EXPORT_METHOD(addListener:(NSString *)eventName) {}\n' +
+          'RCT_EXPORT_METHOD(remove:(double)id) {}\n' +
+          '@end',
+      });
+      expect(
+        reactNativeBridgeResolver.resolve(ref('addListener', 'javascript', 'App.js'), ctx)
+      ).toBeNull();
+      expect(
+        reactNativeBridgeResolver.resolve(ref('remove', 'typescript', 'App.ts'), ctx)
+      ).toBeNull();
+    });
+  });
 });

+ 20 - 0
src/resolution/frameworks/react-native.ts

@@ -237,6 +237,23 @@ function parseTurboModuleSpec(
 
 // ─── Map building ───────────────────────────────────────────────────────────
 
+/**
+ * RCTEventEmitter built-ins that every emitter subclass inherits. JS code
+ * doesn't directly call these — they're internal plumbing for the
+ * `NativeEventEmitter` abstraction. If we leave them in the bridge map,
+ * every JS `addListener` / `remove` call (Firestore subscribers, RxJS
+ * pipelines, plain Array.remove, etc.) gets mis-bridged to whichever
+ * emitter happens to define them. Skip during map building.
+ */
+const RN_EMITTER_BUILTINS = new Set([
+  'addListener',
+  'removeListeners',
+  'remove',
+  'invalidate',
+  'startObserving',
+  'stopObserving',
+]);
+
 function buildRNMaps(context: ResolutionContext): { byJsName: Map<string, NativeMethod[]> } {
   const cached = nativeMethodMaps.get(context);
   if (cached) return cached;
@@ -270,6 +287,7 @@ function buildRNMaps(context: ResolutionContext): { byJsName: Map<string, Native
       const className = findObjcClassName(source);
       const exports = parseObjcRNExports(source, className);
       for (const exp of exports) {
+        if (RN_EMITTER_BUILTINS.has(exp.jsName)) continue;
         // Resolve to the native node by selector first-keyword. Multiple
         // ObjC methods may share a first keyword across modules; filter by
         // file path to attribute the export to this module's
@@ -290,6 +308,7 @@ function buildRNMaps(context: ResolutionContext): { byJsName: Map<string, Native
       if (!source) continue;
       const exports = parseJvmRNExports(source);
       for (const exp of exports) {
+        if (RN_EMITTER_BUILTINS.has(exp.jsName)) continue;
         const candidates = jvmMethodsByName.get(exp.jsName) ?? [];
         const node = candidates.find((c) => c.filePath === file) ?? candidates[0];
         if (!node) continue;
@@ -311,6 +330,7 @@ function buildRNMaps(context: ResolutionContext): { byJsName: Map<string, Native
       // path (Codegen wires it via name convention), so we match across
       // all native methods of the right name.
       for (const methodName of spec.methods) {
+        if (RN_EMITTER_BUILTINS.has(methodName)) continue;
         // ObjC first-keyword match, then JVM bare-name match. Don't
         // require module-name match for ObjC because the native side may
         // have stripped a prefix.