@@ -13,6 +13,7 @@ import {
1313 MUTABLE_GLOBAL_ROOTS ,
1414 MUTATING_ARRAY_METHODS ,
1515 RELATED_USE_STATE_THRESHOLD ,
16+ SUB_HANDLER_DIRECT_CALLEE_NAMES ,
1617 SUBSCRIPTION_METHOD_NAMES ,
1718 TRIVIAL_DERIVATION_CALLEE_NAMES ,
1819 TRIVIAL_INITIALIZER_NAMES ,
@@ -2554,3 +2555,276 @@ export const noMutableInDeps: Rule = {
25542555 } ;
25552556 } ,
25562557} ;
2558+
2559+ // HACK: From "Separating Events from Effects" — when a function-typed
2560+ // prop (or local callback) is read from an effect ONLY inside a sub-
2561+ // handler (setTimeout / addEventListener / store.subscribe / etc.),
2562+ // listing it in the dep array forces the whole effect to re-synchronize
2563+ // every time its identity changes. The article's recommended fix is
2564+ // `useEffectEvent`, which is React 19+. The rule is registered as
2565+ // version-gated in `oxlint-config.ts` (USE_EFFECT_EVENT_MIN_MAJOR) so
2566+ // pre-19 projects don't see noisy diagnostics for an API they don't
2567+ // have.
2568+ //
2569+ // function SearchInput({ onSearch }) {
2570+ // const [query, setQuery] = useState('');
2571+ // useEffect(() => {
2572+ // const id = setTimeout(() => onSearch(query), 300); // sub-handler
2573+ // return () => clearTimeout(id);
2574+ // }, [query, onSearch]);
2575+ // }
2576+ //
2577+ // Detector pre-conditions (all must hold) — chosen to keep FPs near zero:
2578+ // (1) useEffect with at least 2 dep array elements, all Identifiers
2579+ // (2) at least one dep `F` is a function-shaped reactive value:
2580+ // - a destructured prop named `on[A-Z]…`, OR
2581+ // - a local declared via `const F = useCallback(...)`
2582+ // (3) every read of `F` inside the effect body sits inside a sub-
2583+ // handler (SUB_HANDLER_DIRECT_CALLEE_NAMES, OR a MemberExpression
2584+ // whose property is in SUBSCRIPTION_METHOD_NAMES — same set the
2585+ // prefer-use-sync-external-store family uses)
2586+ // (4) `F` is NEVER read at the effect's own top level
2587+ const collectFunctionTypedLocalBindings = ( componentBody : EsTreeNode ) : Set < string > => {
2588+ const functionTypedLocals = new Set < string > ( ) ;
2589+ if ( componentBody ?. type !== "BlockStatement" ) return functionTypedLocals ;
2590+ for ( const statement of componentBody . body ?? [ ] ) {
2591+ if ( statement . type !== "VariableDeclaration" ) continue ;
2592+ for ( const declarator of statement . declarations ?? [ ] ) {
2593+ if ( declarator . id ?. type !== "Identifier" ) continue ;
2594+ if ( declarator . init ?. type !== "CallExpression" ) continue ;
2595+ if ( ! isHookCall ( declarator . init , "useCallback" ) ) continue ;
2596+ functionTypedLocals . add ( declarator . id . name ) ;
2597+ }
2598+ }
2599+ return functionTypedLocals ;
2600+ } ;
2601+
2602+ const findEnclosingFunctionInsideEffect = (
2603+ identifierNode : EsTreeNode ,
2604+ effectCallback : EsTreeNode ,
2605+ ) : EsTreeNode | null => {
2606+ let cursor : EsTreeNode | null = identifierNode . parent ?? null ;
2607+ while ( cursor && cursor !== effectCallback ) {
2608+ if (
2609+ cursor . type === "ArrowFunctionExpression" ||
2610+ cursor . type === "FunctionExpression" ||
2611+ cursor . type === "FunctionDeclaration"
2612+ ) {
2613+ return cursor ;
2614+ }
2615+ cursor = cursor . parent ?? null ;
2616+ }
2617+ return null ;
2618+ } ;
2619+
2620+ const isCallExpressionWithSubHandlerCallee = ( callExpression : EsTreeNode ) : boolean => {
2621+ if ( callExpression ?. type !== "CallExpression" ) return false ;
2622+ const callee = callExpression . callee ;
2623+ if ( callee ?. type === "Identifier" && SUB_HANDLER_DIRECT_CALLEE_NAMES . has ( callee . name ) ) {
2624+ return true ;
2625+ }
2626+ if (
2627+ callee ?. type === "MemberExpression" &&
2628+ callee . property ?. type === "Identifier" &&
2629+ SUBSCRIPTION_METHOD_NAMES . has ( callee . property . name )
2630+ ) {
2631+ return true ;
2632+ }
2633+ return false ;
2634+ } ;
2635+
2636+ const getSubHandlerCalleeName = ( callExpression : EsTreeNode ) : string | null => {
2637+ if ( callExpression ?. type !== "CallExpression" ) return null ;
2638+ const callee = callExpression . callee ;
2639+ if ( callee ?. type === "Identifier" ) return callee . name ;
2640+ if ( callee ?. type === "MemberExpression" && callee . property ?. type === "Identifier" ) {
2641+ return callee . property . name ;
2642+ }
2643+ return null ;
2644+ } ;
2645+
2646+ // HACK: handles the dominant real-world shape where the handler is
2647+ // bound to a const before being passed to addEventListener / subscribe:
2648+ //
2649+ // const handler = (event) => onKey(event.key);
2650+ // window.addEventListener('keydown', handler);
2651+ // return () => window.removeEventListener('keydown', handler);
2652+ //
2653+ // Walks up to the function-level node (the arrow expression) and checks
2654+ // for either a direct sub-handler argument position OR a const binding
2655+ // whose Identifier appears as an argument to a sub-handler call later
2656+ // in the same effect body.
2657+ const findSubHandlerForEnclosingFunction = (
2658+ enclosingFunction : EsTreeNode ,
2659+ effectCallback : EsTreeNode ,
2660+ ) : EsTreeNode | null => {
2661+ const directParent = enclosingFunction . parent ;
2662+ if (
2663+ directParent ?. type === "CallExpression" &&
2664+ directParent . arguments ?. includes ( enclosingFunction ) &&
2665+ isCallExpressionWithSubHandlerCallee ( directParent )
2666+ ) {
2667+ return directParent ;
2668+ }
2669+
2670+ if ( directParent ?. type !== "VariableDeclarator" ) return null ;
2671+ if ( directParent . id ?. type !== "Identifier" ) return null ;
2672+ const localName : string = directParent . id . name ;
2673+
2674+ let matchingSubHandlerCall : EsTreeNode | null = null ;
2675+ walkAst ( effectCallback , ( child : EsTreeNode ) => {
2676+ if ( matchingSubHandlerCall ) return false ;
2677+ if ( child . type !== "CallExpression" ) return ;
2678+ if ( ! isCallExpressionWithSubHandlerCallee ( child ) ) return ;
2679+ for ( const argument of child . arguments ?? [ ] ) {
2680+ if ( argument ?. type === "Identifier" && argument . name === localName ) {
2681+ matchingSubHandlerCall = child ;
2682+ return false ;
2683+ }
2684+ }
2685+ } ) ;
2686+ return matchingSubHandlerCall ;
2687+ } ;
2688+
2689+ interface CallableReadClassification {
2690+ hasAnyRead : boolean ;
2691+ allReadsAreInSubHandlers : boolean ;
2692+ firstSubHandlerName : string | null ;
2693+ }
2694+
2695+ const classifyCallableReadsInsideEffect = (
2696+ callableName : string ,
2697+ effectCallback : EsTreeNode ,
2698+ ) : CallableReadClassification => {
2699+ let hasAnyRead = false ;
2700+ let allReadsAreInSubHandlers = true ;
2701+ let firstSubHandlerName : string | null = null ;
2702+
2703+ walkAst ( effectCallback , ( child : EsTreeNode ) => {
2704+ if ( child . type !== "Identifier" ) return ;
2705+ if ( child . name !== callableName ) return ;
2706+ const parent = child . parent ;
2707+ if ( parent ?. type === "ArrayExpression" ) return ;
2708+ if ( parent ?. type === "MemberExpression" && ! parent . computed && parent . property === child ) {
2709+ return ;
2710+ }
2711+ if (
2712+ parent ?. type === "Property" &&
2713+ ! parent . computed &&
2714+ ! parent . shorthand &&
2715+ parent . key === child
2716+ ) {
2717+ return ;
2718+ }
2719+
2720+ hasAnyRead = true ;
2721+
2722+ const enclosingFunction = findEnclosingFunctionInsideEffect ( child , effectCallback ) ;
2723+ if ( ! enclosingFunction ) {
2724+ allReadsAreInSubHandlers = false ;
2725+ return ;
2726+ }
2727+ const subHandlerCall = findSubHandlerForEnclosingFunction ( enclosingFunction , effectCallback ) ;
2728+ if ( ! subHandlerCall ) {
2729+ allReadsAreInSubHandlers = false ;
2730+ return ;
2731+ }
2732+ if ( firstSubHandlerName === null ) {
2733+ firstSubHandlerName = getSubHandlerCalleeName ( subHandlerCall ) ;
2734+ }
2735+ } ) ;
2736+
2737+ return { hasAnyRead, allReadsAreInSubHandlers, firstSubHandlerName } ;
2738+ } ;
2739+
2740+ export const preferUseEffectEvent : Rule = {
2741+ create : ( context : RuleContext ) => {
2742+ const componentPropParamStack : Array < Set < string > > = [ ] ;
2743+
2744+ const isPropName = ( name : string ) : boolean => {
2745+ for ( let frameIndex = componentPropParamStack . length - 1 ; frameIndex >= 0 ; frameIndex -- ) {
2746+ const frame = componentPropParamStack [ frameIndex ] ;
2747+ if ( frame . size === 0 ) return false ;
2748+ if ( frame . has ( name ) ) return true ;
2749+ }
2750+ return false ;
2751+ } ;
2752+
2753+ const checkComponent = ( componentBody : EsTreeNode | null | undefined ) : void => {
2754+ if ( ! componentBody || componentBody . type !== "BlockStatement" ) return ;
2755+ const functionTypedLocalBindings = collectFunctionTypedLocalBindings ( componentBody ) ;
2756+
2757+ for ( const statement of componentBody . body ?? [ ] ) {
2758+ if ( statement . type !== "ExpressionStatement" ) continue ;
2759+ const effectCall = statement . expression ;
2760+ if ( effectCall ?. type !== "CallExpression" ) continue ;
2761+ if ( ! isHookCall ( effectCall , EFFECT_HOOK_NAMES ) ) continue ;
2762+ if ( ( effectCall . arguments ?. length ?? 0 ) < 2 ) continue ;
2763+
2764+ const depsNode = effectCall . arguments [ 1 ] ;
2765+ if ( depsNode . type !== "ArrayExpression" ) continue ;
2766+ const depElements = depsNode . elements ?? [ ] ;
2767+ if ( depElements . length < 2 ) continue ;
2768+ if ( ! depElements . every ( ( element : EsTreeNode | null ) => element ?. type === "Identifier" ) ) {
2769+ continue ;
2770+ }
2771+
2772+ const callback = getEffectCallback ( effectCall ) ;
2773+ if ( ! callback ) continue ;
2774+
2775+ for ( const depElement of depElements ) {
2776+ if ( ! depElement ) continue ;
2777+ const depName : string = depElement . name ;
2778+ // HACK: a destructured prop is treated as function-typed
2779+ // ONLY if its name matches the React `on[A-Z]` callback
2780+ // convention. Without this filter the rule false-positived
2781+ // on scalar props.
2782+ const isFunctionTypedPropDep = isPropName ( depName ) && / ^ o n [ A - Z ] / . test ( depName ) ;
2783+ const isFunctionTypedLocalDep = functionTypedLocalBindings . has ( depName ) ;
2784+ if ( ! isFunctionTypedPropDep && ! isFunctionTypedLocalDep ) continue ;
2785+
2786+ const classification = classifyCallableReadsInsideEffect ( depName , callback ) ;
2787+ if ( ! classification . hasAnyRead ) continue ;
2788+ if ( ! classification . allReadsAreInSubHandlers ) continue ;
2789+
2790+ const subHandlerLabel = classification . firstSubHandlerName
2791+ ? `\`${ classification . firstSubHandlerName } \``
2792+ : "an async sub-handler" ;
2793+ context . report ( {
2794+ node : depElement ,
2795+ message : `"${ depName } " is read only inside ${ subHandlerLabel } — wrap it with useEffectEvent and remove it from the dep array so the effect doesn't re-synchronize on every parent render` ,
2796+ } ) ;
2797+ }
2798+ }
2799+ } ;
2800+
2801+ return {
2802+ FunctionDeclaration ( node : EsTreeNode ) {
2803+ if ( ! node . id ?. name || ! isUppercaseName ( node . id . name ) ) {
2804+ componentPropParamStack . push ( new Set ( ) ) ;
2805+ return ;
2806+ }
2807+ componentPropParamStack . push ( extractDestructuredPropNames ( node . params ?? [ ] ) ) ;
2808+ checkComponent ( node . body ) ;
2809+ } ,
2810+ "FunctionDeclaration:exit" ( ) {
2811+ componentPropParamStack . pop ( ) ;
2812+ } ,
2813+ VariableDeclarator ( node : EsTreeNode ) {
2814+ if ( isComponentAssignment ( node ) ) {
2815+ componentPropParamStack . push ( extractDestructuredPropNames ( node . init ?. params ?? [ ] ) ) ;
2816+ checkComponent ( node . init ?. body ) ;
2817+ return ;
2818+ }
2819+ if ( isFunctionLikeVariableDeclarator ( node ) ) {
2820+ componentPropParamStack . push ( new Set ( ) ) ;
2821+ }
2822+ } ,
2823+ "VariableDeclarator:exit" ( node : EsTreeNode ) {
2824+ if ( isComponentAssignment ( node ) || isFunctionLikeVariableDeclarator ( node ) ) {
2825+ componentPropParamStack . pop ( ) ;
2826+ }
2827+ } ,
2828+ } ;
2829+ } ,
2830+ } ;
0 commit comments