fix(react-native): event names held in constants join the native↔JS channel (#2138)

The rn-event-channel pairs a native emit with a JS listener on the event's
LITERAL name. Most libraries name events through constants — NetInfo
listens with `addListener(PrivateTypes.DEVICE_CONNECTIVITY_EVENT, …)`, many
emit with `.emit(EVENT_NAME, …)` or `sendEventWithName:kEvent` — so their
channels were empty.

Identifier / member-chain event names are now read to the literal their
declaration holds (JS/TS const & enum, Java `static final`, Kotlin
`const val`, ObjC `NSString *const` / `#define`, Swift `let` / enum case),
scoped as the language scopes them: a bare name in the same file or behind
an import, `Owner.NAME` inside Owner or a namespace import's file. The
handler of a constant-named listener is a function in the same file, else
the enclosing one. A first cut that read constants by name alone turned a
`.emit(eventName, …)` parameter into some test's `eventName = "pong"`.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-09-30 07:57:43 +00:00
committed by GitHub
parent 6f798dce6b
commit 6a02dcaa76
3 changed files with 189 additions and 0 deletions
+1
View File
@@ -14,6 +14,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### Fixes
- React Native events whose name is held in a constant now link native code to the JavaScript that listens for them, like NetInfo's `addListener(PrivateTypes.DEVICE_CONNECTIVITY_EVENT, …)` or a Java `.emit(PROGRESS_EVENT, …)`. The constant is read the way the language scopes it, from the same file, an import or its owning class, so a parameter that happens to share a constant's name is never mistaken for it.
- React Native views declared with `requireNativeComponent('X')`, the older Paper style that react-native-maps and segmented-control use, now link from JavaScript to their native iOS and Android implementations, as Codegen specs already did, including in plain `.js` files. A JSX tag written under a different name than its component, like `<Autocomplete>` for `import Autocomplete from './autocomplete.vue'`, now links to the component it renders.
- A `super` call in an overriding method, like `super.viewDidLoad()`, `[super init]`, `base.Handle()`, `parent::setUp()` or `super().dispatch(…)`, no longer makes the method look like it calls itself. The call goes to the parent's version, so an override stops showing up as recursive and no longer lists itself among its own callers.
- Expo Router API routes (`app/hello+api.ts`) are now endpoints, one per exported method, like `GET /hello`, bound to the function that handles them. They used to show up as a screen named `/hello+api`. In a repository whose root declares Expo Router for an example app, a Next.js app in its own folder, like react-native-true-sheet's `docs/`, no longer gets Expo screens made from its files.
+83
View File
@@ -226,3 +226,86 @@ function tick() {}
expect(rows[1].registered_at).toBe('App.tsx:4');
});
});
describe('RN event channel — event names held in constants', () => {
let dir: string;
beforeEach(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'rn-event-const-'));
});
afterEach(() => {
fs.rmSync(dir, { recursive: true, force: true });
});
const write = (rel: string, content: string) => {
fs.mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true });
fs.writeFileSync(path.join(dir, rel), content);
};
const channel = (cg: CodeGraph) =>
((cg as any).db.db
.prepare(
`SELECT s.name s, t.name t, json_extract(e.metadata,'$.event') event 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' ORDER BY s, t`
)
.all() as Array<{ s: string; t: string; event: string }>).map((r) => `${r.s} -> ${r.t} (${r.event})`);
it('NetInfo: a JS listener on `PrivateTypes.DEVICE_CONNECTIVITY_EVENT`, native sends the literal', async () => {
write('package.json', '{"name":"netinfo","dependencies":{"react-native":"^0.73"}}');
write('ios/RNCNetInfo.m', `@implementation RNCNetInfo
- (void)connectionChanged {
[self sendEventWithName:@"netInfo.networkStatusDidChange" body:@{}];
}
@end
`);
write('src/internal/privateTypes.ts', `export const DEVICE_CONNECTIVITY_EVENT = 'netInfo.networkStatusDidChange';
`);
write('src/internal/state.ts', `import * as PrivateTypes from './privateTypes';
export default class State {
start(emitter: any) {
emitter.addListener(
PrivateTypes.DEVICE_CONNECTIVITY_EVENT,
this._handleNativeStateUpdate,
);
}
_handleNativeStateUpdate(state: unknown) { return state; }
}
`);
const cg = await CodeGraph.init(dir, { index: true });
try {
expect(channel(cg)).toEqual(['connectionChanged -> _handleNativeStateUpdate (netInfo.networkStatusDidChange)']);
} finally {
cg.close();
}
});
it('a Java constant emit and a Kotlin const val meet a JS literal listener', async () => {
write('package.json', '{"name":"lib","dependencies":{"react-native":"^0.73"}}');
write('android/src/main/java/app/Tracker.java', `package app;
public class Tracker {
private static final String PROGRESS_EVENT = "downloadProgress";
void report(ReactContext ctx) {
ctx.getJSModule(RCTDeviceEventEmitter.class).emit(PROGRESS_EVENT, null);
}
}
`);
write('android/src/main/java/app/Finisher.kt', `package app
const val DONE_EVENT = "downloadDone"
class Finisher {
fun finish(ctx: ReactContext) {
sendEvent(ctx, DONE_EVENT, null)
}
}
`);
write('src/index.js', `export function onProgress(e) { return e; }
export function onDone(e) { return e; }
emitter.addListener('downloadProgress', onProgress);
emitter.addListener('downloadDone', onDone);
`);
const cg = await CodeGraph.init(dir, { index: true });
try {
expect(channel(cg)).toEqual(['finish -> onDone (downloadDone)', 'report -> onProgress (downloadProgress)']);
} finally {
cg.close();
}
});
});
+105
View File
@@ -1513,8 +1513,79 @@ const RN_JVM_EMIT_RE = /\.emit\s*\(\s*"([^"]+)"\s*,/g;
// is followed by `… ) {`) never matches. Multi-line tolerant. (java/kotlin/swift)
const RN_NATIVE_SENDEVENT_RE = /\bsendEvent\s*\([^;{}]*?"([^"]+)"/g;
// The same calls with the event named by a CONSTANT — NetInfo listens with
// `addListener(PrivateTypes.DEVICE_CONNECTIVITY_EVENT, …)`, many libraries emit
// with `.emit(EVENT_NAME, …)` or `sendEventWithName:kLocationEvent`.
const RN_OBJC_SEND_CONST_RE = /\bsendEventWithName\s*:\s*([A-Za-z_]\w*)\b/g;
const RN_SWIFT_SEND_CONST_RE = /\bsendEvent\s*\(\s*withName\s*:\s*([A-Za-z_][\w.]*)/g;
const RN_JVM_EMIT_CONST_RE = /\.emit\s*\(\s*([A-Za-z_][\w.]*)\s*,/g;
const RN_NATIVE_SENDEVENT_CONST_RE = /\bsendEvent\s*\(\s*[A-Za-z_][\w.]*\s*,\s*([A-Za-z_][\w.]*)\s*,/g;
const RN_JS_LISTEN_CONST_RE = /\.(?:on|once|addListener)\(\s*((?:[A-Za-z_$][\w$]*\.)*[A-Za-z_$][\w$]*)\s*,\s*([A-Za-z_$][\w$.]*|(?:async\s*)?(?:\([^)]*\)\s*=>|[A-Za-z_$][\w$]*\s*=>|function\s*\())/g;
const CONSTANT_KINDS = new Set<NodeKind>(['constant', 'variable', 'field', 'property', 'enum_member']);
/**
* The string an event-name constant holds, read off its declaration line:
* `export const DEVICE_CONNECTIVITY_EVENT = 'netInfo.networkStatusDidChange'`,
* `static final String EVENT = "x"`, `const val EVENT = "x"`,
* `NSString *const kEvent = @"x"`, `#define kEvent @"x"`, `static let event = "x"`,
* an enum case `Changed = 'changed'`.
*
* Scoped as the language scopes the name, never by the name alone — a
* parameter `eventName` is not some test's `eventName = "pong"`:
* - a bare `NAME` is a declaration in the same file, or (JS) the one an
* import of that name points at;
* - `Owner.NAME` is a declaration inside `Owner`, or (JS) `NAME` in the file
* a namespace import `Owner` points at.
* Null unless those declarations agree on one literal.
*/
function constantEventName(expr: string, file: string, ctx: ResolutionContext, memo: Map<string, string | null>): string | null {
const key = `${file}\0${expr}`;
if (memo.has(key)) return memo.get(key)!;
const parts = expr.replace(/\.rawValue$/, '').split('.');
const name = parts[parts.length - 1]!;
const owner = parts.length > 1 ? parts[parts.length - 2]! : null;
let value: string | null = null;
if (/^[A-Za-z_$][\w$]*$/.test(name) && name !== 'this' && (parts.length <= 2 || parts[0] !== 'this')) {
const read = (n: Node): string | null => {
const line = (ctx.getFileLines?.(n.filePath) ?? ctx.readFile(n.filePath)?.split(/\r?\n/))?.[n.startLine - 1] ?? '';
const at = line.indexOf(name);
if (at < 0) return null;
const rest = line.slice(at + name.length);
const m = /^\s*(?::[^=;]*)?[=:]\s*@?(["'`])([^"'`]+)\1/.exec(rest) ?? /^\s+@?(")([^"]+)"/.exec(rest);
return m ? m[2]! : null;
};
const declaredIn = (f: string) => ctx.getNodesInFile(f).filter((n) => n.name === name && CONSTANT_KINDS.has(n.kind));
const js = /\.(?:[cm]?[jt]sx?)$/.test(file);
const language: Language = /\.tsx$/.test(file) ? 'tsx' : /\.[cm]?ts$/.test(file) ? 'typescript' : file.endsWith('.jsx') ? 'jsx' : 'javascript';
const importedFile = (local: string): string | null => {
if (!js) return null;
const binding = ctx.getImportMappings(file, language).find((m) => m.localName === local);
return binding ? resolveImportPath(binding.source, file, language, ctx) : null;
};
let decls: Node[] = [];
if (owner === null) {
decls = declaredIn(file);
if (decls.length === 0) {
const from = importedFile(name);
if (from) decls = declaredIn(from);
}
} else {
const from = importedFile(owner);
decls = from
? declaredIn(from)
: ctx.getNodesByName(name).filter((n) => CONSTANT_KINDS.has(n.kind) && new RegExp(`(?:^|[.:])${owner}(?:[.:]|$)`).test(n.qualifiedName.slice(0, n.qualifiedName.lastIndexOf(name))));
}
const values = new Set(decls.map(read).filter((v): v is string => v !== null));
value = values.size === 1 ? [...values][0]! : null;
}
memo.set(key, value);
return value;
}
async function rnEventEdges(ctx: ResolutionContext, onYield: MaybeYield): Promise<Edge[]> {
let scannedFiles = 0;
const constants = new Map<string, string | null>();
// Native dispatchers (source = the native method whose body sends the
// event) and JS handlers (target = the function/method registered as
// the listener) keyed by event name.
@@ -1544,6 +1615,11 @@ async function rnEventEdges(ctx: ResolutionContext, onYield: MaybeYield): Promis
while ((m = RN_OBJC_SEND_RE.exec(content))) {
if (m[1]) addDispatcher(m[1], lineOf(m.index));
}
RN_OBJC_SEND_CONST_RE.lastIndex = 0;
while ((m = RN_OBJC_SEND_CONST_RE.exec(content))) {
const event = constantEventName(m[1]!, file, ctx, constants);
if (event) addDispatcher(event, lineOf(m.index));
}
}
// Swift side: same RCTEventEmitter method, parens/named-args syntax.
@@ -1557,6 +1633,11 @@ async function rnEventEdges(ctx: ResolutionContext, onYield: MaybeYield): Promis
while ((m = RN_NATIVE_SENDEVENT_RE.exec(content))) {
if (m[1]) addDispatcher(m[1], lineOf(m.index));
}
RN_SWIFT_SEND_CONST_RE.lastIndex = 0;
while ((m = RN_SWIFT_SEND_CONST_RE.exec(content))) {
const event = constantEventName(m[1]!, file, ctx, constants);
if (event) addDispatcher(event, lineOf(m.index));
}
}
// JVM side: `.emit("X", …)` in Java/Kotlin, plus the common
@@ -1573,6 +1654,13 @@ async function rnEventEdges(ctx: ResolutionContext, onYield: MaybeYield): Promis
while ((m = RN_NATIVE_SENDEVENT_RE.exec(content))) {
if (m[1]) addDispatcher(m[1], lineOf(m.index));
}
for (const re of [RN_JVM_EMIT_CONST_RE, RN_NATIVE_SENDEVENT_CONST_RE]) {
re.lastIndex = 0;
while ((m = re.exec(content))) {
const event = constantEventName(m[1]!, file, ctx, constants);
if (event) addDispatcher(event, lineOf(m.index));
}
}
}
// JS subscribers (.addListener("X", handler)). Restrict to JS-family
@@ -1658,6 +1746,23 @@ async function rnEventEdges(ctx: ResolutionContext, onYield: MaybeYield): Promis
if (!map.has(enclosing.id)) map.set(enclosing.id, `${file}:${lineOf(m.index)}`);
jsHandlersByEvent.set(event, map);
}
// A constant event name: the listener lands where a literal one would —
// the named handler when it is a node, else the enclosing function.
RN_JS_LISTEN_CONST_RE.lastIndex = 0;
while ((m = RN_JS_LISTEN_CONST_RE.exec(content))) {
const event = constantEventName(m[1]!, file, ctx, constants);
if (!event) continue;
const arg = m[2]!;
const bare = /^[A-Za-z_$][\w$.]*$/.test(arg) ? arg.slice(arg.lastIndexOf('.') + 1) : null;
const line = lineOf(m.index);
// The handler this file names — never a same-named function elsewhere.
const named = bare ? nodesInFile.find((n) => (n.kind === 'function' || n.kind === 'method') && n.name === bare) : undefined;
const target = named ?? enclosingFn(nodesInFile, line);
if (!target) continue;
const map = jsHandlersByEvent.get(event) ?? new Map<string, string>();
if (!map.has(target.id)) map.set(target.id, `${file}:${line}`);
jsHandlersByEvent.set(event, map);
}
}
}