From 8f8b336734d7c807f5aa11b0f31540e63302d789 Mon Sep 17 00:00:00 2001 From: Jordan Brown Date: Tue, 4 Nov 2025 14:59:29 -0500 Subject: [PATCH] [eslint] Fix useEffectEvent checks in component syntax (#35041) We were not recording uEE calls in component/hook syntax. Easy fix. Added tests matching function component syntax for component syntax + added one for hooks --- .../__tests__/ESLintRulesOfHooks-test.js | 344 ++++++++++++++++++ .../src/rules/RulesOfHooks.ts | 12 + 2 files changed, 356 insertions(+) diff --git a/packages/eslint-plugin-react-hooks/__tests__/ESLintRulesOfHooks-test.js b/packages/eslint-plugin-react-hooks/__tests__/ESLintRulesOfHooks-test.js index 05bdb1e71e..3d60a36824 100644 --- a/packages/eslint-plugin-react-hooks/__tests__/ESLintRulesOfHooks-test.js +++ b/packages/eslint-plugin-react-hooks/__tests__/ESLintRulesOfHooks-test.js @@ -585,6 +585,29 @@ const allTests = { code: normalizeIndent` // Valid: useEffectEvent can be called in custom effect hooks configured via ESLint settings function MyComponent({ theme }) { + const onClick = useEffectEvent(() => { + showNotification(theme); + }); + useMyEffect(() => { + onClick(); + }); + useServerEffect(() => { + onClick(); + }); + } + `, + settings: { + 'react-hooks': { + additionalEffectHooks: '(useMyEffect|useServerEffect)', + }, + }, + }, + { + syntax: 'flow', + code: normalizeIndent` + // Component syntax version + // Valid: useEffectEvent can be called in custom effect hooks configured via ESLint settings + component MyComponent(theme: any) { const onClick = useEffectEvent(() => { showNotification(theme); }); @@ -618,6 +641,24 @@ const allTests = { } `, }, + { + syntax: 'flow', + code: normalizeIndent` + // Component syntax version + // Valid because functions created with useEffectEvent can be called in a useEffect. + component MyComponent(theme: any) { + const onClick = useEffectEvent(() => { + showNotification(theme); + }); + useEffect(() => { + onClick(); + }); + React.useEffect(() => { + onClick(); + }); + } + `, + }, { code: normalizeIndent` // Valid because functions created with useEffectEvent can be passed by reference in useEffect @@ -644,6 +685,34 @@ const allTests = { } `, }, + { + syntax: 'flow', + code: normalizeIndent` + // Component syntax version + // Valid because functions created with useEffectEvent can be passed by reference in useEffect + // and useEffectEvent. + component MyComponent(theme: any) { + const onClick = useEffectEvent(() => { + showNotification(theme); + }); + const onClick2 = useEffectEvent(() => { + debounce(onClick); + debounce(() => onClick()); + debounce(() => { onClick() }); + deboucne(() => debounce(onClick)); + }); + useEffect(() => { + let id = setInterval(() => onClick(), 100); + return () => clearInterval(onClick); + }, []); + React.useEffect(() => { + let id = setInterval(() => onClick(), 100); + return () => clearInterval(onClick); + }, []); + return null; + } + `, + }, { code: normalizeIndent` function MyComponent({ theme }) { @@ -656,6 +725,20 @@ const allTests = { } `, }, + { + syntax: 'flow', + code: normalizeIndent` + // Component syntax version + component MyComponent(theme: any) { + useEffect(() => { + onClick(); + }); + const onClick = useEffectEvent(() => { + showNotification(theme); + }); + } + `, + }, { code: normalizeIndent` function MyComponent({ theme }) { @@ -673,6 +756,25 @@ const allTests = { } `, }, + { + syntax: 'flow', + code: normalizeIndent` + // Component syntax version + component MyComponent(theme: any) { + // Can receive arguments + const onEvent = useEffectEvent((text) => { + console.log(text); + }); + + useEffect(() => { + onEvent('Hello world'); + }); + React.useEffect(() => { + onEvent('Hello world'); + }); + } + `, + }, { code: normalizeIndent` // Valid because functions created with useEffectEvent can be called in useLayoutEffect. @@ -689,6 +791,24 @@ const allTests = { } `, }, + { + syntax: 'flow', + code: normalizeIndent` + // Component syntax version + // Valid because functions created with useEffectEvent can be called in useLayoutEffect. + component MyComponent(theme: any) { + const onClick = useEffectEvent(() => { + showNotification(theme); + }); + useLayoutEffect(() => { + onClick(); + }); + React.useLayoutEffect(() => { + onClick(); + }); + } + `, + }, { code: normalizeIndent` // Valid because functions created with useEffectEvent can be called in useInsertionEffect. @@ -705,6 +825,24 @@ const allTests = { } `, }, + { + syntax: 'flow', + code: normalizeIndent` + // Component syntax version + // Valid because functions created with useEffectEvent can be called in useInsertionEffect. + component MyComponent(theme) { + const onClick = useEffectEvent(() => { + showNotification(theme); + }); + useInsertionEffect(() => { + onClick(); + }); + React.useInsertionEffect(() => { + onClick(); + }); + } + `, + }, { code: normalizeIndent` // Valid because functions created with useEffectEvent can be passed by reference in useLayoutEffect @@ -739,6 +877,42 @@ const allTests = { } `, }, + { + syntax: 'flow', + code: normalizeIndent` + // Component syntax version + // Valid because functions created with useEffectEvent can be passed by reference in useLayoutEffect. + // and useInsertionEffect. + component MyComponent(theme: any) { + const onClick = useEffectEvent(() => { + showNotification(theme); + }); + const onClick2 = useEffectEvent(() => { + debounce(onClick); + debounce(() => onClick()); + debounce(() => { onClick() }); + deboucne(() => debounce(onClick)); + }); + useLayoutEffect(() => { + let id = setInterval(() => onClick(), 100); + return () => clearInterval(onClick); + }, []); + React.useLayoutEffect(() => { + let id = setInterval(() => onClick(), 100); + return () => clearInterval(onClick); + }, []); + useInsertionEffect(() => { + let id = setInterval(() => onClick(), 100); + return () => clearInterval(onClick); + }, []); + React.useInsertionEffect(() => { + let id = setInterval(() => onClick(), 100); + return () => clearInterval(onClick); + }, []); + return null; + } + `, + }, ], invalid: [ { @@ -1525,6 +1699,22 @@ const allTests = { `, errors: [useEffectEventError('onClick', true)], }, + { + syntax: 'flow', + code: normalizeIndent` + // Component syntax version + // Invalid: useEffectEvent should not be callable in regular custom hooks without additional configuration + component MyComponent() { + const onClick = useEffectEvent(() => { + showNotification(theme); + }); + useCustomHook(() => { + onClick(); + }); + } + `, + errors: [useEffectEventError('onClick', true)], + }, { code: normalizeIndent` // Invalid: useEffectEvent should not be callable in hooks not matching the settings regex @@ -1544,6 +1734,27 @@ const allTests = { }, errors: [useEffectEventError('onClick', true)], }, + { + syntax: 'flow', + code: normalizeIndent` + // Component syntax version + // Invalid: useEffectEvent should not be callable in hooks not matching the settings regex + component MyComponent(theme: any) { + const onClick = useEffectEvent(() => { + showNotification(theme); + }); + useWrongHook(() => { + onClick(); + }); + } + `, + settings: { + 'react-hooks': { + additionalEffectHooks: 'useMyEffect', + }, + }, + errors: [useEffectEventError('onClick', true)], + }, { code: normalizeIndent` function MyComponent({ theme }) { @@ -1555,6 +1766,19 @@ const allTests = { `, errors: [useEffectEventError('onClick', false)], }, + { + syntax: 'flow', + code: normalizeIndent` + // Component syntax version + component MyComponent(theme: any) { + const onClick = useEffectEvent(() => { + showNotification(theme); + }); + return ; + } + `, + errors: [useEffectEventError('onClick', false)], + }, { code: normalizeIndent` // Invalid because useEffectEvent is being passed down @@ -1566,6 +1790,19 @@ const allTests = { `, errors: [{...useEffectEventError(null, false), line: 4}], }, + { + syntax: 'flow', + code: normalizeIndent` + // Component syntax version + // Invalid because useEffectEvent is being passed down + component MyComponent(theme: any) { + return { + showNotification(theme); + })} />; + } + `, + errors: [{...useEffectEventError(null, false), line: 5}], + }, { code: normalizeIndent` // This should error even though it shares an identifier name with the below @@ -1601,6 +1838,43 @@ const allTests = { {...useEffectEventError('onClick', true), line: 15}, ], }, + { + syntax: 'flow', + code: normalizeIndent` + // Component syntax version + // This should error even though it shares an identifier name with the below + component MyComponent(theme: any) { + const onClick = useEffectEvent(() => { + showNotification(theme) + }); + return + } + + // The useEffectEvent function shares an identifier name with the above + component MyOtherComponent(theme: any) { + const onClick = useEffectEvent(() => { + showNotification(theme) + }); + return onClick()} /> + } + + // The useEffectEvent function shares an identifier name with the above + component MyLastComponent(theme: any) { + const onClick = useEffectEvent(() => { + showNotification(theme) + }); + useEffect(() => { + onClick(); // No error here, errors on all other uses + onClick; + }) + return + } + `, + errors: [ + {...useEffectEventError('onClick', false), line: 8}, + {...useEffectEventError('onClick', true), line: 16}, + ], + }, { code: normalizeIndent` const MyComponent = ({ theme }) => { @@ -1625,6 +1899,21 @@ const allTests = { `, errors: [{...useEffectEventError('onClick', false), line: 7}], }, + { + syntax: 'flow', + code: normalizeIndent` + // Component syntax version + // Invalid because onClick is being aliased to foo but not invoked + component MyComponent(theme: any) { + const onClick = useEffectEvent(() => { + showNotification(theme); + }); + let foo = onClick; + return + } + `, + errors: [{...useEffectEventError('onClick', false), line: 8}], + }, { code: normalizeIndent` // Should error because it's being passed down to JSX, although it's been referenced once @@ -1641,6 +1930,24 @@ const allTests = { `, errors: [useEffectEventError('onClick', false)], }, + { + syntax: 'flow', + code: normalizeIndent` + // Component syntax version + // Should error because it's being passed down to JSX, although it's been referenced once + // in an effect + component MyComponent(theme: any) { + const onClick = useEffectEvent(() => { + showNotification(them); + }); + useEffect(() => { + setTimeout(onClick, 100); + }); + return + } + `, + errors: [useEffectEventError('onClick', false)], + }, { code: normalizeIndent` // Invalid because functions created with useEffectEvent cannot be called in arbitrary closures. @@ -1676,6 +1983,43 @@ const allTests = { `It cannot be assigned to a variable or passed down.`, ], }, + { + syntax: 'flow', + code: normalizeIndent` + // Hook syntax version + // Invalid because functions created with useEffectEvent cannot be called in arbitrary closures. + hook useMyHook(theme: any) { + const onClick = useEffectEvent(() => { + showNotification(theme); + }); + // error message 1 + const onClick2 = () => { onClick() }; + // error message 2 + const onClick3 = useCallback(() => onClick(), []); + // error message 3 + const onClick4 = onClick; + return <> + {/** error message 4 */} + + + + ; + } + `, + // Explicitly test error messages here for various cases + errors: [ + `\`onClick\` is a function created with React Hook "useEffectEvent", and can only be called from ` + + 'Effects and Effect Events in the same component.', + `\`onClick\` is a function created with React Hook "useEffectEvent", and can only be called from ` + + 'Effects and Effect Events in the same component.', + `\`onClick\` is a function created with React Hook "useEffectEvent", and can only be called from ` + + `Effects and Effect Events in the same component. ` + + `It cannot be assigned to a variable or passed down.`, + `\`onClick\` is a function created with React Hook "useEffectEvent", and can only be called from ` + + `Effects and Effect Events in the same component. ` + + `It cannot be assigned to a variable or passed down.`, + ], + }, ], }; diff --git a/packages/eslint-plugin-react-hooks/src/rules/RulesOfHooks.ts b/packages/eslint-plugin-react-hooks/src/rules/RulesOfHooks.ts index 4e49e96bfe..ca82c99e2f 100644 --- a/packages/eslint-plugin-react-hooks/src/rules/RulesOfHooks.ts +++ b/packages/eslint-plugin-react-hooks/src/rules/RulesOfHooks.ts @@ -833,6 +833,18 @@ const rule = { recordAllUseEffectEventFunctions(getScope(node)); } }, + + // @ts-expect-error parser-hermes produces these node types + ComponentDeclaration(node) { + // component MyComponent() { const onClick = useEffectEvent(...) } + recordAllUseEffectEventFunctions(getScope(node)); + }, + + // @ts-expect-error parser-hermes produces these node types + HookDeclaration(node) { + // hook useMyHook() { const onClick = useEffectEvent(...) } + recordAllUseEffectEventFunctions(getScope(node)); + }, }; }, } satisfies Rule.RuleModule;