@@ -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.
0 commit comments