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>
This commit is contained in:
Colby McHenry
2026-06-04 15:13:58 -04:00
parent d06a5ec890
commit 74b599c86b
3 changed files with 56 additions and 4 deletions
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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