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
co-authored by Claude Opus 4.8
parent d06a5ec890
commit 74b599c86b
3 changed files with 56 additions and 4 deletions
+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