Skip to content

Commit a87ec85

Browse files
authored
Merge pull request #295 from Wolfvin/fix/issue-294-jsx-handler-references
fix(dead-code): count JSX prop function references as usage (closes #294)
2 parents 1833d13 + a3a4850 commit a87ec85

2 files changed

Lines changed: 137 additions & 0 deletions

File tree

scripts/parsers/tsx_parser.py

100755100644
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,8 @@ def visit(node: Node, _, depth):
6666
self._process_jsx_attribute(node, source, file_path, classes, ids)
6767
elif node.type in ('jsx_opening_element', 'jsx_self_closing_element'):
6868
self._process_jsx_component(node, source, file_path, fn_declarations, edges)
69+
elif node.type == 'jsx_expression':
70+
self._process_jsx_expression(node, source, file_path, fn_declarations, edges)
6971
elif node.type == 'call_expression':
7072
call_info = self._parse_call(node, source, fn_declarations)
7173
if call_info:
@@ -442,6 +444,88 @@ def _process_jsx_component(self, node: Node, source: bytes,
442444
"via_jsx": True
443445
})
444446

447+
def _process_jsx_expression(self, node: Node, source: bytes,
448+
file_path: str, fn_declarations: List[Dict],
449+
edges: List):
450+
"""Emit usage edges for functions referenced inside a JSX expression
451+
container (issue #294).
452+
453+
In React, an event handler is passed by *reference* as a prop value —
454+
``onClick={handleClick}`` — not by call. The per-call passes only see
455+
``call_expression`` nodes, so a bare identifier reference produces zero
456+
edges, leaving the handler with ``ref_count=0`` and false-flagged dead.
457+
458+
This handler counts two reference shapes as usage, but ONLY when the
459+
identifier resolves to a function declared in this file (guarded by
460+
``declared``) — arbitrary identifiers, DOM props, and non-function names
461+
are never counted:
462+
463+
1. Attribute value / child reference: ``onClick={handleClick}`` — the
464+
expression's direct child is the identifier.
465+
2. Callback argument: ``{items.map(renderItem)}`` — the identifier is
466+
passed as an argument to a call.
467+
468+
Double-counting is avoided by:
469+
- skipping the ``function`` position of a ``call_expression`` (those
470+
are already emitted by :meth:`_parse_call`), walking only its
471+
``arguments``;
472+
- not descending into nested ``jsx_expression`` nodes (the outer
473+
tree walk visits each one on its own);
474+
- skipping ``member_expression`` (e.g. ``items.map`` / ``this.x``).
475+
"""
476+
declared = {d["node"]["fn"] for d in fn_declarations}
477+
if not declared:
478+
return
479+
480+
# Resolve the enclosing function (innermost scope) as the edge source.
481+
expr_line = self.get_line(node)
482+
caller_id = None
483+
best_scope_size = float('inf')
484+
for decl in fn_declarations:
485+
if decl["scope_start"] <= expr_line - 1 <= decl["scope_end"]:
486+
scope_size = decl["scope_end"] - decl["scope_start"]
487+
if scope_size < best_scope_size:
488+
best_scope_size = scope_size
489+
caller_id = decl["node"]["id"]
490+
if not caller_id:
491+
return
492+
493+
seen = set()
494+
495+
def emit(name: str):
496+
if name in seen:
497+
return
498+
if name in self.SKIP_NAMES or name not in declared:
499+
return
500+
seen.add(name)
501+
edges.append({
502+
"from": caller_id,
503+
"to_fn": name,
504+
"via_jsx_ref": True,
505+
})
506+
507+
def walk(n: Node):
508+
for child in n.children:
509+
t = child.type
510+
if t == 'jsx_expression':
511+
# Handled by the outer tree walk — avoid re-processing.
512+
continue
513+
if t == 'identifier':
514+
emit(self.get_text(child, source))
515+
elif t == 'call_expression':
516+
# Function position is already counted by _parse_call;
517+
# only inspect arguments for callback references.
518+
args = child.child_by_field_name('arguments')
519+
if args:
520+
walk(args)
521+
elif t == 'member_expression':
522+
# e.g. items.map, this.handler — not a bare function ref.
523+
continue
524+
else:
525+
walk(child)
526+
527+
walk(node)
528+
445529
def _parse_call(self, node: Node, source: bytes,
446530
fn_declarations: List[Dict]) -> Optional[Dict]:
447531
"""Parse a call expression and return an edge if it's within a known function.

tests/test_graph_accuracy_golden.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,29 @@ def _callers_of(backend: dict, fn: str) -> set:
153153
"""
154154

155155

156+
_JSX_HANDLER_TSX = """\
157+
// Guards #294: a function passed as a JSX prop value (onClick={handleClick}) is
158+
// a REFERENCE, not a call — before #294 it produced zero edges, so the handler
159+
// got rc=0 / status=dead (false positive on the primary React use case). A
160+
// function passed as a callback argument ({items.map(renderItem)}) must also
161+
// count. Genuinely-unused handlers must STILL be dead (control).
162+
function handleClick() { return 1; }
163+
function handleSubmit() { return 2; }
164+
function renderItem(x: number) { return x; }
165+
function directlyCalled() { return 3; }
166+
function unusedHandler() { return 4; }
167+
export function App({ items }: { items: number[] }) {
168+
directlyCalled();
169+
return (
170+
<div onClick={handleClick}>
171+
<form onSubmit={handleSubmit} />
172+
<ul>{items.map(renderItem)}</ul>
173+
</div>
174+
);
175+
}
176+
"""
177+
178+
156179
_MODULE_LEVEL_CALL_PY = """\
157180
# Guards #291: Python calls at module top level (not inside any def/class) must
158181
# count toward the callee's rc — analogous to #219 for TS/JS. Before #291 the
@@ -191,6 +214,7 @@ def _build_workspace(tmp_path) -> str:
191214
(ws / "src" / "callback.ts").write_text(_INLINE_CALLBACK_TS)
192215
(ws / "src" / "same_file.rs").write_text(_SAME_FILE_USAGE_RS)
193216
(ws / "src" / "dead.ts").write_text(_GENUINELY_DEAD_TS)
217+
(ws / "src" / "App.tsx").write_text(_JSX_HANDLER_TSX)
194218
return str(ws)
195219

196220

@@ -372,3 +396,32 @@ def test_genuinely_dead_still_detected(self, backend):
372396
f"control: trulyUnusedInternal should have rc 0, got {nodes[0].get('ref_count')} — "
373397
"a fix that inflates rc to hide false-positives would break real dead-code detection"
374398
)
399+
400+
def test_jsx_prop_handler_reference_counts_toward_rc(self, backend):
401+
"""#294: handlers passed as JSX prop values are USED, not dead."""
402+
for fn in ("handleClick", "handleSubmit"):
403+
rc = _rc(backend, fn)
404+
assert rc >= 1, f"#294 regression: {fn} rc={rc}, expected >=1 (referenced via JSX prop)"
405+
callers = _callers_of(backend, fn)
406+
assert any("App.tsx" in c for c in callers), (
407+
f"#294: expected a caller from App.tsx for {fn}, got {callers}"
408+
)
409+
410+
def test_jsx_callback_argument_reference_counts_toward_rc(self, backend):
411+
"""#294: `{items.map(renderItem)}` — callback arg reference is usage."""
412+
rc = _rc(backend, "renderItem")
413+
assert rc >= 1, f"#294 regression: renderItem rc={rc}, expected >=1 (JSX callback arg)"
414+
415+
def test_jsx_direct_call_not_regressed(self, backend):
416+
"""#294 control: a directly-called function in the TSX file keeps rc>=1."""
417+
rc = _rc(backend, "directlyCalled")
418+
assert rc >= 1, f"#294: directlyCalled rc={rc}, expected >=1 (direct call must not regress)"
419+
420+
def test_jsx_genuinely_unused_handler_still_dead(self, backend):
421+
"""#294 control: a TSX function referenced nowhere IS still dead (rc 0)."""
422+
nodes = _nodes_named(backend, "unusedHandler")
423+
assert nodes, "control node unusedHandler missing"
424+
assert nodes[0].get("ref_count", 0) == 0, (
425+
f"#294 control: unusedHandler should have rc 0, got {nodes[0].get('ref_count')} — "
426+
"the JSX-reference fix must not inflate rc for genuinely-unused handlers"
427+
)

0 commit comments

Comments
 (0)