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