diff --git a/eslint-plugin-expensify/CONST.js b/eslint-plugin-expensify/CONST.js index 79baf8f..3bc4c1e 100644 --- a/eslint-plugin-expensify/CONST.js +++ b/eslint-plugin-expensify/CONST.js @@ -34,5 +34,7 @@ module.exports = { PREFER_TYPE_FEST_VALUE_OF: 'Prefer using `ValueOf` from `type-fest` to extract the type of the properties of an object.', PREFER_AT: 'Prefer using the `.at()` method for array element access.', PREFER_SHOULD_USE_NARROW_LAYOUT_INSTEAD_OF_IS_SMALL_SCREEN_WIDTH: 'Prefer using `shouldUseNarrowLayout` instead of `isSmallScreenWidth` from `useResponsiveLayout`.', + NO_USE_STATE_INITIALIZER_CALL_FUNCTION: + 'Avoid calling a function directly in the useState initializer. Use an initializer function instead (a callback).', }, }; diff --git a/eslint-plugin-expensify/no-use-state-initializer-functions.js b/eslint-plugin-expensify/no-use-state-initializer-functions.js new file mode 100644 index 0000000..f788e2c --- /dev/null +++ b/eslint-plugin-expensify/no-use-state-initializer-functions.js @@ -0,0 +1,84 @@ +const message = require('./CONST').MESSAGE.NO_USE_STATE_INITIALIZER_CALL_FUNCTION; + +module.exports = { + meta: { + type: 'problem', // This is a potential performance issue + docs: { + description: + 'Disallow direct function calls in useState initializer', + category: 'Best Practices', + recommended: false, + }, + schema: [], // No options for this rule + messages: { + noDirectCall: message, + }, + }, + create(context) { + return { + CallExpression(node) { + // Return early if the function being called is not `useState` + if ( + node.callee.type !== 'Identifier' + || node.callee.name !== 'useState' + || node.arguments.length === 0 + ) { + return; + } + + const firstArg = node.arguments[0]; + + // Return early if the first argument is not a function call, member expression with a function call, or conditional expression with function calls + if ( + firstArg.type !== 'CallExpression' + && !(firstArg.type === 'MemberExpression' && firstArg.object.type === 'CallExpression') + && !(firstArg.type === 'ConditionalExpression' + && (firstArg.consequent.type === 'CallExpression' || firstArg.alternate.type === 'CallExpression')) + ) { + return; + } + + context.report({ + node: firstArg, + messageId: 'noDirectCall', + }); + }, + + // Handle cases where the initializer is passed as a function reference + VariableDeclarator(node) { + if ( + !node.init + || node.init.type !== 'CallExpression' + || node.init.callee.name !== 'useState' + || node.init.arguments.length === 0 + ) { + return; + } + + const firstArg = node.init.arguments[0]; + + // Return early if the first argument is a function reference (valid case) + if ( + firstArg.type === 'Identifier' // e.g., `getChatTabBrickRoad` + || (firstArg.type === 'ArrowFunctionExpression' + && firstArg.body.type !== 'CallExpression') // e.g., `() => getChatTabBrickRoad` + ) { + return; // Valid case, do nothing + } + + // If it's a direct function call, member expression with a function call, or conditional expression with function calls, report it + if ( + firstArg.type === 'CallExpression' + || (firstArg.type === 'MemberExpression' && firstArg.object.type === 'CallExpression') + || (firstArg.type === 'ConditionalExpression' + && (firstArg.consequent.type === 'CallExpression' || firstArg.alternate.type === 'CallExpression')) + ) { + context.report({ + node: firstArg, + messageId: 'noDirectCall', + }); + } + }, + }; + }, +}; diff --git a/eslint-plugin-expensify/tests/no-use-state-initializer-functions.test.js b/eslint-plugin-expensify/tests/no-use-state-initializer-functions.test.js new file mode 100644 index 0000000..3cc06ea --- /dev/null +++ b/eslint-plugin-expensify/tests/no-use-state-initializer-functions.test.js @@ -0,0 +1,85 @@ +const RuleTester = require('eslint').RuleTester; +const rule = require('../no-use-state-initializer-functions'); +const message = require('../CONST').MESSAGE.NO_USE_STATE_INITIALIZER_CALL_FUNCTION; + +const ruleTester = new RuleTester({ + parserOptions: { + ecmaVersion: 6, + sourceType: 'module', + }, +}); + +ruleTester.run('no-use-state-initializer-functions', rule, { + valid: [ + { + // Calling a callback should be valid + code: ` + useState(() => testFunc()); + `, + }, + { + // Calling a callback should be valid + code: ` + useState(() => testFunc().value); + `, + }, + { + // Calling a callback should be valid + code: ` + useState(condition ? testFunc : testFunc); + `, + }, + { + // Calling a callback should be valid + code: ` + useState(condition ? () => testFunc() : () => testFunc()); + `, + }, + ], + invalid: [ + { + // Calling a function should be invalid + code: ` + useState(testFunc()); + `, + errors: [ + { + message, + }, + ], + }, + { + // Calling a function should be invalid + code: ` + useState(testFunc().value); + `, + errors: [ + { + message, + }, + ], + }, + { + // Calling a function should be invalid + code: ` + useState(condition ? testFunc() : testFunc()); + `, + errors: [ + { + message, + }, + ], + }, + { + // Calling a function should be invalid + code: ` + useState(condition ? (() => testFunc())() : (() => testFunc())()); + `, + errors: [ + { + message, + }, + ], + }, + ], +}); diff --git a/rules/expensify.js b/rules/expensify.js index f3e6076..59d199b 100644 --- a/rules/expensify.js +++ b/rules/expensify.js @@ -29,6 +29,7 @@ module.exports = { }], }], 'rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth': 'warn', + 'rulesdir/no-use-state-initializer-functions': 'error', }, };