diff --git a/package-lock.json b/package-lock.json index 28a95bbf..62bd9cfa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4166,6 +4166,25 @@ "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" } }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", + "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, "node_modules/eslint-plugin-react/node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -5141,6 +5160,21 @@ "node": ">= 0.4" } }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -9240,6 +9274,18 @@ "url": "https://github.com/sponsors/colinhacks" } }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, "packages/code-analyzer-core": { "name": "@salesforce/code-analyzer-core", "version": "0.41.0", @@ -9770,6 +9816,7 @@ "eslint-plugin-import": "^2.32.0", "eslint-plugin-jest": "^29.5.0", "eslint-plugin-react": "^7.37.2", + "eslint-plugin-react-hooks": "^7.0.1", "globals": "^16.5.0", "semver": "^7.7.3", "typescript": "^5.9.3", diff --git a/packages/code-analyzer-eslint-engine/package.json b/packages/code-analyzer-eslint-engine/package.json index 3c4d0e36..147c903f 100644 --- a/packages/code-analyzer-eslint-engine/package.json +++ b/packages/code-analyzer-eslint-engine/package.json @@ -29,6 +29,7 @@ "eslint-plugin-import": "^2.32.0", "eslint-plugin-jest": "^29.5.0", "eslint-plugin-react": "^7.37.2", + "eslint-plugin-react-hooks": "^7.0.1", "globals": "^16.5.0", "semver": "^7.7.3", "typescript": "^5.9.3", diff --git a/packages/code-analyzer-eslint-engine/src/base-config.ts b/packages/code-analyzer-eslint-engine/src/base-config.ts index 88425804..4cb58a84 100644 --- a/packages/code-analyzer-eslint-engine/src/base-config.ts +++ b/packages/code-analyzer-eslint-engine/src/base-config.ts @@ -5,6 +5,7 @@ import lwcEslintPluginLwcPlatform from "@lwc/eslint-plugin-lwc-platform"; import salesforceEslintConfigLwc from "@salesforce/eslint-config-lwc"; import sldsEslintPlugin from "@salesforce-ux/eslint-plugin-slds"; import eslintPluginReact from "eslint-plugin-react"; +import eslintPluginReactHooks from "eslint-plugin-react-hooks"; import {ESLintEngineConfig} from "./config"; import globals from "globals"; @@ -63,7 +64,7 @@ export class BaseConfigFactory { } private createJavascriptPlusLwcConfigArray(): Linter.Config[] { - let configs: Linter.Config[] = validateAndGetRawLwcConfigArray(); + const configs: Linter.Config[] = validateAndGetRawLwcConfigArray(); // Reconstruct languageOptions to avoid mutating the original shared config from the LWC package // TODO: Remove configFile and sourceType overrides when https://github.com/salesforce/eslint-config-lwc/issues/158 is fixed @@ -88,8 +89,17 @@ export class BaseConfigFactory { } }; - // Swap out eslintJs.configs.recommended with eslintJs.configs.all - configs[1] = eslintJs.configs.all; + // File patterns for different config types + const allJsExtensions = this.engineConfig.file_extensions.javascript; + const lwcExtensions = allJsExtensions.filter(ext => ext !== '.jsx'); + const allJsFilePatterns = allJsExtensions.map(ext => `**/*${ext}`); + const lwcFilePatterns = lwcExtensions.map(ext => `**/*${ext}`); + + // Base JS rules (eslintJs.configs.all) - applies to ALL JS files including .jsx + const baseJsConfig: Linter.Config = { + ...eslintJs.configs.all, + files: allJsFilePatterns + }; // This one rule makes eslint throw an exception if the user doesn't have jest installed (which should be // optional), so we turn it off for now. See https://github.com/salesforce/eslint-config-lwc/issues/161 @@ -105,18 +115,15 @@ export class BaseConfigFactory { '@lwc/lwc-platform/valid-offline-wire': 'off' } - // Restrict these configs to just javascript files, excluding .jsx - // since those are React files, not LWC components - const lwcExtensions = this.engineConfig.file_extensions.javascript - .filter(ext => ext !== '.jsx'); - configs = configs.map(config => { - return { - ...config, - files: lwcExtensions.map(ext => `**/*${ext}`) - } - }); + // Apply LWC file patterns to LWC-specific configs (excludes .jsx - React files aren't LWC) + // Then insert the base JS config at position 1 + const lwcConfigs: Linter.Config[] = configs.map(config => ({ + ...config, + files: lwcFilePatterns + })); + lwcConfigs[1] = baseJsConfig; - return configs; + return lwcConfigs; } private createLwcConfigArray(): Linter.Config[] { @@ -191,16 +198,17 @@ export class BaseConfigFactory { } /** - * Creates React plugin config for JavaScript files. + * Creates React plugin config for JavaScript and TypeScript files. * - * React rules are applied to all JS files (.js, .jsx, .cjs, .mjs) - if a file - * doesn't contain React code, the rules simply won't report any violations. + * Includes both eslint-plugin-react and eslint-plugin-react-hooks: + * - react/*: All React rules for JSX/TSX and component patterns + * - react-hooks/rules-of-hooks: Enforces the Rules of Hooks + * - react-hooks/exhaustive-deps: Verifies the list of dependencies for Hooks * - * Note: TypeScript React support (.tsx) is planned for the next iteration. + * React rules are applied to all JS/TS files - if a file doesn't contain React code, + * the rules simply won't report any violations. */ private createReactConfigArray(): Linter.Config[] { - // Apply React rules to all JavaScript and TypeScript files - const jsExtensions = this.engineConfig.file_extensions.javascript; const tsExtensions = this.engineConfig.file_extensions.typescript; const reactExtensions = [...new Set([...jsExtensions, ...tsExtensions])]; @@ -209,22 +217,46 @@ export class BaseConfigFactory { return []; } + const filePatterns = reactExtensions.map(ext => `**/*${ext}`); + // Get all rules from eslint-plugin-react's flat config const reactAllConfig = eslintPluginReact.configs.flat.all; - return [{ - ...reactAllConfig, - files: reactExtensions.map(ext => `**/*${ext}`), - settings: { - ...reactAllConfig.settings, - react: { - // React version - "detect" automatically picks the installed version, falls back to latest - version: 'detect', - // Pragma is the function JSX compiles to (e.g.,
→ React.createElement('div')) - pragma: 'React' + // Get jsx-runtime config to disable outdated rules (react-in-jsx-scope, jsx-uses-react) + // These rules are not needed for React 17+ which is now the standard (released Oct 2020) + const jsxRuntimeConfig = eslintPluginReact.configs.flat['jsx-runtime']; + + return [ + // React all rules config + { + ...reactAllConfig, + files: filePatterns, + settings: { + ...reactAllConfig.settings, + react: { + // React version - "detect" automatically picks the installed version, falls back to latest + version: 'detect', + // Pragma is the function JSX compiles to (e.g.,
→ React.createElement('div')) + pragma: 'React' + } + } + }, + // jsx-runtime config disables outdated rules for React 17+ + { + ...jsxRuntimeConfig, + files: filePatterns + }, + // React Hooks plugin config - use flat.recommended but only enable the 2 classic rules + // (v7.x includes many React Compiler rules that we filter out) + { + ...eslintPluginReactHooks.configs.flat.recommended, + files: filePatterns, + rules: { + 'react-hooks/rules-of-hooks': 'error', + 'react-hooks/exhaustive-deps': 'warn' } } - }]; + ]; } private useJsBaseConfig(): boolean { diff --git a/packages/code-analyzer-eslint-engine/src/missing-types-for-dependencies.d.ts b/packages/code-analyzer-eslint-engine/src/missing-types-for-dependencies.d.ts index 66fdc688..ff880f3c 100644 --- a/packages/code-analyzer-eslint-engine/src/missing-types-for-dependencies.d.ts +++ b/packages/code-analyzer-eslint-engine/src/missing-types-for-dependencies.d.ts @@ -60,11 +60,7 @@ declare module 'eslint-plugin-react' { const plugin: ESLint.Plugin & { readonly rules: Record; readonly configs: { - readonly recommended: Linter.Config; - readonly all: Linter.Config; - readonly "jsx-runtime": Linter.Config; readonly flat: { - readonly recommended: Linter.Config; readonly all: Linter.Config; readonly "jsx-runtime": Linter.Config; }; @@ -72,3 +68,20 @@ declare module 'eslint-plugin-react' { }; export = plugin; } + +// This declaration adds in the missing types for "eslint-plugin-react-hooks" +// We use configs.flat.recommended but override rules to only enable rules-of-hooks and exhaustive-deps +declare module 'eslint-plugin-react-hooks' { + import type { ESLint, Linter } from 'eslint'; + import type { RuleDefinition } from "@eslint/core"; + + const plugin: ESLint.Plugin & { + readonly rules: Record; + readonly configs: { + readonly flat: { + readonly recommended: Linter.Config; + }; + }; + }; + export = plugin; +} diff --git a/packages/code-analyzer-eslint-engine/src/rule-mappings/react.ts b/packages/code-analyzer-eslint-engine/src/rule-mappings/react.ts index 345a90ff..1e3dc70f 100644 --- a/packages/code-analyzer-eslint-engine/src/rule-mappings/react.ts +++ b/packages/code-analyzer-eslint-engine/src/rule-mappings/react.ts @@ -26,11 +26,7 @@ export const RULE_MAPPINGS_REACT_RECOMMENDED: Record = { + "react-hooks/rules-of-hooks": { + severity: SeverityLevel.High, + tags: [COMMON_TAGS.RECOMMENDED, REACT, COMMON_TAGS.CATEGORIES.DESIGN, COMMON_TAGS.LANGUAGES.JAVASCRIPT, COMMON_TAGS.LANGUAGES.TYPESCRIPT] + }, + "react-hooks/exhaustive-deps": { + severity: SeverityLevel.Moderate, + tags: [COMMON_TAGS.RECOMMENDED, REACT, COMMON_TAGS.CATEGORIES.DESIGN, COMMON_TAGS.LANGUAGES.JAVASCRIPT, COMMON_TAGS.LANGUAGES.TYPESCRIPT] + }, +}; + export const RULE_MAPPINGS_REACT: Record = { ...RULE_MAPPINGS_REACT_RECOMMENDED, - ...RULE_MAPPINGS_REACT_NOT_RECOMMENDED + ...RULE_MAPPINGS_REACT_NOT_RECOMMENDED, + ...RULE_MAPPINGS_REACT_HOOKS }; diff --git a/packages/code-analyzer-eslint-engine/test/engine.test.ts b/packages/code-analyzer-eslint-engine/test/engine.test.ts index 72960c68..278acc4c 100644 --- a/packages/code-analyzer-eslint-engine/test/engine.test.ts +++ b/packages/code-analyzer-eslint-engine/test/engine.test.ts @@ -65,7 +65,7 @@ describe('Tests for the describeRules method of ESLintEngine', () => { const HTML_CONFIG_RULES: RuleDescription[] = loadRuleDescriptions('rules_OnlySldsHtmlBaseConfig.goldfile.json'); const REACT_CONFIG_RULES: RuleDescription[] = loadRuleDescriptions('rules_ReactConfig.goldfile.json'); const SLDS_CONFIG_RULES: RuleDescription[] = makeUniqueAndSorted([...CSS_CONFIG_RULES, ...HTML_CONFIG_RULES]); - // React rules apply to all JS files (not just .jsx) - if no React code, rules simply don't report violations + // React rules (including React Hooks) apply to all JS files - if no React code, rules simply don't report violations const DEFAULT_RULES: RuleDescription[] = makeUniqueAndSorted([...LWC_CONFIG_RULES, ...JS_CONFIG_RULES, ...TS_CONFIG_RULES, ...SLDS_CONFIG_RULES, ...REACT_CONFIG_RULES]); const CUSTOM_RULES: RuleDescription[] = loadRuleDescriptions('rules_OnlyCustomConfigWithNewRules.goldfile.json'); @@ -150,7 +150,7 @@ describe('Tests for the describeRules method of ESLintEngine', () => { const ruleDescriptions: RuleDescription[] = await engine.describeRules(createDescribeOptions(new Workspace('id', [ path.join(workspaceWithNoCustomConfig, 'dummy1.js'), path.join(workspaceWithNoCustomConfig, 'dummy3.txt')]))); - // React rules included - applies to .js files + // React rules (including React Hooks) included - applies to .js files expect(ruleDescriptions).toEqual(makeUniqueAndSorted([...LWC_CONFIG_RULES, ...JS_CONFIG_RULES, ...REACT_CONFIG_RULES])); }); @@ -172,7 +172,7 @@ describe('Tests for the describeRules method of ESLintEngine', () => { disable_javascript_base_config: true }); const ruleDescriptions: RuleDescription[] = await engine.describeRules(createDescribeOptions()); - // React rules included - no workspace provided means placeholder files used (includes .jsx) + // React rules (including React Hooks) included - no workspace provided means placeholder files used (includes .jsx) expect(ruleDescriptions).toEqual(makeUniqueAndSorted([...LWC_CONFIG_RULES, ...TS_CONFIG_RULES, ...SLDS_CONFIG_RULES, ...REACT_CONFIG_RULES])); }); @@ -181,7 +181,7 @@ describe('Tests for the describeRules method of ESLintEngine', () => { disable_lwc_base_config: true }); const ruleDescriptions: RuleDescription[] = await engine.describeRules(createDescribeOptions()); - // React rules included - no workspace provided means placeholder files used (includes .jsx) + // React rules (including React Hooks) included - no workspace provided means placeholder files used (includes .jsx) expect(ruleDescriptions).toEqual(makeUniqueAndSorted([...JS_CONFIG_RULES, ...TS_CONFIG_RULES, ...SLDS_CONFIG_RULES, ...REACT_CONFIG_RULES])); }); @@ -190,7 +190,7 @@ describe('Tests for the describeRules method of ESLintEngine', () => { disable_slds_base_config: true }); const ruleDescriptions: RuleDescription[] = await engine.describeRules(createDescribeOptions()); - // React rules included - no workspace provided means placeholder files used (includes .jsx) + // React rules (including React Hooks) included - no workspace provided means placeholder files used (includes .jsx) expect(ruleDescriptions).toEqual(makeUniqueAndSorted([...LWC_CONFIG_RULES, ...JS_CONFIG_RULES, ...TS_CONFIG_RULES, ...REACT_CONFIG_RULES])); }); @@ -210,7 +210,7 @@ describe('Tests for the describeRules method of ESLintEngine', () => { disable_slds_base_config: true }); const ruleDescriptions: RuleDescription[] = await engine.describeRules(createDescribeOptions()); - // React rules included - no workspace provided means placeholder files used (includes .jsx) + // React rules (including React Hooks) included - no workspace provided means placeholder files used (includes .jsx) expect(ruleDescriptions).toEqual(makeUniqueAndSorted([...JS_CONFIG_RULES, ...REACT_CONFIG_RULES])); }); @@ -848,6 +848,83 @@ describe('Typical tests for the runRules method of ESLintEngine', () => { v.codeLocations[0].file.endsWith('.jsx')); expect(jsxViolations.length).toBeGreaterThan(0); }); + + it('When runRules is called with react-hooks rules, then violations are detected in HooksViolations.jsx', async () => { + const engine: Engine = await createEngineFromPlugin(DEFAULT_CONFIG_FOR_TESTING); + const runOptions: RunOptions = createRunOptions(new Workspace('id', [workspaceWithReactFiles])); + const results: EngineRunResults = await engine.runRules(['react-hooks/rules-of-hooks', 'react-hooks/exhaustive-deps'], runOptions); + + // HooksViolations.jsx has multiple violations for both rules + expect(results.violations.length).toBeGreaterThan(0); + + // Should have violations for rules-of-hooks (hooks called conditionally/in loops) + const rulesOfHooksViolations = results.violations.filter(v => v.ruleName === 'react-hooks/rules-of-hooks'); + expect(rulesOfHooksViolations.length).toBeGreaterThan(0); + + // Should have violations for exhaustive-deps (missing dependencies) + const exhaustiveDepsViolations = results.violations.filter(v => v.ruleName === 'react-hooks/exhaustive-deps'); + expect(exhaustiveDepsViolations.length).toBeGreaterThan(0); + + // All violations should be in HooksViolations.jsx + const hooksViolationsFile = results.violations.filter(v => + v.codeLocations[0].file.endsWith('HooksViolations.jsx')); + expect(hooksViolationsFile.length).toBeGreaterThan(0); + }); + + it('When runRules is called with base JS rules on .jsx files, then violations are reported', async () => { + // This test verifies that base JavaScript rules (not just React-specific rules) are applied to .jsx files + // This is a regression test - previously .jsx files were excluded from base JS rules when both JS+LWC configs were enabled + const engine: Engine = await createEngineFromPlugin(DEFAULT_CONFIG_FOR_TESTING); + const runOptions: RunOptions = createRunOptions(new Workspace('id', [workspaceWithReactFiles])); + + // Use 'id-length' - a base JS rule that triggers on short variable names like 'i' in for loops + const results: EngineRunResults = await engine.runRules(['id-length'], runOptions); + + // Should have violations in .jsx files (HooksViolations.jsx has 'i' variable in for loop) + const jsxViolations = results.violations.filter(v => + v.codeLocations[0].file.endsWith('.jsx')); + expect(jsxViolations.length).toBeGreaterThan(0); + + // Verify the rule is a base JS rule, not a React rule + expect(jsxViolations.every(v => v.ruleName === 'id-length')).toBe(true); + }); + + it('When runRules is called with LWC rules, then no violations are reported for .jsx files', async () => { + // This test verifies that LWC-specific rules are NOT applied to .jsx files + // .jsx files are React files, not LWC components, so LWC rules should not apply + // HooksViolations.jsx contains innerHTML usage (ComponentWithInnerHTML) that WOULD + // trigger @lwc/lwc/no-inner-html if LWC rules were applied, but they shouldn't be. + const engine: Engine = await createEngineFromPlugin(DEFAULT_CONFIG_FOR_TESTING); + const runOptions: RunOptions = createRunOptions(new Workspace('id', [workspaceWithReactFiles])); + + // Run '@lwc/lwc/no-inner-html' - HooksViolations.jsx has innerHTML usage that would trigger this + const results: EngineRunResults = await engine.runRules(['@lwc/lwc/no-inner-html'], runOptions); + + // Should NOT have any violations in .jsx files (LWC rules don't apply to React files) + const jsxViolations = results.violations.filter(v => + v.codeLocations[0].file.endsWith('.jsx')); + expect(jsxViolations.length).toBe(0); + }); +}); + +describe('Tests for React Hooks rules', () => { + it('React Hooks rules are included in describeRules output', async () => { + const engine: Engine = await createEngineFromPlugin(DEFAULT_CONFIG_FOR_TESTING); + + const ruleDescriptions = await engine.describeRules(createDescribeOptions( + new Workspace('id', [workspaceWithReactFiles]))); + + // Both react-hooks rules should be present + const rulesOfHooks = ruleDescriptions.find(r => r.name === 'react-hooks/rules-of-hooks'); + expect(rulesOfHooks).toBeDefined(); + expect(rulesOfHooks!.tags).toContain('Recommended'); + expect(rulesOfHooks!.tags).toContain('React'); + + const exhaustiveDeps = ruleDescriptions.find(r => r.name === 'react-hooks/exhaustive-deps'); + expect(exhaustiveDeps).toBeDefined(); + expect(exhaustiveDeps!.tags).toContain('Recommended'); + expect(exhaustiveDeps!.tags).toContain('React'); + }); }); describe('Tests for the getEngineVersion method of ESLint Engine', () => { diff --git a/packages/code-analyzer-eslint-engine/test/test-data/rules_ReactConfig.goldfile.json b/packages/code-analyzer-eslint-engine/test/test-data/rules_ReactConfig.goldfile.json index f411767e..a418f570 100644 --- a/packages/code-analyzer-eslint-engine/test/test-data/rules_ReactConfig.goldfile.json +++ b/packages/code-analyzer-eslint-engine/test/test-data/rules_ReactConfig.goldfile.json @@ -1,4 +1,34 @@ [ + { + "description": "verifies the list of dependencies for Hooks like useEffect and similar", + "name": "react-hooks/exhaustive-deps", + "resourceUrls": [ + "https://github.com/facebook/react/issues/14920" + ], + "severityLevel": 3, + "tags": [ + "Recommended", + "React", + "Design", + "JavaScript", + "TypeScript" + ] + }, + { + "description": "enforces the Rules of Hooks", + "name": "react-hooks/rules-of-hooks", + "resourceUrls": [ + "https://react.dev/reference/rules/rules-of-hooks" + ], + "severityLevel": 2, + "tags": [ + "Recommended", + "React", + "Design", + "JavaScript", + "TypeScript" + ] + }, { "description": "Enforces consistent naming for boolean props", "name": "react/boolean-prop-naming", @@ -705,21 +735,6 @@ "TypeScript" ] }, - { - "description": "Disallow React to be incorrectly marked as unused", - "name": "react/jsx-uses-react", - "resourceUrls": [ - "https://github.com/jsx-eslint/eslint-plugin-react/tree/master/docs/rules/jsx-uses-react.md" - ], - "severityLevel": 3, - "tags": [ - "Recommended", - "React", - "Design", - "JavaScript", - "TypeScript" - ] - }, { "description": "Disallow variables used in JSX to be incorrectly marked as unused", "name": "react/jsx-uses-vars", @@ -1264,21 +1279,6 @@ "TypeScript" ] }, - { - "description": "Disallow missing React when using JSX", - "name": "react/react-in-jsx-scope", - "resourceUrls": [ - "https://github.com/jsx-eslint/eslint-plugin-react/tree/master/docs/rules/react-in-jsx-scope.md" - ], - "severityLevel": 3, - "tags": [ - "Recommended", - "React", - "Design", - "JavaScript", - "TypeScript" - ] - }, { "description": "Enforce a defaultProps definition for every prop that is not a required prop", "name": "react/require-default-props", diff --git a/packages/code-analyzer-eslint-engine/test/test-data/workspaceWithReactFiles/HooksViolations.jsx b/packages/code-analyzer-eslint-engine/test/test-data/workspaceWithReactFiles/HooksViolations.jsx new file mode 100644 index 00000000..e51eb940 --- /dev/null +++ b/packages/code-analyzer-eslint-engine/test/test-data/workspaceWithReactFiles/HooksViolations.jsx @@ -0,0 +1,103 @@ +import React, { useState, useEffect, useCallback, useMemo } from 'react'; + +// ============================================================================ +// VIOLATIONS OF: react-hooks/rules-of-hooks +// Rule: Hooks must be called at the top level, not inside conditions/loops +// ============================================================================ + +function ComponentWithConditionalHook({ showCounter }) { + // VIOLATION: Hook called inside a condition + if (showCounter) { + const [count, setCount] = useState(0); + } + + return
Component
; +} + +function ComponentWithLoopHook({ items }) { + // VIOLATION: Hook called inside a loop + for (let i = 0; i < items.length; i++) { + const [itemState, setItemState] = useState(items[i]); + } + + return
Component with loop
; +} + +// VIOLATION: Hook called in a regular function (not a component or custom hook) +function regularFunction() { + const [value, setValue] = useState('test'); + return value; +} + +// ============================================================================ +// CODE THAT WOULD TRIGGER LWC RULES (but shouldn't since this is a React file) +// @lwc/lwc/no-inner-html - LWC rule that disallows innerHTML usage +// This code uses innerHTML which would be flagged by LWC rules, but since +// .jsx files are React files (not LWC), this rule should NOT be applied. +// ============================================================================ + +function ComponentWithInnerHTML() { + const ref = React.useRef(null); + + useEffect(() => { + // This would trigger @lwc/lwc/no-inner-html if LWC rules were applied + if (ref.current) { + ref.current.innerHTML = 'Dynamic content'; + } + }, []); + + return
; +} + +// ============================================================================ +// VIOLATIONS OF: react-hooks/exhaustive-deps +// Rule: Dependencies array must include all values used inside the effect +// ============================================================================ + +function ComponentWithMissingDeps({ userId, config }) { + const [userData, setUserData] = useState(null); + const [count, setCount] = useState(0); + + // VIOLATION: 'userId' is used but not in dependencies + useEffect(() => { + fetch(`/api/users/${userId}`) + .then(res => res.json()) + .then(data => setUserData(data)); + }, []); // Missing 'userId' in dependency array + + // VIOLATION: 'config' is used but not in dependencies + useEffect(() => { + console.log('Config changed:', config.theme); + document.body.className = config.theme; + }, []); // Missing 'config' or 'config.theme' in dependency array + + // VIOLATION: 'count' is used but not in dependencies + const handleClick = useCallback(() => { + console.log('Current count:', count); + setCount(count + 1); + }, []); // Missing 'count' in dependency array + + // VIOLATION: 'userData' is used but not in dependencies + const processedData = useMemo(() => { + if (!userData) return null; + return { + ...userData, + displayName: userData.firstName + ' ' + userData.lastName + }; + }, []); // Missing 'userData' in dependency array + + return ( +
+

User: {processedData?.displayName}

+ +
+ ); +} + +export { + ComponentWithConditionalHook, + ComponentWithLoopHook, + regularFunction, + ComponentWithInnerHTML, + ComponentWithMissingDeps +};