From 74b599c86b013dfd3c272e1c4e4474ffb7ff3b0c Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Thu, 4 Jun 2026 15:13:58 -0400 Subject: [PATCH] =?UTF-8?q?feat(impact):=20RN=20native=E2=86=92JS=20events?= =?UTF-8?q?=20via=20the=20sendEvent(ctx,=20"X",=20body)=20wrapper?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CHANGELOG.md | 1 + __tests__/rn-event-channel.test.ts | 34 ++++++++++++++++++++++++++ src/resolution/callback-synthesizer.ts | 25 ++++++++++++++++--- 3 files changed, 56 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c87eaa745..25ccf4350 100644 --- a/CHANGELOG.md +++ b/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("getBatteryLevelAsync")` — is now indexed (the `` 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) diff --git a/__tests__/rn-event-channel.test.ts b/__tests__/rn-event-channel.test.ts index 7d84ba34a..f5debe7c5 100644 --- a/__tests__/rn-event-channel.test.ts +++ b/__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'); + }); }); diff --git a/src/resolution/callback-synthesizer.ts b/src/resolution/callback-synthesizer.ts index 918d261c7..13db14cf1 100644 --- a/src/resolution/callback-synthesizer.ts +++ b/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