@@ -102,6 +102,16 @@ const TEXT_ATTRIBUTES = new Set([
102102] ) ;
103103const HTTP_METHODS = new Set ( [ "get" , "post" , "put" , "patch" , "delete" , "head" , "options" ] ) ;
104104const TOAST_CALLEES = / ^ ( t o a s t ( \. \w + ) ? | e n q u e u e S n a c k b a r | m e s s a g e \. ( s u c c e s s | e r r o r | i n f o | w a r n i n g ) ) $ / ;
105+ /** Hotkey-library hooks whose (keys, handler) registration becomes an event (3.4). */
106+ const HOTKEY_HOOKS = new Set ( [
107+ "useHotkeys" ,
108+ "useHotkey" ,
109+ "useKeyboardShortcut" ,
110+ "useKey" ,
111+ "useKeyPress" ,
112+ ] ) ;
113+ /** Form components whose `onSubmit` prop is a real submit handler (Formik, 3.4). */
114+ const FORM_TAGS = new Set ( [ "Formik" , "Form" ] ) ;
105115
106116/** Scan a directory of React source and produce a lineage graph. */
107117export function scanReact ( options : ScanOptions ) : LineageGraph {
@@ -428,9 +438,63 @@ function extractBodyFacts(
428438 // sites get the real, substituted endpoint instead.
429439 const declIsWrapper = wrappers . has ( declName ) ;
430440
441+ // Create an EventNode + handles edge and queue its handler for effect mining.
442+ // Shared by JSX on* props and the non-JSX bindings (forms, listeners, hotkeys).
443+ const registerEvent = (
444+ eventName : string ,
445+ handlerNode : Node | undefined ,
446+ source : "jsx" | "form" | "effect" | "hotkey" ,
447+ at : Node ,
448+ flags ?: string [ ] ,
449+ ) : void => {
450+ let handler : string | null = null ;
451+ if (
452+ handlerNode !== undefined &&
453+ ( Node . isIdentifier ( handlerNode ) || Node . isPropertyAccessExpression ( handlerNode ) )
454+ ) {
455+ handler = handlerNode . getText ( ) ;
456+ }
457+ const suffix = `${ handler !== null ? `:${ handler } ` : "" } ${ source !== "jsx" ? `@${ source } ` : "" } ` ;
458+ const evId = nodeId ( "event" , file , `${ declName } .${ eventName } ${ suffix } ` ) ;
459+ if ( ! nodes . has ( evId ) ) {
460+ nodes . set ( evId , {
461+ id : evId ,
462+ kind : "event" ,
463+ name : eventName ,
464+ loc : locOf ( at , file ) ,
465+ event : eventName ,
466+ handler,
467+ ...( source !== "jsx" ? { source } : { } ) ,
468+ ...( flags !== undefined && flags . length > 0 ? { flags } : { } ) ,
469+ } ) ;
470+ }
471+ if ( handlerNode !== undefined ) {
472+ const list = handlerExprs . get ( evId ) ;
473+ if ( list ) list . push ( handlerNode ) ;
474+ else handlerExprs . set ( evId , [ handlerNode ] ) ;
475+ }
476+ addEdge ( { from : ownerId , to : evId , kind : "handles" } ) ;
477+ } ;
478+
431479 for ( const call of body . getDescendantsOfKind ( SyntaxKind . CallExpression ) ) {
432480 const callee = call . getExpression ( ) . getText ( ) ;
433481
482+ // Non-JSX event bindings (TRACKER 3.4): addEventListener (usually in an
483+ // effect) and hotkey-library registrations become events; an unresolvable
484+ // event type is flagged "unscanned-events" rather than dropped.
485+ if ( callee === "addEventListener" || callee . endsWith ( ".addEventListener" ) ) {
486+ const args = call . getArguments ( ) ;
487+ const type = resolveStringValue ( args [ 0 ] , 0 ) ;
488+ registerEvent ( type ?? "unknown" , args [ 1 ] , "effect" , call , type === null ? [ "unscanned-events" ] : undefined ) ;
489+ continue ;
490+ }
491+ if ( HOTKEY_HOOKS . has ( callee ) ) {
492+ const args = call . getArguments ( ) ;
493+ const keys = resolveStringValue ( args [ 0 ] , 0 ) ;
494+ registerEvent ( keys ?? "unknown" , args [ 1 ] , "hotkey" , call , keys === null ? [ "unscanned-events" ] : undefined ) ;
495+ continue ;
496+ }
497+
434498 // Store readers/dispatchers first — useSelector would otherwise fall
435499 // through to the generic per-component state handling.
436500 if ( callee === "useSelector" ) {
@@ -502,39 +566,39 @@ function extractBodyFacts(
502566 const attrName = attr . getNameNode ( ) . getText ( ) ;
503567 if ( ! / ^ o n [ A - Z ] / . test ( attrName ) ) continue ;
504568 const init = attr . getInitializer ( ) ;
505- let handler : string | null = null ;
506- let handlerExpr : Node | undefined ;
507- if ( init !== undefined && Node . isJsxExpression ( init ) ) {
508- const expr = init . getExpression ( ) ;
509- handlerExpr = expr ;
510- // Plain references (handleDelete) and method references (this.refresh).
511- if (
512- expr !== undefined &&
513- ( Node . isIdentifier ( expr ) || Node . isPropertyAccessExpression ( expr ) )
514- ) {
515- handler = expr . getText ( ) ;
516- }
569+ if ( init === undefined || ! Node . isJsxExpression ( init ) ) {
570+ registerEvent ( attrName , undefined , "jsx" , attr ) ;
571+ continue ;
517572 }
518- const evId = nodeId ( "event" , file , `${ declName } .${ attrName } ${ handler !== null ? `:${ handler } ` : "" } ` ) ;
519- if ( ! nodes . has ( evId ) ) {
520- nodes . set ( evId , {
521- id : evId ,
522- kind : "event" ,
523- name : attrName ,
524- loc : locOf ( attr , file ) ,
525- event : attrName ,
526- handler,
527- } ) ;
573+ let expr : Node | undefined = init . getExpression ( ) ;
574+ let source : "jsx" | "form" = "jsx" ;
575+ // react-hook-form: onSubmit={handleSubmit(onValid)} — the real handler is
576+ // the wrapped argument, not the handleSubmit() call itself.
577+ if ( expr !== undefined && Node . isCallExpression ( expr ) ) {
578+ const callee = expr . getExpression ( ) ;
579+ const calleeName = Node . isPropertyAccessExpression ( callee ) ? callee . getName ( ) : callee . getText ( ) ;
580+ if ( calleeName === "handleSubmit" ) {
581+ expr = expr . getArguments ( ) [ 0 ] ?? expr ;
582+ source = "form" ;
583+ }
528584 }
529- // The handler expression is mined for effects in resolveHandlerChains (3.2);
530- // stored by node id so the same evId collects every inline arrow it carries.
531- if ( handlerExpr !== undefined ) {
532- const list = handlerExprs . get ( evId ) ;
533- if ( list ) list . push ( handlerExpr ) ;
534- else handlerExprs . set ( evId , [ handlerExpr ] ) ;
585+ // Formik: <Formik onSubmit={onSubmit}> — the onSubmit prop is a submit handler.
586+ if ( source === "jsx" && attrName === "onSubmit" && FORM_TAGS . has ( jsxTagName ( attr ) ) ) {
587+ source = "form" ;
535588 }
536- addEdge ( { from : ownerId , to : evId , kind : "handles" } ) ;
589+ registerEvent ( attrName , expr , source , attr ) ;
590+ }
591+ }
592+
593+ /** The tag name of the JSX element an attribute belongs to (e.g. "Formik"). */
594+ function jsxTagName ( attr : Node ) : string {
595+ const element = attr . getFirstAncestor (
596+ ( a ) => Node . isJsxOpeningElement ( a ) || Node . isJsxSelfClosingElement ( a ) ,
597+ ) ;
598+ if ( element !== undefined && ( Node . isJsxOpeningElement ( element ) || Node . isJsxSelfClosingElement ( element ) ) ) {
599+ return element . getTagNameNode ( ) . getText ( ) ;
537600 }
601+ return "" ;
538602}
539603
540604/** `useSelector((s) => s.users.list)` → the "users" slice's StateNode id. */
0 commit comments