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

feat(impact): RN native→JS events via the sendEvent(ctx, "X", body) wrapper

Many React Native libraries (react-native-device-info and others) emit events
through a custom `sendEvent(reactContext, "eventName", body)` helper that
internally calls `…emit(eventName, …)` with the event name as a VARIABLE — so
RN_JVM_EMIT_RE (which matches `.emit("literal", …)`) saw only the variable and
produced no event edge. The literal event name lives in the wrapper CALL.

Add RN_NATIVE_SENDEVENT_RE — captures the first string literal inside a
`sendEvent(...)` call (multi-line tolerant; `[^;{}]*?` keeps it to one statement
so the wrapper DEFINITION, whose `(` is followed by `… ) {`, never matches).
Wired for Java/Kotlin and Swift. A native method firing `sendEvent(…, "X", …)`
now links to the JS `addListener('X', handler)`.

Measured on react-native-device-info: the Java-side battery/power/headphone
events now connect to their JS hooks (usePowerState, useBatteryLevel, …) —
previously only the ObjC `sendEventWithName:` side did. Full suite green.

(Expo's constant-based events — `sendEvent(batteryLevelDidChange, …)` where the
name is a `const` — still need constant resolution; tracked as a follow-up.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Colby McHenry 2 долоо хоног өмнө
parent
commit
74b599c86b

+ 1 - 0
CHANGELOG.md

@@ -15,6 +15,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
 ### Fixes
 
+- React Native native→JS events now connect through the common `sendEvent(context, "X", body)` wrapper. Many libraries (react-native-device-info and others) wrap the event emitter behind a helper whose `.emit(eventName, …)` takes a *variable*, so the matcher — which looked for `.emit("literal", …)` — missed it; the literal event name actually lives in the wrapper call. Now a native method that fires `sendEvent(…, "batteryLevelChanged", …)` links to the JS `addListener('batteryLevelChanged', …)` handler, so editing the native emitter surfaces the JS subscriber. (React Native)
 - React Native / Expo cross-language bridges are more complete and more precise. An Expo Module method declared with a generic type — Android's `AsyncFunction<Float>("getBatteryLevelAsync")` — is now indexed (the `<Float>` used to defeat the matcher, so every Android Expo method was dropped and a JS call resolved only to the iOS Swift impl). The iOS and Android implementations of the same JS-visible method — both Expo Modules and classic NativeModules (`@ReactMethod` on Android, the matching method on iOS) — are now linked to each other, so a JS call that resolves to one platform still reaches the other and editing either platform's native code surfaces the JS caller. And a `Type.member` static read in native code (e.g. Android's `BatteryManager.EXTRA_LEVEL`) no longer falsely links to a coincidentally same-named class in another language (a web `BatteryManager`) — type references stay within a language family, while genuine cross-language bridges (config→code, JS↔native calls) are unaffected. (React Native, Expo)
 - `codegraph affected` now reports the tests and files that actually depend on your changes. It used to follow only `import` statements — but those never cross file boundaries in CodeGraph's graph — so it returned **no affected tests for any change, in every language**. It now traces the real cross-file usage graph (calls, references, instantiations, and class `extends` / `implements`), so `git diff --name-only | codegraph affected` surfaces the test files that exercise the changed code. Circular-dependency detection, which had the same blind spot, now works too.
 - Blast radius, callers, and `codegraph affected` now recognize far more of the dependencies that were already in your code. A symbol now counts as a dependency whether it's called, used only in a type annotation inside a function body (`const items: Foo[] = []`), imported and placed in a registry array or passed as an argument, used as a JSX component, simply re-exported from a barrel (`export { X } from './x'`), or pulled in as a namespace (`import * as ns from '@/x'`) — including through tsconfig path aliases like `@/`. Previously only called, instantiated, or signature-typed symbols created a cross-file link, so a file that used a dependency in any other way could look like it depended on nothing — and the file that defined a widely-used symbol could look like nothing depended on it. The graph still indexes exactly the same symbols; it just connects the ones that were already there. (TypeScript/JavaScript)

+ 34 - 0
__tests__/rn-event-channel.test.ts

@@ -123,4 +123,38 @@ export function onMessage(listener: (m: any) => void) {
     expect(edge.target_name).toBe('onMessage');
     expect(['function', 'method']).toContain(edge.target_kind);
   });
+  it('synthesizes an edge from a Java sendEvent(ctx, "X", body) wrapper to a JS handler', async () => {
+    fs.writeFileSync(path.join(dir, 'package.json'), '{"dependencies":{"react-native":"^0.74.0"}}');
+    // The literal event name lives in the WRAPPER CALL, not in `.emit` (whose
+    // first arg is the `eventName` VARIABLE) — the common react-native-device-info
+    // shape that RN_JVM_EMIT_RE alone misses.
+    fs.writeFileSync(path.join(dir, 'BatteryModule.java'),
+      'public class BatteryModule extends ReactContextBaseJavaModule {\n' +
+      '  @Override public String getName() { return "BatteryModule"; }\n' +
+      '  public void onBatteryChanged() {\n' +
+      '    sendEvent(getReactApplicationContext(),\n' +
+      '      "myWrapperBatteryEvent", null);\n' +
+      '  }\n' +
+      '  private void sendEvent(ReactContext ctx, String eventName, Object data) {\n' +
+      '    ctx.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class).emit(eventName, data);\n' +
+      '  }\n' +
+      '}\n');
+    fs.writeFileSync(path.join(dir, 'index.ts'),
+      "function onBattery() {}\n" +
+      "emitter.addListener('myWrapperBatteryEvent', onBattery);\n");
+
+    const cg = await CodeGraph.init(dir, { silent: true });
+    await cg.indexAll();
+    const db = (cg as any).db.db;
+    const rows = db.prepare(
+      "SELECT s.name source_name, s.language sl, t.name target_name FROM edges e " +
+      "JOIN nodes s ON s.id=e.source JOIN nodes t ON t.id=e.target " +
+      "WHERE json_extract(e.metadata,'$.synthesizedBy')='rn-event-channel' AND json_extract(e.metadata,'$.event')='myWrapperBatteryEvent'"
+    ).all();
+    cg.close?.();
+    expect(rows.length).toBeGreaterThanOrEqual(1);
+    expect(rows[0].sl).toBe('java');
+    expect(rows[0].source_name).toBe('onBatteryChanged');
+    expect(rows[0].target_name).toBe('onBattery');
+  });
 });

+ 21 - 4
src/resolution/callback-synthesizer.ts

@@ -906,6 +906,14 @@ const RN_SWIFT_SEND_RE = /\bsendEvent\s*\(\s*withName\s*:\s*"([^"]+)"/g;
 // JVM source files in the consumer so we don't re-process JS emits
 // (which `eventEmitterEdges` already handles).
 const RN_JVM_EMIT_RE = /\.emit\s*\(\s*"([^"]+)"\s*,/g;
+// Custom `sendEvent(reactContext, "X", body)` wrapper — extremely common
+// (react-native-device-info and many libs wrap `DeviceEventManagerModule…emit`
+// behind a helper whose `.emit(eventName, …)` uses a VARIABLE, so RN_JVM_EMIT_RE
+// misses it; the literal lives in the wrapper CALL instead). Captures the first
+// string literal inside a `sendEvent(...)` call. `[^;{}]*?` keeps it on one
+// statement and stops at a block boundary, so the wrapper DEFINITION (whose `(`
+// is followed by `… ) {`) never matches. Multi-line tolerant. (java/kotlin/swift)
+const RN_NATIVE_SENDEVENT_RE = /\bsendEvent\s*\([^;{}]*?"([^"]+)"/g;
 
 function rnEventEdges(ctx: ResolutionContext): Edge[] {
   // Native dispatchers (source = the native method whose body sends the
@@ -945,17 +953,26 @@ function rnEventEdges(ctx: ResolutionContext): Edge[] {
       while ((m = RN_SWIFT_SEND_RE.exec(content))) {
         if (m[1]) addDispatcher(m[1], lineOf(m.index));
       }
+      RN_NATIVE_SENDEVENT_RE.lastIndex = 0;
+      while ((m = RN_NATIVE_SENDEVENT_RE.exec(content))) {
+        if (m[1]) addDispatcher(m[1], lineOf(m.index));
+      }
     }
 
-    // JVM side: `.emit("X", …)` in Java/Kotlin. (We pattern-match
-    // anywhere in the file; the JS in-language path uses a separate
-    // emitter object pattern and is already handled by eventEmitterEdges.)
+    // JVM side: `.emit("X", …)` in Java/Kotlin, plus the common
+    // `sendEvent(ctx, "X", body)` wrapper. (We pattern-match anywhere in the
+    // file; the JS in-language path uses a separate emitter object pattern and
+    // is already handled by eventEmitterEdges.)
     if (file.endsWith('.java') || file.endsWith('.kt')) {
-      RN_JVM_EMIT_RE.lastIndex = 0;
       let m: RegExpExecArray | null;
+      RN_JVM_EMIT_RE.lastIndex = 0;
       while ((m = RN_JVM_EMIT_RE.exec(content))) {
         if (m[1]) addDispatcher(m[1], lineOf(m.index));
       }
+      RN_NATIVE_SENDEVENT_RE.lastIndex = 0;
+      while ((m = RN_NATIVE_SENDEVENT_RE.exec(content))) {
+        if (m[1]) addDispatcher(m[1], lineOf(m.index));
+      }
     }
 
     // JS subscribers (.addListener("X", handler)). Restrict to JS-family