diff --git a/packages/eslint-config/base.js b/packages/eslint-config/base.js index 6c5ec42..ff301ba 100644 --- a/packages/eslint-config/base.js +++ b/packages/eslint-config/base.js @@ -1,5 +1,6 @@ import js from "@eslint/js"; import eslintConfigPrettier from "eslint-config-prettier"; +import reactStrictStructure from "eslint-plugin-react-strict-structure"; import strictEnv from "eslint-plugin-strict-env"; import turboPlugin from "eslint-plugin-turbo"; import tseslint from "typescript-eslint"; @@ -23,9 +24,11 @@ export const config = [ }, { plugins: { + "react-strict-structure": reactStrictStructure, "strict-env": strictEnv, }, rules: { + "react-strict-structure/no-inline-jsx-functions": "error", "strict-env/no-process-env": "error", }, }, diff --git a/packages/eslint-config/package.json b/packages/eslint-config/package.json index 69a0447..f6f2469 100644 --- a/packages/eslint-config/package.json +++ b/packages/eslint-config/package.json @@ -18,6 +18,7 @@ "eslint-plugin-only-warn": "^1.1.0", "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-strict-structure": "workspace:*", "eslint-plugin-strict-env": "workspace:*", "eslint-plugin-turbo": "^2.5.5", "globals": "^15.15.0", diff --git a/packages/eslint-plugin-react-strict-structure/README.md b/packages/eslint-plugin-react-strict-structure/README.md new file mode 100644 index 0000000..e4ef595 --- /dev/null +++ b/packages/eslint-plugin-react-strict-structure/README.md @@ -0,0 +1,20 @@ +# eslint-plugin-react-strict-structure + +A custom ESLint plugin to enforce strict React performance patterns. Specifically, it prevents the definition of inline functions that return JSX inside components. + +## Why use this? + +Defining a function that returns JSX _inside_ another component (often called a "render helper") is a React anti-pattern. + +```javascript +// ❌ BAD +function Parent() { + // This function is re-created on every render + const RenderItem = () =>
Item
; + + // React treats as a BRAND NEW component type every time. + // Result: It unmounts the old one, mounts the new one. + // Side effects: Loss of state, loss of focus, high memory churn. + return ; +} +``` diff --git a/packages/eslint-plugin-react-strict-structure/index.js b/packages/eslint-plugin-react-strict-structure/index.js new file mode 100644 index 0000000..fbdee74 --- /dev/null +++ b/packages/eslint-plugin-react-strict-structure/index.js @@ -0,0 +1,15 @@ +import { noInlineJsxFunctions } from "./rules/no-inline-jsx-functions.js"; + +export default { + rules: { + 'no-inline-jsx-functions': noInlineJsxFunctions, + }, + configs: { + recommended: { + plugins: ['react-strict-structure'], + rules: { + 'react-strict-structure/no-inline-jsx-functions': 'error', + }, + }, + }, +}; diff --git a/packages/eslint-plugin-react-strict-structure/package.json b/packages/eslint-plugin-react-strict-structure/package.json new file mode 100644 index 0000000..d2cbfae --- /dev/null +++ b/packages/eslint-plugin-react-strict-structure/package.json @@ -0,0 +1,21 @@ +{ + "name": "eslint-plugin-react-strict-structure", + "version": "0.0.1", + "type": "module", + "main": "index.js", + "scripts": { + "test": "node --test src/rules/no-inline-jsx-functions.test.js" + }, + "keywords": [ + "eslint", + "eslint-plugin", + "eslint-plugin-react", + "eslint-plugin-react-jsx" + ], + "peerDependencies": { + "eslint": ">=9.0.0" + }, + "devDependencies": { + "eslint": "^9.39.2" + } +} diff --git a/packages/eslint-plugin-react-strict-structure/rules/no-inline-jsx-functions.js b/packages/eslint-plugin-react-strict-structure/rules/no-inline-jsx-functions.js new file mode 100644 index 0000000..133f841 --- /dev/null +++ b/packages/eslint-plugin-react-strict-structure/rules/no-inline-jsx-functions.js @@ -0,0 +1,98 @@ +export const noInlineJsxFunctions = { + meta: { + type: "problem", + docs: { + description: + "Prevent definition of inline functions that return JSX inside components", + category: "Best Practices", + recommended: true, + }, + schema: [], + messages: { + noInlineJsx: + "Avoid defining functions that return JSX inside components. Move this to a separate component or outside the body.", + }, + }, + create(context) { + return { + // Check Function Declarations, Expressions, and Arrow Functions + ":function"(node) { + if (!parentFunction(parent(node.parent))) { + // It's a top-level component, which is fine + return; + } + + if (isReturnJSX(node.body)) { + context.report({ + node, + messageId: "noInlineJsx", + }); + } + }, + }; + }, +}; + +function parent(nodeParent) { + let parent = nodeParent; + while (parent) { + if (isFunction(parent)) { + return parent; + } + parent = parent.parent; + } + + return parent; +} + +function parentFunction(parent) { + while (parent) { + if (isFunction(parent)) { + return parent; + } + } + + return null; +} + +function isReturnJSX(nodeBody) { + const { body, type } = nodeBody; + if (type === "BlockStatement") { + // Look for 'return ' inside the function body + for (const { argument, type } of body) { + if (type === "ReturnStatement" && isJSX(argument)) { + return true; + } + } + + return false; + } + + if (isJSX(nodeBody)) { + // Implicit arrow return: () =>
+ return true; + } + + return false; +} + +function isFunction(node) { + if (!node) { + return false; + } + + return [ + "FunctionDeclaration", + "FunctionExpression", + "ArrowFunctionExpression", + ].includes(node.type); +} + +function isJSX(node) { + if (!node) { + return false; + } + + const { type } = node; + return type === "JSXElement" || type === "JSXFragment"; +} diff --git a/packages/eslint-plugin-react-strict-structure/rules/no-inline-jsx-functions.test.js b/packages/eslint-plugin-react-strict-structure/rules/no-inline-jsx-functions.test.js new file mode 100644 index 0000000..5e4ba55 --- /dev/null +++ b/packages/eslint-plugin-react-strict-structure/rules/no-inline-jsx-functions.test.js @@ -0,0 +1,45 @@ +import { RuleTester } from "eslint"; +import { noInlineJsxFunctions } from "./no-inline-jsx-functions.js"; + +new RuleTester({ + languageOptions: { + ecmaVersion: 2020, + sourceType: "module", + parserOptions: { + ecmaFeatures: { + jsx: true, + }, + }, + }, +}).run("no-inline-jsx-functions", noInlineJsxFunctions, { + valid: [ + { + code: `function MyComponent() { return
Hello
; }`, + }, + { + code: `const Helper = () => Helper; function MyComponent() { return ; }`, + }, + ], + invalid: [ + { + code: ` + function MyComponent() { + function NestedHelper() { + return
Bad
; + } + return ; + } + `, + errors: [{ messageId: "noInlineJsx" }], + }, + { + code: ` + const MyComponent = () => { + const renderItem = () => Item; + return
{renderItem()}
; + }; + `, + errors: [{ messageId: "noInlineJsx" }], + }, + ], +}); \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ce2cdc3..064782f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -172,6 +172,9 @@ importers: eslint-plugin-react-hooks: specifier: ^5.2.0 version: 5.2.0(eslint@9.32.0(jiti@2.5.1)) + eslint-plugin-react-strict-structure: + specifier: workspace:* + version: link:../eslint-plugin-react-strict-structure eslint-plugin-strict-env: specifier: workspace:* version: link:../eslint-plugin-strict-env @@ -188,6 +191,12 @@ importers: specifier: ^8.39.0 version: 8.39.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.2) + packages/eslint-plugin-react-strict-structure: + devDependencies: + eslint: + specifier: ^9.39.2 + version: 9.39.2(jiti@2.5.1) + packages/eslint-plugin-strict-env: devDependencies: eslint: @@ -293,6 +302,12 @@ packages: peerDependencies: eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + '@eslint-community/eslint-utils@4.9.0': + resolution: {integrity: sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + '@eslint-community/regexpp@4.12.1': resolution: {integrity: sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} @@ -301,14 +316,26 @@ packages: resolution: {integrity: sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/config-array@0.21.1': + resolution: {integrity: sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/config-helpers@0.3.0': resolution: {integrity: sha512-ViuymvFmcJi04qdZeDc2whTHryouGcDlaxPqarTD0ZE10ISpxGUVZGZDx4w01upyIynL3iu6IXH2bS1NhclQMw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/config-helpers@0.4.2': + resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/core@0.15.1': resolution: {integrity: sha512-bkOp+iumZCCbt1K1CmWf0R9pM5yKpDv+ZXtvSyQpudrI9kuFLp+bM2WOPXImuD/ceQuaa8f5pj93Y7zyECIGNA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/core@0.17.0': + resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/eslintrc@3.3.1': resolution: {integrity: sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -321,14 +348,26 @@ packages: resolution: {integrity: sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/js@9.39.2': + resolution: {integrity: sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/object-schema@2.1.6': resolution: {integrity: sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/object-schema@2.1.7': + resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/plugin-kit@0.3.4': resolution: {integrity: sha512-Ul5l+lHEcw3L5+k8POx6r74mxEYKG5kOb6Xpy2gCRW6zweT6TEhAf8vhxGgjhqrd/VO/Dirhsb+1hNpD1ue9hw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/plugin-kit@0.4.1': + resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} engines: {node: '>=18.18.0'} @@ -1407,6 +1446,16 @@ packages: jiti: optional: true + eslint@9.39.2: + resolution: {integrity: sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + espree@10.4.0: resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -2836,6 +2885,11 @@ snapshots: eslint: 9.32.0(jiti@2.5.1) eslint-visitor-keys: 3.4.3 + '@eslint-community/eslint-utils@4.9.0(eslint@9.39.2(jiti@2.5.1))': + dependencies: + eslint: 9.39.2(jiti@2.5.1) + eslint-visitor-keys: 3.4.3 + '@eslint-community/regexpp@4.12.1': {} '@eslint/config-array@0.21.0': @@ -2846,12 +2900,28 @@ snapshots: transitivePeerDependencies: - supports-color + '@eslint/config-array@0.21.1': + dependencies: + '@eslint/object-schema': 2.1.7 + debug: 4.4.1 + minimatch: 3.1.2 + transitivePeerDependencies: + - supports-color + '@eslint/config-helpers@0.3.0': {} + '@eslint/config-helpers@0.4.2': + dependencies: + '@eslint/core': 0.17.0 + '@eslint/core@0.15.1': dependencies: '@types/json-schema': 7.0.15 + '@eslint/core@0.17.0': + dependencies: + '@types/json-schema': 7.0.15 + '@eslint/eslintrc@3.3.1': dependencies: ajv: 6.12.6 @@ -2870,13 +2940,22 @@ snapshots: '@eslint/js@9.39.1': {} + '@eslint/js@9.39.2': {} + '@eslint/object-schema@2.1.6': {} + '@eslint/object-schema@2.1.7': {} + '@eslint/plugin-kit@0.3.4': dependencies: '@eslint/core': 0.15.1 levn: 0.4.1 + '@eslint/plugin-kit@0.4.1': + dependencies: + '@eslint/core': 0.17.0 + levn: 0.4.1 + '@humanfs/core@0.19.1': {} '@humanfs/node@0.16.6': @@ -4045,6 +4124,47 @@ snapshots: transitivePeerDependencies: - supports-color + eslint@9.39.2(jiti@2.5.1): + dependencies: + '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.2(jiti@2.5.1)) + '@eslint-community/regexpp': 4.12.1 + '@eslint/config-array': 0.21.1 + '@eslint/config-helpers': 0.4.2 + '@eslint/core': 0.17.0 + '@eslint/eslintrc': 3.3.1 + '@eslint/js': 9.39.2 + '@eslint/plugin-kit': 0.4.1 + '@humanfs/node': 0.16.6 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.8 + ajv: 6.12.6 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.1 + escape-string-regexp: 4.0.0 + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + esquery: 1.6.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + lodash.merge: 4.6.2 + minimatch: 3.1.2 + natural-compare: 1.4.0 + optionator: 0.9.4 + optionalDependencies: + jiti: 2.5.1 + transitivePeerDependencies: + - supports-color + espree@10.4.0: dependencies: acorn: 8.15.0