From 545e9afb75e9445a1db7ce20d9e10036917156e5 Mon Sep 17 00:00:00 2001 From: Ilya Olovyannikov Date: Tue, 9 Dec 2025 12:56:00 +0300 Subject: [PATCH 01/12] feat(rule): add new rule for destructured units --- .gitignore | 3 + config/react.js | 0 index.js | 0 .../use-unit-destructuring.js | 139 +++++++++++++++ .../use-unit-destructuring.md | 1 + .../use-unit-destructuring.test.js | 161 ++++++++++++++++++ 6 files changed, 304 insertions(+) create mode 100644 config/react.js create mode 100644 index.js create mode 100644 rules/use-unit-destructuring/use-unit-destructuring.js create mode 100644 rules/use-unit-destructuring/use-unit-destructuring.md create mode 100644 rules/use-unit-destructuring/use-unit-destructuring.test.js diff --git a/.gitignore b/.gitignore index 61450cc..91cd5fb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +.idea +**/*.xml + # Logs logs *.log diff --git a/config/react.js b/config/react.js new file mode 100644 index 0000000..e69de29 diff --git a/index.js b/index.js new file mode 100644 index 0000000..e69de29 diff --git a/rules/use-unit-destructuring/use-unit-destructuring.js b/rules/use-unit-destructuring/use-unit-destructuring.js new file mode 100644 index 0000000..66fb48d --- /dev/null +++ b/rules/use-unit-destructuring/use-unit-destructuring.js @@ -0,0 +1,139 @@ +const { createLinkToRule } = require("../../utils/create-link-to-rule"); +module.exports = { + meta: { + type: "problem", + docs: { + description: + "Ensure destructured properties match the passed unit object/array", + category: "Best Practices", + recommended: true, + url: createLinkToRule("use-unit-destructuring"), + }, + messages: { + unusedKey: 'Property "{{key}}" is passed but not destructured', + missingKey: + 'Property "{{key}}" is destructured but not passed in the unit object', + implicitSubscription: + "Element at index {{index}} ({{name}}) is passed but not destructured, causing implicit subscription", + }, + schema: [], + }, + create(context) { + return { + CallExpression(node) { + // Search for useUnit + if ( + node.callee.type !== "Identifier" || + node.callee.name !== "useUnit" || + node.arguments.length === 0 + ) { + return; + } + + const argument = node.arguments[0]; + const parent = node.parent; + + if (parent.type !== "VariableDeclarator") { + return; + } + + // Shape is Object-like + if ( + argument.type === "ObjectExpression" && + parent.id.type === "ObjectPattern" + ) { + handleObjectPattern(context, argument, parent.id); + } + + // Shape is Array-like + if ( + argument.type === "ArrayExpression" && + parent.id.type === "ArrayPattern" + ) { + handleArrayPattern(context, argument, parent.id); + } + }, + }; + }, +}; + +function handleObjectPattern(context, objectArgument, objectPattern) { + // Collect all keys from argument object + const argumentKeys = new Set( + objectArgument.properties + .filter( + (prop) => prop.type === "Property" && prop.key.type === "Identifier" + ) + .map((prop) => prop.key.name) + ); + + // Collect destructured keys + const destructuredKeys = new Set( + objectPattern.properties + .filter( + (prop) => prop.type === "Property" && prop.key.type === "Identifier" + ) + .map((prop) => prop.key.name) + ); + + // Check unused keys + for (const key of argumentKeys) { + if (!destructuredKeys.has(key)) { + context.report({ + node: objectArgument, + messageId: "unusedKey", + data: { key }, + }); + } + } + + // Check missing keys + for (const key of destructuredKeys) { + if (!argumentKeys.has(key)) { + context.report({ + node: objectPattern, + messageId: "missingKey", + data: { key }, + }); + } + } +} + +function handleArrayPattern(context, arrayArgument, arrayPattern) { + const argumentElements = arrayArgument.elements; + const destructuredElements = arrayPattern.elements; + + // Check all array elements was destructured + const destructuredCount = destructuredElements.filter( + (el) => el !== null + ).length; + const argumentCount = argumentElements.filter((el) => el !== null).length; + + if (destructuredCount < argumentCount) { + // If undestructured elements exists + for (let i = destructuredCount; i < argumentCount; i++) { + const element = argumentElements[i]; + if (element) { + // Get the name of variable for an info message + let name = "unknown"; + if (element.type === "Identifier") { + name = element.name; + } else if (element.type === "MemberExpression") { + const sourceCode = context.getSourceCode + ? context.getSourceCode() + : context.sourceCode; + name = sourceCode.getText(element); + } + + context.report({ + node: element, + messageId: "implicitSubscription", + data: { + index: i, + name: name, + }, + }); + } + } + } +} diff --git a/rules/use-unit-destructuring/use-unit-destructuring.md b/rules/use-unit-destructuring/use-unit-destructuring.md new file mode 100644 index 0000000..6ad7127 --- /dev/null +++ b/rules/use-unit-destructuring/use-unit-destructuring.md @@ -0,0 +1 @@ +https://eslint.effector.dev/rules/use-unit-destructuring.html diff --git a/rules/use-unit-destructuring/use-unit-destructuring.test.js b/rules/use-unit-destructuring/use-unit-destructuring.test.js new file mode 100644 index 0000000..68d62e4 --- /dev/null +++ b/rules/use-unit-destructuring/use-unit-destructuring.test.js @@ -0,0 +1,161 @@ +const { RuleTester } = require("eslint"); +const rule = require("./use-unit-destructuring"); + +const ruleTester = new RuleTester({ + parserOptions: { + ecmaVersion: 2020, + sourceType: "module", + ecmaFeatures: { jsx: true }, + }, +}); + +ruleTester.run("effector/use-unit-destructuring.test", rule, { + valid: [ + // All keys were destructured + { + code: ` + import { useUnit } from "effector-react"; + const { value, setValue } = useUnit({ + value: $store, + setValue: event, + }); + `, + }, + // All keys were destructured + { + code: ` + import { useUnit } from "effector-react"; + const [value, setValue] = useUnit([$store, event]); + `, + }, + // With one element in object-shape + { + code: ` + import { useUnit } from "effector-react"; + const { value } = useUnit({ value: $store }); + `, + }, + // With one element in array-shape + { + code: ` + import { useUnit } from "effector-react"; + const [value] = useUnit([$store]); + `, + }, + // Is not useUnit - no check + { + code: ` + const { value } = someOtherFunction({ + value: $store, + setValue: event, + }); + `, + }, + ], + + invalid: [ + // Object: not destructured + { + code: ` + import { useUnit } from "effector-react"; + const { value } = useUnit({ + value: $store, + setValue: event, + }); + `, + errors: [ + { + messageId: "unusedKey", + data: { key: "setValue" }, + }, + ], + }, + // Object: destructured, but key does not exist + { + code: ` + import { useUnit } from "effector-react"; + const { value, setValue, extra } = useUnit({ + value: $store, + setValue: event, + }); + `, + errors: [ + { + messageId: "missingKey", + data: { key: "extra" }, + }, + ], + }, + // Array: implicit subscription (not all elements were destructuring) + { + code: ` + import { useUnit } from "effector-react"; + const [setValue] = useUnit([event, $store]); + `, + errors: [ + { + messageId: "implicitSubscription", + data: { index: 1, name: "$store" }, + }, + ], + }, + // Array: several implicit subscriptions + { + code: ` + import { useUnit } from "effector-react"; + const [value] = useUnit([$store, event, $anotherStore]); + `, + errors: [ + { + messageId: "implicitSubscription", + data: { index: 1, name: "event" }, + }, + { + messageId: "implicitSubscription", + data: { index: 2, name: "$anotherStore" }, + }, + ], + }, + // Object: several unused keys + { + code: ` + import { useUnit } from "effector-react"; + const { value } = useUnit({ + value: $store, + setValue: event, + reset: resetEvent, + }); + `, + errors: [ + { + messageId: "unusedKey", + data: { key: "setValue" }, + }, + { + messageId: "unusedKey", + data: { key: "reset" }, + }, + ], + }, + { + code: ` + import React, { Fragment } from "react"; + import { useUnit } from "effector-react"; + + const ObjectShapeComponent = () => { + const { value } = useUnit({ + value: $store, + setValue: event, + }); + return {value}; + }; + `, + errors: [ + { + messageId: "unusedKey", + data: { key: "setValue" }, + }, + ], + }, + ], +}); From 65e072fb7dce047b4fd131151ec2f2560d6ecbc9 Mon Sep 17 00:00:00 2001 From: Ilya Olovyannikov Date: Tue, 9 Dec 2025 13:01:33 +0300 Subject: [PATCH 02/12] docs(rule): add docs for new rule --- docs/rules/use-unit-destructuring.md | 145 +++++++++++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 docs/rules/use-unit-destructuring.md diff --git a/docs/rules/use-unit-destructuring.md b/docs/rules/use-unit-destructuring.md new file mode 100644 index 0000000..88d6d7e --- /dev/null +++ b/docs/rules/use-unit-destructuring.md @@ -0,0 +1,145 @@ +# effector/use-unit-destructuring + +[Related documentation](https://effector.dev/en/api/effector-react/useunit/) + +Ensures that all units passed to useUnit are properly destructured to avoid unused subscriptions and implicit re-renders. + +## Rule Details +This rule enforces that: +- All properties passed in an object to useUnit must be destructured to prevent implicit subscriptions; +- All elements passed in an array to useUnit must be destructured to prevent implicit subscriptions also. + +### Object shape +When using useUnit with an object, you must destructure all keys that you pass. Otherwise, unused units will still create subscriptions and cause unnecessary re-renders. +TypeScript + +```ts +// 👍 correct - all properties are destructured +const { value, setValue } = useUnit({ + value: $store, + setValue: event, +}); +``` + +```ts +// 👎 incorrect - setValue is not destructured but still creates subscription +const { value } = useUnit({ + value: $store, + setValue: event, // unused but subscribed! +}); +``` + +```ts +// 👎 incorrect - extra is destructured but not passed +const { + value, + setValue, + extra // extra is missing - will be undefined +} = useUnit({ + value: $store, + setValue: event, +}); +``` + +### Array shape +When using useUnit with an array, you must destructure all elements. Elements that are not destructured will still create subscriptions, leading to implicit re-renders. +TypeScript + +```ts +// 👍 correct - all elements are destructured +const [value, setValue] = useUnit([$store, event]); +``` + +```ts +// 👎 incorrect - $store is not destructured but creates implicit subscription +const [setValue] = useUnit([event, $store]); +// Component will re-render when $store changes, even though you don't use it! +``` + +```ts +// 👎 incorrect - event and $anotherStore cause implicit subscriptions +const [value] = useUnit([$store, event, $anotherStore]); +// Component re-renders on $store, event, and $anotherStore changes +``` + +## Why is this important? +Implicit subscriptions can lead to: +- Performance issues: unnecessary re-renders when unused stores update +- Hard-to-debug behavior: component re-renders for unclear reasons +- Memory leaks: subscriptions that are never cleaned up properly + +## Examples + +### Real-world example + +```tsx +import React, { Fragment } from "react"; +import { createEvent, createStore } from "effector"; +import { useUnit } from "effector-react"; + +const $store = createStore("Hello World!"); +const event = createEvent(); + +// 👎 incorrect +const BadComponent = () => { + const { value } = useUnit({ + value: $store, + setValue: event, // ❌ not used but subscribed! + }); + + return {value}; +}; + +// 👍 correct +const GoodComponent = () => { + const { value, setValue } = useUnit({ + value: $store, + setValue: event, + }); + + return ; +}; +``` + +```tsx +import React, { Fragment } from "react"; +import { createEvent, createStore } from "effector"; +import { useUnit } from "effector-react"; + +const $store = createStore("Hello World!"); +const event = createEvent(); + +// 👎 incorrect - implicit subscription to $store +const BadComponent = () => { + const [setValue] = useUnit([event, $store]); // ❌ $store not used but subscribed! + + return ; +}; + +// 👍 correct - explicit destructuring +const GoodComponent = () => { + const [value, setValue] = useUnit([$store, event]); + + return ; +}; + +// 👍 also correct - only pass what you need +const AlsoGoodComponent = () => { + const [setValue] = useUnit([event]); // ✅ no implicit subscriptions + + return ; +}; +``` + +### When Not To Use It +If you intentionally want to subscribe to a store without using its value (rare case), you can disable this rule for that line: + +```tsx +// eslint-disable-next-line effector/use-unit-destructuring +const { value } = useUnit({ + value: $store, + trigger: $triggerStore, // intentionally subscribing without using +}); +``` + +However, in most cases, you should refactor your code to avoid implicit subscriptions. \ No newline at end of file From a8fc73c53d17f1dbd1beefc0029f8a8e77c783b2 Mon Sep 17 00:00:00 2001 From: Ilya Olovyannikov Date: Wed, 4 Mar 2026 11:22:30 +0300 Subject: [PATCH 03/12] feat(use-unit-destructuring): update to v17 --- .../use-unit-destructuring.js | 139 ------------------ .../use-unit-destructuring.md | 1 - src/index.ts | 2 + .../use-unit-destructuring.md | 0 .../use-unit-destructuring.test.ts | 22 +-- .../use-unit-destructuring.ts | 138 +++++++++++++++++ src/ruleset.ts | 1 + 7 files changed, 154 insertions(+), 149 deletions(-) delete mode 100644 rules/use-unit-destructuring/use-unit-destructuring.js delete mode 100644 rules/use-unit-destructuring/use-unit-destructuring.md rename {docs/rules => src/rules/use-unit-destructuring}/use-unit-destructuring.md (100%) rename rules/use-unit-destructuring/use-unit-destructuring.test.js => src/rules/use-unit-destructuring/use-unit-destructuring.test.ts (90%) create mode 100644 src/rules/use-unit-destructuring/use-unit-destructuring.ts diff --git a/rules/use-unit-destructuring/use-unit-destructuring.js b/rules/use-unit-destructuring/use-unit-destructuring.js deleted file mode 100644 index 66fb48d..0000000 --- a/rules/use-unit-destructuring/use-unit-destructuring.js +++ /dev/null @@ -1,139 +0,0 @@ -const { createLinkToRule } = require("../../utils/create-link-to-rule"); -module.exports = { - meta: { - type: "problem", - docs: { - description: - "Ensure destructured properties match the passed unit object/array", - category: "Best Practices", - recommended: true, - url: createLinkToRule("use-unit-destructuring"), - }, - messages: { - unusedKey: 'Property "{{key}}" is passed but not destructured', - missingKey: - 'Property "{{key}}" is destructured but not passed in the unit object', - implicitSubscription: - "Element at index {{index}} ({{name}}) is passed but not destructured, causing implicit subscription", - }, - schema: [], - }, - create(context) { - return { - CallExpression(node) { - // Search for useUnit - if ( - node.callee.type !== "Identifier" || - node.callee.name !== "useUnit" || - node.arguments.length === 0 - ) { - return; - } - - const argument = node.arguments[0]; - const parent = node.parent; - - if (parent.type !== "VariableDeclarator") { - return; - } - - // Shape is Object-like - if ( - argument.type === "ObjectExpression" && - parent.id.type === "ObjectPattern" - ) { - handleObjectPattern(context, argument, parent.id); - } - - // Shape is Array-like - if ( - argument.type === "ArrayExpression" && - parent.id.type === "ArrayPattern" - ) { - handleArrayPattern(context, argument, parent.id); - } - }, - }; - }, -}; - -function handleObjectPattern(context, objectArgument, objectPattern) { - // Collect all keys from argument object - const argumentKeys = new Set( - objectArgument.properties - .filter( - (prop) => prop.type === "Property" && prop.key.type === "Identifier" - ) - .map((prop) => prop.key.name) - ); - - // Collect destructured keys - const destructuredKeys = new Set( - objectPattern.properties - .filter( - (prop) => prop.type === "Property" && prop.key.type === "Identifier" - ) - .map((prop) => prop.key.name) - ); - - // Check unused keys - for (const key of argumentKeys) { - if (!destructuredKeys.has(key)) { - context.report({ - node: objectArgument, - messageId: "unusedKey", - data: { key }, - }); - } - } - - // Check missing keys - for (const key of destructuredKeys) { - if (!argumentKeys.has(key)) { - context.report({ - node: objectPattern, - messageId: "missingKey", - data: { key }, - }); - } - } -} - -function handleArrayPattern(context, arrayArgument, arrayPattern) { - const argumentElements = arrayArgument.elements; - const destructuredElements = arrayPattern.elements; - - // Check all array elements was destructured - const destructuredCount = destructuredElements.filter( - (el) => el !== null - ).length; - const argumentCount = argumentElements.filter((el) => el !== null).length; - - if (destructuredCount < argumentCount) { - // If undestructured elements exists - for (let i = destructuredCount; i < argumentCount; i++) { - const element = argumentElements[i]; - if (element) { - // Get the name of variable for an info message - let name = "unknown"; - if (element.type === "Identifier") { - name = element.name; - } else if (element.type === "MemberExpression") { - const sourceCode = context.getSourceCode - ? context.getSourceCode() - : context.sourceCode; - name = sourceCode.getText(element); - } - - context.report({ - node: element, - messageId: "implicitSubscription", - data: { - index: i, - name: name, - }, - }); - } - } - } -} diff --git a/rules/use-unit-destructuring/use-unit-destructuring.md b/rules/use-unit-destructuring/use-unit-destructuring.md deleted file mode 100644 index 6ad7127..0000000 --- a/rules/use-unit-destructuring/use-unit-destructuring.md +++ /dev/null @@ -1 +0,0 @@ -https://eslint.effector.dev/rules/use-unit-destructuring.html diff --git a/src/index.ts b/src/index.ts index 2787ff8..8aa909d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -23,6 +23,7 @@ import noWatch from "./rules/no-watch/no-watch" import preferUseUnit from "./rules/prefer-useUnit/prefer-useUnit" import requirePickupInPersist from "./rules/require-pickup-in-persist/require-pickup-in-persist" import strictEffectHandlers from "./rules/strict-effect-handlers/strict-effect-handlers" +import useUnitDestructuring from "./rules/use-unit-destructuring/use-unit-destructuring" import { ruleset } from "./ruleset" const base = { @@ -47,6 +48,7 @@ const base = { "no-useless-methods": noUselessMethods, "no-watch": noWatch, "prefer-useUnit": preferUseUnit, + "use-unit-destructuring": useUnitDestructuring, "require-pickup-in-persist": requirePickupInPersist, "strict-effect-handlers": strictEffectHandlers, }, diff --git a/docs/rules/use-unit-destructuring.md b/src/rules/use-unit-destructuring/use-unit-destructuring.md similarity index 100% rename from docs/rules/use-unit-destructuring.md rename to src/rules/use-unit-destructuring/use-unit-destructuring.md diff --git a/rules/use-unit-destructuring/use-unit-destructuring.test.js b/src/rules/use-unit-destructuring/use-unit-destructuring.test.ts similarity index 90% rename from rules/use-unit-destructuring/use-unit-destructuring.test.js rename to src/rules/use-unit-destructuring/use-unit-destructuring.test.ts index 68d62e4..de1eb69 100644 --- a/rules/use-unit-destructuring/use-unit-destructuring.test.js +++ b/src/rules/use-unit-destructuring/use-unit-destructuring.test.ts @@ -1,15 +1,18 @@ -const { RuleTester } = require("eslint"); -const rule = require("./use-unit-destructuring"); +import { RuleTester } from "@typescript-eslint/rule-tester" + +import rule from "./use-unit-destructuring" const ruleTester = new RuleTester({ - parserOptions: { - ecmaVersion: 2020, - sourceType: "module", - ecmaFeatures: { jsx: true }, + languageOptions: { + parserOptions: { + ecmaVersion: 2020, + sourceType: "module", + ecmaFeatures: { jsx: true }, + }, }, -}); +}) -ruleTester.run("effector/use-unit-destructuring.test", rule, { +ruleTester.run("effector/use-unit-destructuring", rule, { valid: [ // All keys were destructured { @@ -137,6 +140,7 @@ ruleTester.run("effector/use-unit-destructuring.test", rule, { }, ], }, + // JSX component with object-shape { code: ` import React, { Fragment } from "react"; @@ -158,4 +162,4 @@ ruleTester.run("effector/use-unit-destructuring.test", rule, { ], }, ], -}); +}) diff --git a/src/rules/use-unit-destructuring/use-unit-destructuring.ts b/src/rules/use-unit-destructuring/use-unit-destructuring.ts new file mode 100644 index 0000000..3fc6397 --- /dev/null +++ b/src/rules/use-unit-destructuring/use-unit-destructuring.ts @@ -0,0 +1,138 @@ +import { AST_NODE_TYPES, type TSESTree } from "@typescript-eslint/utils" + +import { createRule } from "@/shared/create" + +type MessageIds = "unusedKey" | "missingKey" | "implicitSubscription" +type Options = [] + +export default createRule({ + name: "use-unit-destructuring", + meta: { + type: "problem", + docs: { + description: "Ensure destructured properties match the passed unit object/array", + }, + messages: { + unusedKey: 'Property "{{key}}" is passed but not destructured', + missingKey: 'Property "{{key}}" is destructured but not passed in the unit object', + implicitSubscription: + "Element at index {{index}} ({{name}}) is passed but not destructured, causing implicit subscription", + }, + schema: [], + }, + defaultOptions: [], + create(context) { + function handleObjectPattern( + objectArgument: TSESTree.ObjectExpression, + objectPattern: TSESTree.ObjectPattern, + ): void { + // Collect all keys from argument object + const argumentKeys = new Set( + objectArgument.properties + .filter( + (prop): prop is TSESTree.Property => + prop.type === AST_NODE_TYPES.Property && prop.key.type === AST_NODE_TYPES.Identifier, + ) + .map((prop) => (prop.key as TSESTree.Identifier).name), + ) + + // Collect destructured keys + const destructuredKeys = new Set( + objectPattern.properties + .filter( + (prop): prop is TSESTree.Property => + prop.type === AST_NODE_TYPES.Property && prop.key.type === AST_NODE_TYPES.Identifier, + ) + .map((prop) => (prop.key as TSESTree.Identifier).name), + ) + + // Check unused keys + for (const key of argumentKeys) { + if (!destructuredKeys.has(key)) { + context.report({ + node: objectArgument, + messageId: "unusedKey", + data: { key }, + }) + } + } + + // Check missing keys + for (const key of destructuredKeys) { + if (!argumentKeys.has(key)) { + context.report({ + node: objectPattern, + messageId: "missingKey", + data: { key }, + }) + } + } + } + + function handleArrayPattern(arrayArgument: TSESTree.ArrayExpression, arrayPattern: TSESTree.ArrayPattern): void { + const argumentElements = arrayArgument.elements + const destructuredElements = arrayPattern.elements + + const destructuredCount = destructuredElements.filter((el) => el !== null).length + const argumentCount = argumentElements.filter((el) => el !== null).length + + if (destructuredCount >= argumentCount) return + + // If undestructured elements exists + for (let i = destructuredCount; i < argumentCount; i++) { + const element = argumentElements[i] + if (!element || element.type === AST_NODE_TYPES.SpreadElement) continue + + let name = "unknown" + + if (element.type === AST_NODE_TYPES.Identifier) { + name = element.name + } else if (element.type === AST_NODE_TYPES.MemberExpression) { + name = context.sourceCode.getText(element) + } + + context.report({ + node: element, + messageId: "implicitSubscription", + data: { + index: i, + name, + }, + }) + } + } + + return { + CallExpression(node): void { + if ( + node.callee.type !== AST_NODE_TYPES.Identifier || + node.callee.name !== "useUnit" || + node.arguments.length === 0 + ) { + return + } + + const argument = node.arguments[0] + const parent = node.parent + + if ( + !parent || + parent.type !== AST_NODE_TYPES.VariableDeclarator || + argument?.type === AST_NODE_TYPES.SpreadElement + ) { + return + } + + // Shape is Object-like + if (argument?.type === AST_NODE_TYPES.ObjectExpression && parent.id.type === AST_NODE_TYPES.ObjectPattern) { + handleObjectPattern(argument, parent.id) + } + + // Shape is Array-like + if (argument?.type === AST_NODE_TYPES.ArrayExpression && parent.id.type === AST_NODE_TYPES.ArrayPattern) { + handleArrayPattern(argument, parent.id) + } + }, + } + }, +}) diff --git a/src/ruleset.ts b/src/ruleset.ts index 25fc617..40cb3b0 100644 --- a/src/ruleset.ts +++ b/src/ruleset.ts @@ -29,6 +29,7 @@ const react = { "effector/mandatory-scope-binding": "error", "effector/no-units-spawn-in-render": "error", "effector/prefer-useUnit": "error", + "effector/use-unit-destructuring": "warn", } satisfies TSESLint.Linter.RulesRecord const future = { From e8e65b7dc7dd034a2699179303a8ecf32e390971 Mon Sep 17 00:00:00 2001 From: Ilya Olovyannikov Date: Fri, 10 Apr 2026 11:24:39 +0300 Subject: [PATCH 04/12] fix(files): remove legacy empty files --- config/react.js | 0 index.js | 0 2 files changed, 0 insertions(+), 0 deletions(-) delete mode 100644 config/react.js delete mode 100644 index.js diff --git a/config/react.js b/config/react.js deleted file mode 100644 index e69de29..0000000 diff --git a/index.js b/index.js deleted file mode 100644 index e69de29..0000000 From 40bd653efa7987f15509ebb6d609f2f4a00f4fee Mon Sep 17 00:00:00 2001 From: Ilya Olovyannikov Date: Fri, 10 Apr 2026 20:21:24 +0300 Subject: [PATCH 05/12] refactor(review): update tests,aliasing,refactor --- .../use-unit-destructuring.test.ts | 103 +++++++++--- .../use-unit-destructuring.ts | 153 ++++++++++-------- 2 files changed, 170 insertions(+), 86 deletions(-) diff --git a/src/rules/use-unit-destructuring/use-unit-destructuring.test.ts b/src/rules/use-unit-destructuring/use-unit-destructuring.test.ts index de1eb69..4ca9418 100644 --- a/src/rules/use-unit-destructuring/use-unit-destructuring.test.ts +++ b/src/rules/use-unit-destructuring/use-unit-destructuring.test.ts @@ -1,21 +1,22 @@ import { RuleTester } from "@typescript-eslint/rule-tester" +import { parser } from "typescript-eslint" import rule from "./use-unit-destructuring" const ruleTester = new RuleTester({ languageOptions: { + parser, parserOptions: { - ecmaVersion: 2020, - sourceType: "module", + projectService: { allowDefaultProject: ["*.tsx"], defaultProject: "tsconfig.fixture.json" }, ecmaFeatures: { jsx: true }, }, }, }) -ruleTester.run("effector/use-unit-destructuring", rule, { +ruleTester.run("use-unit-destructuring", rule, { valid: [ - // All keys were destructured { + name: "All keys were destructured (object shape)", code: ` import { useUnit } from "effector-react"; const { value, setValue } = useUnit({ @@ -24,29 +25,29 @@ ruleTester.run("effector/use-unit-destructuring", rule, { }); `, }, - // All keys were destructured { + name: "All keys were destructured (array shape)", code: ` import { useUnit } from "effector-react"; const [value, setValue] = useUnit([$store, event]); `, }, - // With one element in object-shape { + name: "With one element in object shape", code: ` import { useUnit } from "effector-react"; const { value } = useUnit({ value: $store }); `, }, - // With one element in array-shape { + name: "With one element in array shape", code: ` import { useUnit } from "effector-react"; const [value] = useUnit([$store]); `, }, - // Is not useUnit - no check { + name: "Is not useUnit - no check", code: ` const { value } = someOtherFunction({ value: $store, @@ -54,11 +55,28 @@ ruleTester.run("effector/use-unit-destructuring", rule, { }); `, }, + { + name: "useUnit aliased import - all keys destructured", + code: ` + import { useUnit as useEffectorUnit } from "effector-react"; + const { value, setValue } = useEffectorUnit({ + value: $store, + setValue: event, + }); + `, + }, + { + name: "Array: all elements destructured with no holes", + code: ` + import { useUnit } from "effector-react"; + const [a, b, c] = useUnit([$a, $b, $c]); + `, + }, ], invalid: [ - // Object: not destructured { + name: "Object: key is passed but not destructured", code: ` import { useUnit } from "effector-react"; const { value } = useUnit({ @@ -73,8 +91,8 @@ ruleTester.run("effector/use-unit-destructuring", rule, { }, ], }, - // Object: destructured, but key does not exist { + name: "Object: key is destructured but does not exist in passed object", code: ` import { useUnit } from "effector-react"; const { value, setValue, extra } = useUnit({ @@ -89,38 +107,38 @@ ruleTester.run("effector/use-unit-destructuring", rule, { }, ], }, - // Array: implicit subscription (not all elements were destructuring) { + name: "Array: implicit subscription when not all elements are destructured", code: ` import { useUnit } from "effector-react"; const [setValue] = useUnit([event, $store]); `, errors: [ { - messageId: "implicitSubscription", - data: { index: 1, name: "$store" }, + messageId: "unusedKey", + data: { key: "$store" }, }, ], }, - // Array: several implicit subscriptions { + name: "Array: several implicit subscriptions", code: ` import { useUnit } from "effector-react"; const [value] = useUnit([$store, event, $anotherStore]); `, errors: [ { - messageId: "implicitSubscription", - data: { index: 1, name: "event" }, + messageId: "unusedKey", + data: { key: "event" }, }, { - messageId: "implicitSubscription", - data: { index: 2, name: "$anotherStore" }, + messageId: "unusedKey", + data: { key: "$anotherStore" }, }, ], }, - // Object: several unused keys { + name: "Object: several keys are passed but not destructured", code: ` import { useUnit } from "effector-react"; const { value } = useUnit({ @@ -140,8 +158,8 @@ ruleTester.run("effector/use-unit-destructuring", rule, { }, ], }, - // JSX component with object-shape { + name: "JSX component with object shape: key is passed but not destructured", code: ` import React, { Fragment } from "react"; import { useUnit } from "effector-react"; @@ -161,5 +179,50 @@ ruleTester.run("effector/use-unit-destructuring", rule, { }, ], }, + { + name: "useUnit aliased import: key is passed but not destructured", + code: ` + import { useUnit as useEffectorUnit } from "effector-react"; + const { value } = useEffectorUnit({ + value: $store, + setValue: event, + }); + `, + errors: [ + { + messageId: "unusedKey", + data: { key: "setValue" }, + }, + ], + }, + { + name: "Array: implicit subscription on skipped hole in pattern", + code: ` + import { useUnit } from "effector-react"; + const [a, , c] = useUnit([$a, $b, $c]); + `, + errors: [ + { + messageId: "unusedKey", + data: { key: "$b" }, + }, + ], + }, + { + name: "Object: string literal key is passed but not destructured", + code: ` + import { useUnit } from "effector-react"; + const { value } = useUnit({ + value: $store, + "setValue": event, + }); + `, + errors: [ + { + messageId: "unusedKey", + data: { key: "setValue" }, + }, + ], + }, ], }) diff --git a/src/rules/use-unit-destructuring/use-unit-destructuring.ts b/src/rules/use-unit-destructuring/use-unit-destructuring.ts index 3fc6397..3f46180 100644 --- a/src/rules/use-unit-destructuring/use-unit-destructuring.ts +++ b/src/rules/use-unit-destructuring/use-unit-destructuring.ts @@ -22,46 +22,86 @@ export default createRule({ }, defaultOptions: [], create(context) { - function handleObjectPattern( + const importedAs = new Set() + + function getPropertyKey(prop: TSESTree.Property | TSESTree.RestElement | TSESTree.SpreadElement): string | null { + if (prop.type !== AST_NODE_TYPES.Property) return null + + if (prop.key.type === AST_NODE_TYPES.Identifier && !prop.computed) { + return prop.key.name + } + + if (prop.key.type === AST_NODE_TYPES.Literal && typeof prop.key.value === "string" && !prop.computed) { + return prop.key.value + } + + return null + } + + function getObjectKeys( objectArgument: TSESTree.ObjectExpression, objectPattern: TSESTree.ObjectPattern, + ): { argumentKeys: string[]; destructuredKeys: string[]; keyToName: Map } { + const argumentKeys = objectArgument.properties.map(getPropertyKey).filter((key): key is string => key !== null) + + const destructuredKeys = objectPattern.properties.map(getPropertyKey).filter((key): key is string => key !== null) + + // For objects key itself is the display name + const keyToName = new Map(argumentKeys.map((key) => [key, key])) + + return { argumentKeys, destructuredKeys, keyToName } + } + + function getArrayKeys( + arrayArgument: TSESTree.ArrayExpression, + arrayPattern: TSESTree.ArrayPattern, + ): { argumentKeys: string[]; destructuredKeys: string[]; keyToName: Map } { + const argumentKeys: string[] = [] + const keyToName = new Map() + + arrayArgument.elements.forEach((el, i) => { + if (el === null || el.type === AST_NODE_TYPES.SpreadElement) return + + const key = String(i) + argumentKeys.push(key) + + if (el.type === AST_NODE_TYPES.Identifier) { + keyToName.set(key, el.name) + } else if (el.type === AST_NODE_TYPES.MemberExpression) { + keyToName.set(key, context.sourceCode.getText(el)) + } else { + keyToName.set(key, key) + } + }) + + const destructuredKeys = arrayPattern.elements + .map((el, i) => (el !== null && el.type !== AST_NODE_TYPES.RestElement ? String(i) : null)) + .filter((key): key is string => key !== null) + + return { argumentKeys, destructuredKeys, keyToName } + } + + function handlePattern( + argumentKeys: string[], + destructuredKeys: string[], + keyToName: Map, + argumentNode: TSESTree.ArrayExpression | TSESTree.ObjectExpression, + patternNode: TSESTree.ArrayPattern | TSESTree.ObjectPattern, ): void { - // Collect all keys from argument object - const argumentKeys = new Set( - objectArgument.properties - .filter( - (prop): prop is TSESTree.Property => - prop.type === AST_NODE_TYPES.Property && prop.key.type === AST_NODE_TYPES.Identifier, - ) - .map((prop) => (prop.key as TSESTree.Identifier).name), - ) - - // Collect destructured keys - const destructuredKeys = new Set( - objectPattern.properties - .filter( - (prop): prop is TSESTree.Property => - prop.type === AST_NODE_TYPES.Property && prop.key.type === AST_NODE_TYPES.Identifier, - ) - .map((prop) => (prop.key as TSESTree.Identifier).name), - ) - - // Check unused keys for (const key of argumentKeys) { - if (!destructuredKeys.has(key)) { + if (!destructuredKeys.includes(key)) { context.report({ - node: objectArgument, + node: argumentNode, messageId: "unusedKey", - data: { key }, + data: { key: keyToName.get(key) ?? key }, }) } } - // Check missing keys for (const key of destructuredKeys) { - if (!argumentKeys.has(key)) { + if (!argumentKeys.includes(key)) { context.report({ - node: objectPattern, + node: patternNode, messageId: "missingKey", data: { key }, }) @@ -69,45 +109,26 @@ export default createRule({ } } - function handleArrayPattern(arrayArgument: TSESTree.ArrayExpression, arrayPattern: TSESTree.ArrayPattern): void { - const argumentElements = arrayArgument.elements - const destructuredElements = arrayPattern.elements - - const destructuredCount = destructuredElements.filter((el) => el !== null).length - const argumentCount = argumentElements.filter((el) => el !== null).length - - if (destructuredCount >= argumentCount) return - - // If undestructured elements exists - for (let i = destructuredCount; i < argumentCount; i++) { - const element = argumentElements[i] - if (!element || element.type === AST_NODE_TYPES.SpreadElement) continue - - let name = "unknown" - - if (element.type === AST_NODE_TYPES.Identifier) { - name = element.name - } else if (element.type === AST_NODE_TYPES.MemberExpression) { - name = context.sourceCode.getText(element) + return { + ImportDeclaration(node): void { + if (node.source.value !== "effector-react") return + + for (const specifier of node.specifiers) { + if ( + specifier.type === AST_NODE_TYPES.ImportSpecifier && + specifier.imported.type === AST_NODE_TYPES.Identifier && + specifier.imported.name === "useUnit" + ) { + importedAs.add(specifier.local.name) + } } + }, - context.report({ - node: element, - messageId: "implicitSubscription", - data: { - index: i, - name, - }, - }) - } - } - - return { CallExpression(node): void { if ( node.callee.type !== AST_NODE_TYPES.Identifier || - node.callee.name !== "useUnit" || - node.arguments.length === 0 + !importedAs.has(node.callee.name) || + node.arguments.length !== 1 ) { return } @@ -123,14 +144,14 @@ export default createRule({ return } - // Shape is Object-like if (argument?.type === AST_NODE_TYPES.ObjectExpression && parent.id.type === AST_NODE_TYPES.ObjectPattern) { - handleObjectPattern(argument, parent.id) + const { argumentKeys, destructuredKeys, keyToName } = getObjectKeys(argument, parent.id) + handlePattern(argumentKeys, destructuredKeys, keyToName, argument, parent.id) } - // Shape is Array-like if (argument?.type === AST_NODE_TYPES.ArrayExpression && parent.id.type === AST_NODE_TYPES.ArrayPattern) { - handleArrayPattern(argument, parent.id) + const { argumentKeys, destructuredKeys, keyToName } = getArrayKeys(argument, parent.id) + handlePattern(argumentKeys, destructuredKeys, keyToName, argument, parent.id) } }, } From b0e9f02dca50319527aa38b70052e29a72995ad7 Mon Sep 17 00:00:00 2001 From: Ilya Olovyannikov Date: Fri, 10 Apr 2026 20:48:09 +0300 Subject: [PATCH 06/12] refactor(esquery): support esquery dynamic selectors --- .../use-unit-destructuring.ts | 51 +++++++++---------- 1 file changed, 24 insertions(+), 27 deletions(-) diff --git a/src/rules/use-unit-destructuring/use-unit-destructuring.ts b/src/rules/use-unit-destructuring/use-unit-destructuring.ts index 3f46180..cd6b1a9 100644 --- a/src/rules/use-unit-destructuring/use-unit-destructuring.ts +++ b/src/rules/use-unit-destructuring/use-unit-destructuring.ts @@ -46,7 +46,6 @@ export default createRule({ const destructuredKeys = objectPattern.properties.map(getPropertyKey).filter((key): key is string => key !== null) - // For objects key itself is the display name const keyToName = new Map(argumentKeys.map((key) => [key, key])) return { argumentKeys, destructuredKeys, keyToName } @@ -110,7 +109,7 @@ export default createRule({ } return { - ImportDeclaration(node): void { + "ImportDeclaration"(node): void { if (node.source.value !== "effector-react") return for (const specifier of node.specifiers) { @@ -124,35 +123,33 @@ export default createRule({ } }, - CallExpression(node): void { - if ( - node.callee.type !== AST_NODE_TYPES.Identifier || - !importedAs.has(node.callee.name) || - node.arguments.length !== 1 - ) { - return - } + "VariableDeclarator[id.type='ObjectPattern'] > CallExpression[arguments.length=1][callee.type='Identifier']"( + node: TSESTree.CallExpression, + ): void { + if (!importedAs.has((node.callee as TSESTree.Identifier).name)) return + const argument = node.arguments[0] + + if (argument?.type !== AST_NODE_TYPES.ObjectExpression) return + const parent = node.parent as TSESTree.VariableDeclarator + if (parent.id.type !== AST_NODE_TYPES.ObjectPattern) return + const { argumentKeys, destructuredKeys, keyToName } = getObjectKeys(argument, parent.id) + + handlePattern(argumentKeys, destructuredKeys, keyToName, argument, parent.id) + }, + + "VariableDeclarator[id.type='ArrayPattern'] > CallExpression[arguments.length=1][callee.type='Identifier']"( + node: TSESTree.CallExpression, + ): void { + if (!importedAs.has((node.callee as TSESTree.Identifier).name)) return const argument = node.arguments[0] - const parent = node.parent - - if ( - !parent || - parent.type !== AST_NODE_TYPES.VariableDeclarator || - argument?.type === AST_NODE_TYPES.SpreadElement - ) { - return - } - if (argument?.type === AST_NODE_TYPES.ObjectExpression && parent.id.type === AST_NODE_TYPES.ObjectPattern) { - const { argumentKeys, destructuredKeys, keyToName } = getObjectKeys(argument, parent.id) - handlePattern(argumentKeys, destructuredKeys, keyToName, argument, parent.id) - } + if (argument?.type !== AST_NODE_TYPES.ArrayExpression) return + const parent = node.parent as TSESTree.VariableDeclarator - if (argument?.type === AST_NODE_TYPES.ArrayExpression && parent.id.type === AST_NODE_TYPES.ArrayPattern) { - const { argumentKeys, destructuredKeys, keyToName } = getArrayKeys(argument, parent.id) - handlePattern(argumentKeys, destructuredKeys, keyToName, argument, parent.id) - } + if (parent.id.type !== AST_NODE_TYPES.ArrayPattern) return + const { argumentKeys, destructuredKeys, keyToName } = getArrayKeys(argument, parent.id) + handlePattern(argumentKeys, destructuredKeys, keyToName, argument, parent.id) }, } }, From 25e16c956de2ec36c6f9f3a50686fee1ad92755a Mon Sep 17 00:00:00 2001 From: Ilya Olovyannikov Date: Fri, 10 Apr 2026 20:59:10 +0300 Subject: [PATCH 07/12] refactor(test): add error column and line indentifier --- .../use-unit-destructuring.test.ts | 26 ++++++++++++------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/src/rules/use-unit-destructuring/use-unit-destructuring.test.ts b/src/rules/use-unit-destructuring/use-unit-destructuring.test.ts index 4ca9418..b8ccd71 100644 --- a/src/rules/use-unit-destructuring/use-unit-destructuring.test.ts +++ b/src/rules/use-unit-destructuring/use-unit-destructuring.test.ts @@ -78,16 +78,20 @@ ruleTester.run("use-unit-destructuring", rule, { { name: "Object: key is passed but not destructured", code: ` - import { useUnit } from "effector-react"; - const { value } = useUnit({ - value: $store, - setValue: event, - }); - `, + import { useUnit } from "effector-react"; + const { value } = useUnit({ + value: $store, + setValue: event, + }); + `, errors: [ { messageId: "unusedKey", data: { key: "setValue" }, + line: 3, + column: 31, + endLine: 6, + endColumn: 6, }, ], }, @@ -110,13 +114,17 @@ ruleTester.run("use-unit-destructuring", rule, { { name: "Array: implicit subscription when not all elements are destructured", code: ` - import { useUnit } from "effector-react"; - const [setValue] = useUnit([event, $store]); - `, + import { useUnit } from "effector-react"; + const [setValue] = useUnit([event, $store]); + `, errors: [ { messageId: "unusedKey", data: { key: "$store" }, + line: 3, + column: 32, + endLine: 3, + endColumn: 47, }, ], }, From a3ec5e13af68bfdc5ad19e57f72adbe49c61bcf2 Mon Sep 17 00:00:00 2001 From: Ilya Olovyannikov Date: Sun, 12 Apr 2026 11:01:06 +0300 Subject: [PATCH 08/12] fix(test): bringing testcase names to a common format --- src/index.ts | 2 +- .../use-unit-destructuring.md | 76 +++++---- .../use-unit-destructuring.test.ts | 148 +++++++++--------- 3 files changed, 120 insertions(+), 106 deletions(-) diff --git a/src/index.ts b/src/index.ts index 8aa909d..d538558 100644 --- a/src/index.ts +++ b/src/index.ts @@ -48,9 +48,9 @@ const base = { "no-useless-methods": noUselessMethods, "no-watch": noWatch, "prefer-useUnit": preferUseUnit, - "use-unit-destructuring": useUnitDestructuring, "require-pickup-in-persist": requirePickupInPersist, "strict-effect-handlers": strictEffectHandlers, + "use-unit-destructuring": useUnitDestructuring, }, } diff --git a/src/rules/use-unit-destructuring/use-unit-destructuring.md b/src/rules/use-unit-destructuring/use-unit-destructuring.md index 88d6d7e..5f0635b 100644 --- a/src/rules/use-unit-destructuring/use-unit-destructuring.md +++ b/src/rules/use-unit-destructuring/use-unit-destructuring.md @@ -1,16 +1,22 @@ +--- +description: Ensures that all units passed to useUnit are properly destructured to avoid unused subscriptions and implicit re-renders. +--- + # effector/use-unit-destructuring [Related documentation](https://effector.dev/en/api/effector-react/useunit/) -Ensures that all units passed to useUnit are properly destructured to avoid unused subscriptions and implicit re-renders. - ## Rule Details + This rule enforces that: + - All properties passed in an object to useUnit must be destructured to prevent implicit subscriptions; - All elements passed in an array to useUnit must be destructured to prevent implicit subscriptions also. ### Object shape -When using useUnit with an object, you must destructure all keys that you pass. Otherwise, unused units will still create subscriptions and cause unnecessary re-renders. + +When using useUnit with an object, you must destructure all keys that you pass. Otherwise, unused units will still +create subscriptions and cause unnecessary re-renders. TypeScript ```ts @@ -31,10 +37,10 @@ const { value } = useUnit({ ```ts // 👎 incorrect - extra is destructured but not passed -const { - value, - setValue, - extra // extra is missing - will be undefined +const { + value, + setValue, + extra // extra is missing - will be undefined } = useUnit({ value: $store, setValue: event, @@ -42,7 +48,9 @@ const { ``` ### Array shape -When using useUnit with an array, you must destructure all elements. Elements that are not destructured will still create subscriptions, leading to implicit re-renders. + +When using useUnit with an array, you must destructure all elements. Elements that are not destructured will still +create subscriptions, leading to implicit re-renders. TypeScript ```ts @@ -63,7 +71,9 @@ const [value] = useUnit([$store, event, $anotherStore]); ``` ## Why is this important? + Implicit subscriptions can lead to: + - Performance issues: unnecessary re-renders when unused stores update - Hard-to-debug behavior: component re-renders for unclear reasons - Memory leaks: subscriptions that are never cleaned up properly @@ -82,24 +92,24 @@ const event = createEvent(); // 👎 incorrect const BadComponent = () => { - const { value } = useUnit({ - value: $store, - setValue: event, // ❌ not used but subscribed! - }); - - return {value}; + const { value } = useUnit({ + value: $store, + setValue: event, // ❌ not used but subscribed! + }); + + return {value}; }; // 👍 correct const GoodComponent = () => { - const { value, setValue } = useUnit({ - value: $store, - setValue: event, - }); + const { value, setValue } = useUnit({ + value: $store, + setValue: event, + }); - return ; + return ; }; -``` +``` ```tsx import React, { Fragment } from "react"; @@ -111,34 +121,36 @@ const event = createEvent(); // 👎 incorrect - implicit subscription to $store const BadComponent = () => { - const [setValue] = useUnit([event, $store]); // ❌ $store not used but subscribed! - - return ; + const [setValue] = useUnit([event, $store]); // ❌ $store not used but subscribed! + + return ; }; // 👍 correct - explicit destructuring const GoodComponent = () => { - const [value, setValue] = useUnit([$store, event]); - - return ; + const [value, setValue] = useUnit([$store, event]); + + return ; }; // 👍 also correct - only pass what you need const AlsoGoodComponent = () => { - const [setValue] = useUnit([event]); // ✅ no implicit subscriptions - - return ; + const [setValue] = useUnit([event]); // ✅ no implicit subscriptions + + return ; }; ``` ### When Not To Use It -If you intentionally want to subscribe to a store without using its value (rare case), you can disable this rule for that line: + +If you intentionally want to subscribe to a store without using its value (rare case), you can disable this rule for +that line: ```tsx // eslint-disable-next-line effector/use-unit-destructuring const { value } = useUnit({ - value: $store, - trigger: $triggerStore, // intentionally subscribing without using + value: $store, + trigger: $triggerStore, // intentionally subscribing without using }); ``` diff --git a/src/rules/use-unit-destructuring/use-unit-destructuring.test.ts b/src/rules/use-unit-destructuring/use-unit-destructuring.test.ts index b8ccd71..6d0d69d 100644 --- a/src/rules/use-unit-destructuring/use-unit-destructuring.test.ts +++ b/src/rules/use-unit-destructuring/use-unit-destructuring.test.ts @@ -1,6 +1,8 @@ import { RuleTester } from "@typescript-eslint/rule-tester" import { parser } from "typescript-eslint" +import { tsx } from "@/shared/tag" + import rule from "./use-unit-destructuring" const ruleTester = new RuleTester({ @@ -16,74 +18,74 @@ const ruleTester = new RuleTester({ ruleTester.run("use-unit-destructuring", rule, { valid: [ { - name: "All keys were destructured (object shape)", - code: ` - import { useUnit } from "effector-react"; + name: "object: all keys were destructured", + code: tsx` + import { useUnit } from "effector-react" const { value, setValue } = useUnit({ value: $store, setValue: event, - }); + }) `, }, { - name: "All keys were destructured (array shape)", - code: ` - import { useUnit } from "effector-react"; - const [value, setValue] = useUnit([$store, event]); + name: "array: all keys were destructured", + code: tsx` + import { useUnit } from "effector-react" + const [value, setValue] = useUnit([$store, event]) `, }, { - name: "With one element in object shape", - code: ` - import { useUnit } from "effector-react"; - const { value } = useUnit({ value: $store }); + name: "object: with one element", + code: tsx` + import { useUnit } from "effector-react" + const { value } = useUnit({ value: $store }) `, }, { - name: "With one element in array shape", - code: ` - import { useUnit } from "effector-react"; - const [value] = useUnit([$store]); + name: "array: with one element", + code: tsx` + import { useUnit } from "effector-react" + const [value] = useUnit([$store]) `, }, { - name: "Is not useUnit - no check", - code: ` + name: "nocheck: is not useUnit", + code: tsx` const { value } = someOtherFunction({ value: $store, setValue: event, - }); + }) `, }, { - name: "useUnit aliased import - all keys destructured", - code: ` - import { useUnit as useEffectorUnit } from "effector-react"; + name: "alias: all keys destructured", + code: tsx` + import { useUnit as useEffectorUnit } from "effector-react" const { value, setValue } = useEffectorUnit({ value: $store, setValue: event, - }); + }) `, }, { - name: "Array: all elements destructured with no holes", - code: ` - import { useUnit } from "effector-react"; - const [a, b, c] = useUnit([$a, $b, $c]); + name: "array: all elements destructured with no holes", + code: tsx` + import { useUnit } from "effector-react" + const [a, b, c] = useUnit([$a, $b, $c]) `, }, ], invalid: [ { - name: "Object: key is passed but not destructured", - code: ` - import { useUnit } from "effector-react"; - const { value } = useUnit({ - value: $store, - setValue: event, - }); - `, + name: "object: key is passed but not destructured", + code: tsx` + import { useUnit } from "effector-react" + const { value } = useUnit({ + value: $store, + setValue: event, + }) + `, errors: [ { messageId: "unusedKey", @@ -96,13 +98,13 @@ ruleTester.run("use-unit-destructuring", rule, { ], }, { - name: "Object: key is destructured but does not exist in passed object", - code: ` - import { useUnit } from "effector-react"; + name: "object: key is destructured but does not exist in passed object", + code: tsx` + import { useUnit } from "effector-react" const { value, setValue, extra } = useUnit({ value: $store, setValue: event, - }); + }) `, errors: [ { @@ -112,11 +114,11 @@ ruleTester.run("use-unit-destructuring", rule, { ], }, { - name: "Array: implicit subscription when not all elements are destructured", - code: ` - import { useUnit } from "effector-react"; - const [setValue] = useUnit([event, $store]); - `, + name: "array: implicit subscription when not all elements are destructured", + code: tsx` + import { useUnit } from "effector-react" + const [setValue] = useUnit([event, $store]) + `, errors: [ { messageId: "unusedKey", @@ -129,10 +131,10 @@ ruleTester.run("use-unit-destructuring", rule, { ], }, { - name: "Array: several implicit subscriptions", - code: ` - import { useUnit } from "effector-react"; - const [value] = useUnit([$store, event, $anotherStore]); + name: "array: several implicit subscriptions", + code: tsx` + import { useUnit } from "effector-react" + const [value] = useUnit([$store, event, $anotherStore]) `, errors: [ { @@ -146,14 +148,14 @@ ruleTester.run("use-unit-destructuring", rule, { ], }, { - name: "Object: several keys are passed but not destructured", - code: ` - import { useUnit } from "effector-react"; + name: "object: several keys are passed but not destructured", + code: tsx` + import { useUnit } from "effector-react" const { value } = useUnit({ value: $store, setValue: event, reset: resetEvent, - }); + }) `, errors: [ { @@ -167,18 +169,18 @@ ruleTester.run("use-unit-destructuring", rule, { ], }, { - name: "JSX component with object shape: key is passed but not destructured", - code: ` - import React, { Fragment } from "react"; - import { useUnit } from "effector-react"; - + name: "object: JSX component: key is passed but not destructured", + code: tsx` + import React, { Fragment } from "react" + import { useUnit } from "effector-react" + const ObjectShapeComponent = () => { const { value } = useUnit({ value: $store, setValue: event, - }); - return {value}; - }; + }) + return {value} + } `, errors: [ { @@ -188,13 +190,13 @@ ruleTester.run("use-unit-destructuring", rule, { ], }, { - name: "useUnit aliased import: key is passed but not destructured", - code: ` - import { useUnit as useEffectorUnit } from "effector-react"; + name: "alias: key is passed but not destructured", + code: tsx` + import { useUnit as useEffectorUnit } from "effector-react" const { value } = useEffectorUnit({ value: $store, setValue: event, - }); + }) `, errors: [ { @@ -204,10 +206,10 @@ ruleTester.run("use-unit-destructuring", rule, { ], }, { - name: "Array: implicit subscription on skipped hole in pattern", - code: ` - import { useUnit } from "effector-react"; - const [a, , c] = useUnit([$a, $b, $c]); + name: "array: implicit subscription on skipped hole in pattern", + code: tsx` + import { useUnit } from "effector-react" + const [a, , c] = useUnit([$a, $b, $c]) `, errors: [ { @@ -217,13 +219,13 @@ ruleTester.run("use-unit-destructuring", rule, { ], }, { - name: "Object: string literal key is passed but not destructured", - code: ` - import { useUnit } from "effector-react"; + name: "object: string literal key is passed but not destructured", + code: tsx` + import { useUnit } from "effector-react" const { value } = useUnit({ value: $store, - "setValue": event, - }); + setValue: event, + }) `, errors: [ { From aa98a41967b5ed3c189530957a7696979620af8a Mon Sep 17 00:00:00 2001 From: Ilya Olovyannikov Date: Sun, 12 Apr 2026 12:07:19 +0300 Subject: [PATCH 09/12] refactor: moved common functions to shared --- src/index.ts | 4 +- .../prefer-useUnit-destructuring.md} | 4 +- .../prefer-useUnit-destructuring.test.ts} | 20 +-- .../prefer-useUnit-destructuring.ts | 94 +++++++++++ .../use-unit-destructuring.ts | 156 ------------------ src/ruleset.ts | 2 +- src/shared/create.ts | 39 ++++- src/shared/name.ts | 43 ++++- 8 files changed, 189 insertions(+), 173 deletions(-) rename src/rules/{use-unit-destructuring/use-unit-destructuring.md => prefer-useUnit-destructuring/prefer-useUnit-destructuring.md} (97%) rename src/rules/{use-unit-destructuring/use-unit-destructuring.test.ts => prefer-useUnit-destructuring/prefer-useUnit-destructuring.test.ts} (95%) create mode 100644 src/rules/prefer-useUnit-destructuring/prefer-useUnit-destructuring.ts delete mode 100644 src/rules/use-unit-destructuring/use-unit-destructuring.ts diff --git a/src/index.ts b/src/index.ts index d538558..b4a5cbc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,9 +21,9 @@ import noUnnecessaryDuplication from "./rules/no-unnecessary-duplication/no-unne import noUselessMethods from "./rules/no-useless-methods/no-useless-methods" import noWatch from "./rules/no-watch/no-watch" import preferUseUnit from "./rules/prefer-useUnit/prefer-useUnit" +import preferUseUnitDestructuring from "./rules/prefer-useUnit-destructuring/prefer-useUnit-destructuring" import requirePickupInPersist from "./rules/require-pickup-in-persist/require-pickup-in-persist" import strictEffectHandlers from "./rules/strict-effect-handlers/strict-effect-handlers" -import useUnitDestructuring from "./rules/use-unit-destructuring/use-unit-destructuring" import { ruleset } from "./ruleset" const base = { @@ -48,9 +48,9 @@ const base = { "no-useless-methods": noUselessMethods, "no-watch": noWatch, "prefer-useUnit": preferUseUnit, + "prefer-useUnit-destructuring": preferUseUnitDestructuring, "require-pickup-in-persist": requirePickupInPersist, "strict-effect-handlers": strictEffectHandlers, - "use-unit-destructuring": useUnitDestructuring, }, } diff --git a/src/rules/use-unit-destructuring/use-unit-destructuring.md b/src/rules/prefer-useUnit-destructuring/prefer-useUnit-destructuring.md similarity index 97% rename from src/rules/use-unit-destructuring/use-unit-destructuring.md rename to src/rules/prefer-useUnit-destructuring/prefer-useUnit-destructuring.md index 5f0635b..133db6f 100644 --- a/src/rules/use-unit-destructuring/use-unit-destructuring.md +++ b/src/rules/prefer-useUnit-destructuring/prefer-useUnit-destructuring.md @@ -2,7 +2,7 @@ description: Ensures that all units passed to useUnit are properly destructured to avoid unused subscriptions and implicit re-renders. --- -# effector/use-unit-destructuring +# effector/prefer-useUnit-destructuring [Related documentation](https://effector.dev/en/api/effector-react/useunit/) @@ -147,7 +147,7 @@ If you intentionally want to subscribe to a store without using its value (rare that line: ```tsx -// eslint-disable-next-line effector/use-unit-destructuring +// eslint-disable-next-line effector/prefer-useUnit-destructuring const { value } = useUnit({ value: $store, trigger: $triggerStore, // intentionally subscribing without using diff --git a/src/rules/use-unit-destructuring/use-unit-destructuring.test.ts b/src/rules/prefer-useUnit-destructuring/prefer-useUnit-destructuring.test.ts similarity index 95% rename from src/rules/use-unit-destructuring/use-unit-destructuring.test.ts rename to src/rules/prefer-useUnit-destructuring/prefer-useUnit-destructuring.test.ts index 6d0d69d..0b62e19 100644 --- a/src/rules/use-unit-destructuring/use-unit-destructuring.test.ts +++ b/src/rules/prefer-useUnit-destructuring/prefer-useUnit-destructuring.test.ts @@ -3,7 +3,7 @@ import { parser } from "typescript-eslint" import { tsx } from "@/shared/tag" -import rule from "./use-unit-destructuring" +import rule from "./prefer-useUnit-destructuring" const ruleTester = new RuleTester({ languageOptions: { @@ -15,7 +15,7 @@ const ruleTester = new RuleTester({ }, }) -ruleTester.run("use-unit-destructuring", rule, { +ruleTester.run("prefer-useUnit-destructuring", rule, { valid: [ { name: "object: all keys were destructured", @@ -90,10 +90,10 @@ ruleTester.run("use-unit-destructuring", rule, { { messageId: "unusedKey", data: { key: "setValue" }, - line: 3, - column: 31, - endLine: 6, - endColumn: 6, + line: 2, + column: 27, + endLine: 5, + endColumn: 2, }, ], }, @@ -123,10 +123,10 @@ ruleTester.run("use-unit-destructuring", rule, { { messageId: "unusedKey", data: { key: "$store" }, - line: 3, - column: 32, - endLine: 3, - endColumn: 47, + line: 2, + column: 28, + endLine: 2, + endColumn: 43, }, ], }, diff --git a/src/rules/prefer-useUnit-destructuring/prefer-useUnit-destructuring.ts b/src/rules/prefer-useUnit-destructuring/prefer-useUnit-destructuring.ts new file mode 100644 index 0000000..005a6b7 --- /dev/null +++ b/src/rules/prefer-useUnit-destructuring/prefer-useUnit-destructuring.ts @@ -0,0 +1,94 @@ +import { type TSESTree as Node } from "@typescript-eslint/utils" + +import { createRule, listToKeyMap, shapeToKeyMap } from "@/shared/create" +import { check } from "@/shared/name" +import { PACKAGE_NAME } from "@/shared/package" + +type MessageIds = "unusedKey" | "missingKey" +type Options = [] + +type ShapeCall = Node.VariableDeclarator & { + init: Node.CallExpression & { + callee: Node.Identifier + arguments: [Node.ObjectExpression] + } + id: Node.ObjectPattern +} + +type ListCall = Node.VariableDeclarator & { + init: Node.CallExpression & { + callee: Node.Identifier + arguments: [Node.ArrayExpression] + } + id: Node.ArrayPattern +} + +const selector = { + import: `ImportDeclaration[source.value=${PACKAGE_NAME.react}] > ImportSpecifier[imported.name=useUnit]`, + variable: { + shape: "VariableDeclarator[id.type=ObjectPattern]", + list: "VariableDeclarator[id.type=ArrayPattern]", + }, + call: "CallExpression.init[arguments.length=1][callee.type=Identifier]", + arg: { + shape: "ObjectExpression.arguments", + list: "ArrayExpression.arguments", + }, +} as const + +export default createRule({ + name: "prefer-useUnit-destructuring", + meta: { + type: "problem", + docs: { + description: "Ensure destructured properties match the passed unit object/array", + }, + messages: { + unusedKey: 'Property "{{key}}" is passed but not destructured.', + missingKey: 'Property "{{key}}" is destructured but not passed in the unit object.', + }, + schema: [], + defaultOptions: [], + }, + create(context) { + const importedAs = new Set() + + function handleObjectPattern(objectArgument: Node.ObjectExpression, objectPattern: Node.ObjectPattern): void { + const provided = shapeToKeyMap(objectArgument) + const consumed = shapeToKeyMap(objectPattern) + + if (provided === null || consumed === null) return + + for (const { type, name } of check(provided, consumed)) { + if (type === "unused") context.report({ node: objectArgument, messageId: "unusedKey", data: { key: name } }) + else context.report({ node: objectPattern, messageId: "missingKey", data: { key: name } }) + } + } + + function handleArrayPattern(arrayArgument: Node.ArrayExpression, arrayPattern: Node.ArrayPattern): void { + const provided = listToKeyMap(arrayArgument) + const consumed = listToKeyMap(arrayPattern) + + if (provided === null || consumed === null) return + + for (const { type, name } of check(provided, consumed)) { + if (type === "unused") context.report({ node: arrayArgument, messageId: "unusedKey", data: { key: name } }) + else context.report({ node: arrayPattern, messageId: "missingKey", data: { key: name } }) + } + } + + return { + [selector.import]: (node: Node.ImportSpecifier) => void importedAs.add(node.local.name), + + [`${selector.variable.shape}:has(> ${selector.call}:has(${selector.arg.shape}))`](node: ShapeCall): void { + if (!importedAs.has(node.init.callee.name)) return + handleObjectPattern(node.init.arguments[0], node.id) + }, + + [`${selector.variable.list}:has(> ${selector.call}:has(${selector.arg.list}))`](node: ListCall): void { + if (!importedAs.has(node.init.callee.name)) return + handleArrayPattern(node.init.arguments[0], node.id) + }, + } + }, +}) diff --git a/src/rules/use-unit-destructuring/use-unit-destructuring.ts b/src/rules/use-unit-destructuring/use-unit-destructuring.ts deleted file mode 100644 index cd6b1a9..0000000 --- a/src/rules/use-unit-destructuring/use-unit-destructuring.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { AST_NODE_TYPES, type TSESTree } from "@typescript-eslint/utils" - -import { createRule } from "@/shared/create" - -type MessageIds = "unusedKey" | "missingKey" | "implicitSubscription" -type Options = [] - -export default createRule({ - name: "use-unit-destructuring", - meta: { - type: "problem", - docs: { - description: "Ensure destructured properties match the passed unit object/array", - }, - messages: { - unusedKey: 'Property "{{key}}" is passed but not destructured', - missingKey: 'Property "{{key}}" is destructured but not passed in the unit object', - implicitSubscription: - "Element at index {{index}} ({{name}}) is passed but not destructured, causing implicit subscription", - }, - schema: [], - }, - defaultOptions: [], - create(context) { - const importedAs = new Set() - - function getPropertyKey(prop: TSESTree.Property | TSESTree.RestElement | TSESTree.SpreadElement): string | null { - if (prop.type !== AST_NODE_TYPES.Property) return null - - if (prop.key.type === AST_NODE_TYPES.Identifier && !prop.computed) { - return prop.key.name - } - - if (prop.key.type === AST_NODE_TYPES.Literal && typeof prop.key.value === "string" && !prop.computed) { - return prop.key.value - } - - return null - } - - function getObjectKeys( - objectArgument: TSESTree.ObjectExpression, - objectPattern: TSESTree.ObjectPattern, - ): { argumentKeys: string[]; destructuredKeys: string[]; keyToName: Map } { - const argumentKeys = objectArgument.properties.map(getPropertyKey).filter((key): key is string => key !== null) - - const destructuredKeys = objectPattern.properties.map(getPropertyKey).filter((key): key is string => key !== null) - - const keyToName = new Map(argumentKeys.map((key) => [key, key])) - - return { argumentKeys, destructuredKeys, keyToName } - } - - function getArrayKeys( - arrayArgument: TSESTree.ArrayExpression, - arrayPattern: TSESTree.ArrayPattern, - ): { argumentKeys: string[]; destructuredKeys: string[]; keyToName: Map } { - const argumentKeys: string[] = [] - const keyToName = new Map() - - arrayArgument.elements.forEach((el, i) => { - if (el === null || el.type === AST_NODE_TYPES.SpreadElement) return - - const key = String(i) - argumentKeys.push(key) - - if (el.type === AST_NODE_TYPES.Identifier) { - keyToName.set(key, el.name) - } else if (el.type === AST_NODE_TYPES.MemberExpression) { - keyToName.set(key, context.sourceCode.getText(el)) - } else { - keyToName.set(key, key) - } - }) - - const destructuredKeys = arrayPattern.elements - .map((el, i) => (el !== null && el.type !== AST_NODE_TYPES.RestElement ? String(i) : null)) - .filter((key): key is string => key !== null) - - return { argumentKeys, destructuredKeys, keyToName } - } - - function handlePattern( - argumentKeys: string[], - destructuredKeys: string[], - keyToName: Map, - argumentNode: TSESTree.ArrayExpression | TSESTree.ObjectExpression, - patternNode: TSESTree.ArrayPattern | TSESTree.ObjectPattern, - ): void { - for (const key of argumentKeys) { - if (!destructuredKeys.includes(key)) { - context.report({ - node: argumentNode, - messageId: "unusedKey", - data: { key: keyToName.get(key) ?? key }, - }) - } - } - - for (const key of destructuredKeys) { - if (!argumentKeys.includes(key)) { - context.report({ - node: patternNode, - messageId: "missingKey", - data: { key }, - }) - } - } - } - - return { - "ImportDeclaration"(node): void { - if (node.source.value !== "effector-react") return - - for (const specifier of node.specifiers) { - if ( - specifier.type === AST_NODE_TYPES.ImportSpecifier && - specifier.imported.type === AST_NODE_TYPES.Identifier && - specifier.imported.name === "useUnit" - ) { - importedAs.add(specifier.local.name) - } - } - }, - - "VariableDeclarator[id.type='ObjectPattern'] > CallExpression[arguments.length=1][callee.type='Identifier']"( - node: TSESTree.CallExpression, - ): void { - if (!importedAs.has((node.callee as TSESTree.Identifier).name)) return - const argument = node.arguments[0] - - if (argument?.type !== AST_NODE_TYPES.ObjectExpression) return - const parent = node.parent as TSESTree.VariableDeclarator - - if (parent.id.type !== AST_NODE_TYPES.ObjectPattern) return - const { argumentKeys, destructuredKeys, keyToName } = getObjectKeys(argument, parent.id) - - handlePattern(argumentKeys, destructuredKeys, keyToName, argument, parent.id) - }, - - "VariableDeclarator[id.type='ArrayPattern'] > CallExpression[arguments.length=1][callee.type='Identifier']"( - node: TSESTree.CallExpression, - ): void { - if (!importedAs.has((node.callee as TSESTree.Identifier).name)) return - const argument = node.arguments[0] - - if (argument?.type !== AST_NODE_TYPES.ArrayExpression) return - const parent = node.parent as TSESTree.VariableDeclarator - - if (parent.id.type !== AST_NODE_TYPES.ArrayPattern) return - const { argumentKeys, destructuredKeys, keyToName } = getArrayKeys(argument, parent.id) - handlePattern(argumentKeys, destructuredKeys, keyToName, argument, parent.id) - }, - } - }, -}) diff --git a/src/ruleset.ts b/src/ruleset.ts index 40cb3b0..3a4ed89 100644 --- a/src/ruleset.ts +++ b/src/ruleset.ts @@ -29,7 +29,7 @@ const react = { "effector/mandatory-scope-binding": "error", "effector/no-units-spawn-in-render": "error", "effector/prefer-useUnit": "error", - "effector/use-unit-destructuring": "warn", + "effector/prefer-useUnit-destructuring": "warn", } satisfies TSESLint.Linter.RulesRecord const future = { diff --git a/src/shared/create.ts b/src/shared/create.ts index 9cbab7a..5acbe7b 100644 --- a/src/shared/create.ts +++ b/src/shared/create.ts @@ -1,3 +1,40 @@ -import { ESLintUtils } from "@typescript-eslint/utils" +import { AST_NODE_TYPES, ESLintUtils, type TSESTree as Node } from "@typescript-eslint/utils" export const createRule = ESLintUtils.RuleCreator((name) => `https://eslint.effector.dev/rules/${name}`) + +type ShapeProperty = Node.Property | Node.RestElement | Node.SpreadElement +type ValueNode = Exclude | null + +export function toKey(prop: ShapeProperty): string | number | null { + if (prop.type !== AST_NODE_TYPES.Property || prop.computed) return null + if (prop.key.type === AST_NODE_TYPES.Identifier) return prop.key.name + if (prop.key.type === AST_NODE_TYPES.Literal) return prop.key.value + return null +} + +export function shapeToKeyMap( + shape: Node.ObjectPattern | Node.ObjectExpression, +): Map | null { + const map = new Map() + + for (const prop of shape.properties) { + if (prop.type === AST_NODE_TYPES.RestElement || prop.type === AST_NODE_TYPES.SpreadElement) return null + const key = toKey(prop) + if (key === null) return null + map.set(key, prop.key as ValueNode) + } + + return map +} + +export function listToKeyMap(list: Node.ArrayPattern | Node.ArrayExpression): Map | null { + const map = new Map() + + for (const [index, element] of list.elements.entries()) { + if (element?.type === AST_NODE_TYPES.RestElement || element?.type === AST_NODE_TYPES.SpreadElement) return null + if (element === null) continue + map.set(index, element as ValueNode) + } + + return map +} diff --git a/src/shared/name.ts b/src/shared/name.ts index bf574e7..27beaa6 100644 --- a/src/shared/name.ts +++ b/src/shared/name.ts @@ -1,6 +1,7 @@ -import { type TSESTree as Node, AST_NODE_TYPES as NodeType } from "@typescript-eslint/utils" +import { AST_NODE_TYPES, type TSESTree as Node, AST_NODE_TYPES as NodeType } from "@typescript-eslint/utils" type FunctionNode = Node.FunctionDeclaration | Node.FunctionExpression | Node.ArrowFunctionExpression +type ValueNode = Node.Expression | Node.DestructuringPattern | null function functionToName(node: FunctionNode): Node.Identifier | null { if (node.id) return node.id @@ -19,4 +20,44 @@ function functionToName(node: FunctionNode): Node.Identifier | null { return null } +export function getMemberExpressionName(node: Node.MemberExpression): string | null { + if (node.computed) return null + + const prop = node.property + if (prop.type !== AST_NODE_TYPES.Identifier) return null + + if (node.object.type === AST_NODE_TYPES.Identifier) { + return `${node.object.name}.${prop.name}` + } + + if (node.object.type === AST_NODE_TYPES.MemberExpression) { + const objectName = getMemberExpressionName(node.object) + return objectName !== null ? `${objectName}.${prop.name}` : null + } + + return null +} + +export function toName(key: string | number, node: ValueNode): string { + if (!node) return `` + if (node.type === AST_NODE_TYPES.Identifier) return node.name + if (node.type === AST_NODE_TYPES.Literal) return String(node.value) + if (node.type === AST_NODE_TYPES.MemberExpression && node.property.type === AST_NODE_TYPES.Identifier) { + return `${toName(key, node.object)}.${node.property.name}` + } + return `` +} + +export function* check( + provided: Map, + consumed: Map, +): Generator<{ type: "unused" | "missing"; name: string }> { + for (const [key, node] of provided) { + if (!consumed.has(key)) yield { type: "unused", name: toName(key, node) } + } + for (const [key, node] of consumed) { + if (!provided.has(key)) yield { type: "missing", name: toName(key, node) } + } +} + export const nameOf = { function: functionToName } From 4c53ddecb6bfe13dc361f9f4aaca23a43632ed22 Mon Sep 17 00:00:00 2001 From: Ilya Olovyannikov Date: Mon, 13 Apr 2026 14:38:19 +0300 Subject: [PATCH 10/12] refactor: moved feature-specific code from shared --- .gitignore | 3 +- src/index.ts | 4 +- ...force-exhaustive-useUnit-destructuring.md} | 6 +- ...-exhaustive-useUnit-destructuring.test.ts} | 26 ++--- ...nforce-exhaustive-useUnit-destructuring.ts | 94 +++++++++++++++++++ .../lib/index.ts | 64 +++++++++++++ .../prefer-useUnit-destructuring.ts | 94 ------------------- src/ruleset.ts | 2 +- src/shared/create.ts | 39 +------- src/shared/name.ts | 43 +-------- 10 files changed, 180 insertions(+), 195 deletions(-) rename src/rules/{prefer-useUnit-destructuring/prefer-useUnit-destructuring.md => enforce-exhaustive-useUnit-destructuring/enforce-exhaustive-useUnit-destructuring.md} (93%) rename src/rules/{prefer-useUnit-destructuring/prefer-useUnit-destructuring.test.ts => enforce-exhaustive-useUnit-destructuring/enforce-exhaustive-useUnit-destructuring.test.ts} (90%) create mode 100644 src/rules/enforce-exhaustive-useUnit-destructuring/enforce-exhaustive-useUnit-destructuring.ts create mode 100644 src/rules/enforce-exhaustive-useUnit-destructuring/lib/index.ts delete mode 100644 src/rules/prefer-useUnit-destructuring/prefer-useUnit-destructuring.ts diff --git a/.gitignore b/.gitignore index 91cd5fb..e9ffb1f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,4 @@ -.idea -**/*.xml +.idea/ # Logs logs diff --git a/src/index.ts b/src/index.ts index b4a5cbc..21eba7d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,7 @@ import type { ESLint, Linter } from "eslint" import { name, version } from "../package.json" import enforceEffectNamingConvention from "./rules/enforce-effect-naming-convention/enforce-effect-naming-convention" +import enforceExhaustiveUseUnitDestructuring from "./rules/enforce-exhaustive-useUnit-destructuring/enforce-exhaustive-useUnit-destructuring" import enforceGateNamingConvention from "./rules/enforce-gate-naming-convention/enforce-gate-naming-convention" import enforceStoreNamingConvention from "./rules/enforce-store-naming-convention/enforce-store-naming-convention" import keepOptionsOrder from "./rules/keep-options-order/keep-options-order" @@ -21,7 +22,6 @@ import noUnnecessaryDuplication from "./rules/no-unnecessary-duplication/no-unne import noUselessMethods from "./rules/no-useless-methods/no-useless-methods" import noWatch from "./rules/no-watch/no-watch" import preferUseUnit from "./rules/prefer-useUnit/prefer-useUnit" -import preferUseUnitDestructuring from "./rules/prefer-useUnit-destructuring/prefer-useUnit-destructuring" import requirePickupInPersist from "./rules/require-pickup-in-persist/require-pickup-in-persist" import strictEffectHandlers from "./rules/strict-effect-handlers/strict-effect-handlers" import { ruleset } from "./ruleset" @@ -30,6 +30,7 @@ const base = { meta: { name, version, namespace: "effector" }, rules: { "enforce-effect-naming-convention": enforceEffectNamingConvention, + "enforce-exhaustive-useUnit-destructuring": enforceExhaustiveUseUnitDestructuring, "enforce-gate-naming-convention": enforceGateNamingConvention, "enforce-store-naming-convention": enforceStoreNamingConvention, "keep-options-order": keepOptionsOrder, @@ -48,7 +49,6 @@ const base = { "no-useless-methods": noUselessMethods, "no-watch": noWatch, "prefer-useUnit": preferUseUnit, - "prefer-useUnit-destructuring": preferUseUnitDestructuring, "require-pickup-in-persist": requirePickupInPersist, "strict-effect-handlers": strictEffectHandlers, }, diff --git a/src/rules/prefer-useUnit-destructuring/prefer-useUnit-destructuring.md b/src/rules/enforce-exhaustive-useUnit-destructuring/enforce-exhaustive-useUnit-destructuring.md similarity index 93% rename from src/rules/prefer-useUnit-destructuring/prefer-useUnit-destructuring.md rename to src/rules/enforce-exhaustive-useUnit-destructuring/enforce-exhaustive-useUnit-destructuring.md index 133db6f..49a6445 100644 --- a/src/rules/prefer-useUnit-destructuring/prefer-useUnit-destructuring.md +++ b/src/rules/enforce-exhaustive-useUnit-destructuring/enforce-exhaustive-useUnit-destructuring.md @@ -1,8 +1,8 @@ --- -description: Ensures that all units passed to useUnit are properly destructured to avoid unused subscriptions and implicit re-renders. +description: Ensure all units passed to useUnit are properly destructured to avoid unused subscriptions and implicit re-renders. --- -# effector/prefer-useUnit-destructuring +# effector/enforce-exhaustive-useUnit-destructuring [Related documentation](https://effector.dev/en/api/effector-react/useunit/) @@ -147,7 +147,7 @@ If you intentionally want to subscribe to a store without using its value (rare that line: ```tsx -// eslint-disable-next-line effector/prefer-useUnit-destructuring +// eslint-disable-next-line effector/enforce-exhaustive-useUnit-destructuring const { value } = useUnit({ value: $store, trigger: $triggerStore, // intentionally subscribing without using diff --git a/src/rules/prefer-useUnit-destructuring/prefer-useUnit-destructuring.test.ts b/src/rules/enforce-exhaustive-useUnit-destructuring/enforce-exhaustive-useUnit-destructuring.test.ts similarity index 90% rename from src/rules/prefer-useUnit-destructuring/prefer-useUnit-destructuring.test.ts rename to src/rules/enforce-exhaustive-useUnit-destructuring/enforce-exhaustive-useUnit-destructuring.test.ts index 0b62e19..9cdde9c 100644 --- a/src/rules/prefer-useUnit-destructuring/prefer-useUnit-destructuring.test.ts +++ b/src/rules/enforce-exhaustive-useUnit-destructuring/enforce-exhaustive-useUnit-destructuring.test.ts @@ -3,7 +3,7 @@ import { parser } from "typescript-eslint" import { tsx } from "@/shared/tag" -import rule from "./prefer-useUnit-destructuring" +import rule from "./enforce-exhaustive-useUnit-destructuring" const ruleTester = new RuleTester({ languageOptions: { @@ -15,7 +15,7 @@ const ruleTester = new RuleTester({ }, }) -ruleTester.run("prefer-useUnit-destructuring", rule, { +ruleTester.run("enforce-exhaustive-useUnit-destructuring", rule, { valid: [ { name: "object: all keys were destructured", @@ -89,7 +89,7 @@ ruleTester.run("prefer-useUnit-destructuring", rule, { errors: [ { messageId: "unusedKey", - data: { key: "setValue" }, + data: { name: "setValue" }, line: 2, column: 27, endLine: 5, @@ -109,7 +109,7 @@ ruleTester.run("prefer-useUnit-destructuring", rule, { errors: [ { messageId: "missingKey", - data: { key: "extra" }, + data: { name: "extra" }, }, ], }, @@ -122,7 +122,7 @@ ruleTester.run("prefer-useUnit-destructuring", rule, { errors: [ { messageId: "unusedKey", - data: { key: "$store" }, + data: { name: "$store" }, line: 2, column: 28, endLine: 2, @@ -139,11 +139,11 @@ ruleTester.run("prefer-useUnit-destructuring", rule, { errors: [ { messageId: "unusedKey", - data: { key: "event" }, + data: { name: "event" }, }, { messageId: "unusedKey", - data: { key: "$anotherStore" }, + data: { name: "$anotherStore" }, }, ], }, @@ -160,11 +160,11 @@ ruleTester.run("prefer-useUnit-destructuring", rule, { errors: [ { messageId: "unusedKey", - data: { key: "setValue" }, + data: { name: "setValue" }, }, { messageId: "unusedKey", - data: { key: "reset" }, + data: { name: "reset" }, }, ], }, @@ -185,7 +185,7 @@ ruleTester.run("prefer-useUnit-destructuring", rule, { errors: [ { messageId: "unusedKey", - data: { key: "setValue" }, + data: { name: "setValue" }, }, ], }, @@ -201,7 +201,7 @@ ruleTester.run("prefer-useUnit-destructuring", rule, { errors: [ { messageId: "unusedKey", - data: { key: "setValue" }, + data: { name: "setValue" }, }, ], }, @@ -214,7 +214,7 @@ ruleTester.run("prefer-useUnit-destructuring", rule, { errors: [ { messageId: "unusedKey", - data: { key: "$b" }, + data: { name: "$b" }, }, ], }, @@ -230,7 +230,7 @@ ruleTester.run("prefer-useUnit-destructuring", rule, { errors: [ { messageId: "unusedKey", - data: { key: "setValue" }, + data: { name: "setValue" }, }, ], }, diff --git a/src/rules/enforce-exhaustive-useUnit-destructuring/enforce-exhaustive-useUnit-destructuring.ts b/src/rules/enforce-exhaustive-useUnit-destructuring/enforce-exhaustive-useUnit-destructuring.ts new file mode 100644 index 0000000..a515357 --- /dev/null +++ b/src/rules/enforce-exhaustive-useUnit-destructuring/enforce-exhaustive-useUnit-destructuring.ts @@ -0,0 +1,94 @@ +import { type TSESTree as Node } from "@typescript-eslint/utils" + +import { createRule } from "@/shared/create" +import { PACKAGE_NAME } from "@/shared/package" + +import { type MessageIds, check, listToKeyMap, shapeToKeyMap } from "./lib" + +type Options = [] + +type ShapeCall = Node.VariableDeclarator & { + init: Node.CallExpression & { + callee: Node.Identifier + arguments: [Node.ObjectExpression] + } + id: Node.ObjectPattern +} + +type ListCall = Node.VariableDeclarator & { + init: Node.CallExpression & { + callee: Node.Identifier + arguments: [Node.ArrayExpression] + } + id: Node.ArrayPattern +} + +const selector = { + import: `ImportDeclaration[source.value=${PACKAGE_NAME.react}] > ImportSpecifier[imported.name=useUnit]`, + variable: { + shape: "VariableDeclarator[id.type=ObjectPattern]", + list: "VariableDeclarator[id.type=ArrayPattern]", + }, + call: "CallExpression.init[arguments.length=1][callee.type=Identifier]", + arg: { + shape: "ObjectExpression.arguments", + list: "ArrayExpression.arguments", + }, +} as const + +export default createRule({ + name: "enforce-exhaustive-useUnit-destructuring", + meta: { + type: "problem", + docs: { + description: "Ensure all units passed to useUnit are properly destructured.", + }, + messages: { + unusedKey: 'Property "{{name}}" is passed but not destructured.', + missingKey: 'Property "{{name}}" is destructured but not passed in the unit object.', + }, + schema: [], + defaultOptions: [], + }, + create(context) { + const importedAs = new Set() + + return { + [selector.import]: (node: Node.ImportSpecifier) => void importedAs.add(node.local.name), + + [`${selector.variable.shape}:has(> ${selector.call}:has(${selector.arg.shape}))`](node: ShapeCall): void { + if (!importedAs.has(node.init.callee.name)) return + + const objectArgument = node.init.arguments[0] + const objectPattern = node.id + + const provided = shapeToKeyMap(objectArgument) + const consumed = shapeToKeyMap(objectPattern) + + if (provided === null || consumed === null) return + + for (const { type, name } of check(provided, consumed)) { + if (type === "unused") context.report({ node: objectArgument, messageId: "unusedKey", data: { name } }) + else context.report({ node: objectPattern, messageId: "missingKey", data: { name } }) + } + }, + + [`${selector.variable.list}:has(> ${selector.call}:has(${selector.arg.list}))`](node: ListCall): void { + if (!importedAs.has(node.init.callee.name)) return + + const arrayArgument = node.init.arguments[0] + const arrayPattern = node.id + + const provided = listToKeyMap(arrayArgument) + const consumed = listToKeyMap(arrayPattern) + + if (provided === null || consumed === null) return + + for (const { type, name } of check(provided, consumed)) { + if (type === "unused") context.report({ node: arrayArgument, messageId: "unusedKey", data: { name } }) + else context.report({ node: arrayPattern, messageId: "missingKey", data: { name } }) + } + }, + } + }, +}) diff --git a/src/rules/enforce-exhaustive-useUnit-destructuring/lib/index.ts b/src/rules/enforce-exhaustive-useUnit-destructuring/lib/index.ts new file mode 100644 index 0000000..ec3809e --- /dev/null +++ b/src/rules/enforce-exhaustive-useUnit-destructuring/lib/index.ts @@ -0,0 +1,64 @@ +import { type TSESTree as Node, AST_NODE_TYPES as NodeType } from "@typescript-eslint/utils" + +export type MessageIds = "unusedKey" | "missingKey" +type ValueNode = Node.Expression | Node.DestructuringPattern | null + +function toName(key: string | number, node: ValueNode): string { + if (!node) return `` + if (node.type === NodeType.Identifier) return node.name + if (node.type === NodeType.Literal) return String(node.value) + if (node.type === NodeType.MemberExpression && node.property.type === NodeType.Identifier) { + return `${toName(key, node.object)}.${node.property.name}` + } + return `` +} + +export function* check( + provided: Map, + consumed: Map, +): Generator<{ type: "unused" | "missing"; name: string }> { + for (const [key, node] of provided) { + if (!consumed.has(key)) yield { type: "unused", name: toName(key, node) } + } + for (const [key, node] of consumed) { + if (!provided.has(key)) yield { type: "missing", name: toName(key, node) } + } +} + +function toKey(prop: Node.Property): string | number | null { + if (prop.computed) return null + if (prop.key.type === NodeType.Identifier) return prop.key.name + if (prop.key.type === NodeType.Literal) return prop.key.value + return null +} + +export function shapeToKeyMap( + shape: Node.ObjectPattern | Node.ObjectExpression, +): Map | null { + const map = new Map() + + for (const prop of shape.properties) { + if (prop.type !== NodeType.Property) return null + + const key = toKey(prop) + + if (key === null) return null + + map.set(key, prop.key) + } + + return map +} + +export function listToKeyMap(list: Node.ArrayPattern | Node.ArrayExpression): Map | null { + const map = new Map() + + for (const [index, element] of list.elements.entries()) { + if (element === null) continue + if (element.type === NodeType.RestElement || element.type === NodeType.SpreadElement) return null + + map.set(index, element as ValueNode) + } + + return map +} diff --git a/src/rules/prefer-useUnit-destructuring/prefer-useUnit-destructuring.ts b/src/rules/prefer-useUnit-destructuring/prefer-useUnit-destructuring.ts deleted file mode 100644 index 005a6b7..0000000 --- a/src/rules/prefer-useUnit-destructuring/prefer-useUnit-destructuring.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { type TSESTree as Node } from "@typescript-eslint/utils" - -import { createRule, listToKeyMap, shapeToKeyMap } from "@/shared/create" -import { check } from "@/shared/name" -import { PACKAGE_NAME } from "@/shared/package" - -type MessageIds = "unusedKey" | "missingKey" -type Options = [] - -type ShapeCall = Node.VariableDeclarator & { - init: Node.CallExpression & { - callee: Node.Identifier - arguments: [Node.ObjectExpression] - } - id: Node.ObjectPattern -} - -type ListCall = Node.VariableDeclarator & { - init: Node.CallExpression & { - callee: Node.Identifier - arguments: [Node.ArrayExpression] - } - id: Node.ArrayPattern -} - -const selector = { - import: `ImportDeclaration[source.value=${PACKAGE_NAME.react}] > ImportSpecifier[imported.name=useUnit]`, - variable: { - shape: "VariableDeclarator[id.type=ObjectPattern]", - list: "VariableDeclarator[id.type=ArrayPattern]", - }, - call: "CallExpression.init[arguments.length=1][callee.type=Identifier]", - arg: { - shape: "ObjectExpression.arguments", - list: "ArrayExpression.arguments", - }, -} as const - -export default createRule({ - name: "prefer-useUnit-destructuring", - meta: { - type: "problem", - docs: { - description: "Ensure destructured properties match the passed unit object/array", - }, - messages: { - unusedKey: 'Property "{{key}}" is passed but not destructured.', - missingKey: 'Property "{{key}}" is destructured but not passed in the unit object.', - }, - schema: [], - defaultOptions: [], - }, - create(context) { - const importedAs = new Set() - - function handleObjectPattern(objectArgument: Node.ObjectExpression, objectPattern: Node.ObjectPattern): void { - const provided = shapeToKeyMap(objectArgument) - const consumed = shapeToKeyMap(objectPattern) - - if (provided === null || consumed === null) return - - for (const { type, name } of check(provided, consumed)) { - if (type === "unused") context.report({ node: objectArgument, messageId: "unusedKey", data: { key: name } }) - else context.report({ node: objectPattern, messageId: "missingKey", data: { key: name } }) - } - } - - function handleArrayPattern(arrayArgument: Node.ArrayExpression, arrayPattern: Node.ArrayPattern): void { - const provided = listToKeyMap(arrayArgument) - const consumed = listToKeyMap(arrayPattern) - - if (provided === null || consumed === null) return - - for (const { type, name } of check(provided, consumed)) { - if (type === "unused") context.report({ node: arrayArgument, messageId: "unusedKey", data: { key: name } }) - else context.report({ node: arrayPattern, messageId: "missingKey", data: { key: name } }) - } - } - - return { - [selector.import]: (node: Node.ImportSpecifier) => void importedAs.add(node.local.name), - - [`${selector.variable.shape}:has(> ${selector.call}:has(${selector.arg.shape}))`](node: ShapeCall): void { - if (!importedAs.has(node.init.callee.name)) return - handleObjectPattern(node.init.arguments[0], node.id) - }, - - [`${selector.variable.list}:has(> ${selector.call}:has(${selector.arg.list}))`](node: ListCall): void { - if (!importedAs.has(node.init.callee.name)) return - handleArrayPattern(node.init.arguments[0], node.id) - }, - } - }, -}) diff --git a/src/ruleset.ts b/src/ruleset.ts index 3a4ed89..64c19dd 100644 --- a/src/ruleset.ts +++ b/src/ruleset.ts @@ -26,10 +26,10 @@ const scope = { const react = { "effector/enforce-gate-naming-convention": "error", + "effector/enforce-exhaustive-useUnit-destructuring": "warn", "effector/mandatory-scope-binding": "error", "effector/no-units-spawn-in-render": "error", "effector/prefer-useUnit": "error", - "effector/prefer-useUnit-destructuring": "warn", } satisfies TSESLint.Linter.RulesRecord const future = { diff --git a/src/shared/create.ts b/src/shared/create.ts index 5acbe7b..9cbab7a 100644 --- a/src/shared/create.ts +++ b/src/shared/create.ts @@ -1,40 +1,3 @@ -import { AST_NODE_TYPES, ESLintUtils, type TSESTree as Node } from "@typescript-eslint/utils" +import { ESLintUtils } from "@typescript-eslint/utils" export const createRule = ESLintUtils.RuleCreator((name) => `https://eslint.effector.dev/rules/${name}`) - -type ShapeProperty = Node.Property | Node.RestElement | Node.SpreadElement -type ValueNode = Exclude | null - -export function toKey(prop: ShapeProperty): string | number | null { - if (prop.type !== AST_NODE_TYPES.Property || prop.computed) return null - if (prop.key.type === AST_NODE_TYPES.Identifier) return prop.key.name - if (prop.key.type === AST_NODE_TYPES.Literal) return prop.key.value - return null -} - -export function shapeToKeyMap( - shape: Node.ObjectPattern | Node.ObjectExpression, -): Map | null { - const map = new Map() - - for (const prop of shape.properties) { - if (prop.type === AST_NODE_TYPES.RestElement || prop.type === AST_NODE_TYPES.SpreadElement) return null - const key = toKey(prop) - if (key === null) return null - map.set(key, prop.key as ValueNode) - } - - return map -} - -export function listToKeyMap(list: Node.ArrayPattern | Node.ArrayExpression): Map | null { - const map = new Map() - - for (const [index, element] of list.elements.entries()) { - if (element?.type === AST_NODE_TYPES.RestElement || element?.type === AST_NODE_TYPES.SpreadElement) return null - if (element === null) continue - map.set(index, element as ValueNode) - } - - return map -} diff --git a/src/shared/name.ts b/src/shared/name.ts index 27beaa6..bf574e7 100644 --- a/src/shared/name.ts +++ b/src/shared/name.ts @@ -1,7 +1,6 @@ -import { AST_NODE_TYPES, type TSESTree as Node, AST_NODE_TYPES as NodeType } from "@typescript-eslint/utils" +import { type TSESTree as Node, AST_NODE_TYPES as NodeType } from "@typescript-eslint/utils" type FunctionNode = Node.FunctionDeclaration | Node.FunctionExpression | Node.ArrowFunctionExpression -type ValueNode = Node.Expression | Node.DestructuringPattern | null function functionToName(node: FunctionNode): Node.Identifier | null { if (node.id) return node.id @@ -20,44 +19,4 @@ function functionToName(node: FunctionNode): Node.Identifier | null { return null } -export function getMemberExpressionName(node: Node.MemberExpression): string | null { - if (node.computed) return null - - const prop = node.property - if (prop.type !== AST_NODE_TYPES.Identifier) return null - - if (node.object.type === AST_NODE_TYPES.Identifier) { - return `${node.object.name}.${prop.name}` - } - - if (node.object.type === AST_NODE_TYPES.MemberExpression) { - const objectName = getMemberExpressionName(node.object) - return objectName !== null ? `${objectName}.${prop.name}` : null - } - - return null -} - -export function toName(key: string | number, node: ValueNode): string { - if (!node) return `` - if (node.type === AST_NODE_TYPES.Identifier) return node.name - if (node.type === AST_NODE_TYPES.Literal) return String(node.value) - if (node.type === AST_NODE_TYPES.MemberExpression && node.property.type === AST_NODE_TYPES.Identifier) { - return `${toName(key, node.object)}.${node.property.name}` - } - return `` -} - -export function* check( - provided: Map, - consumed: Map, -): Generator<{ type: "unused" | "missing"; name: string }> { - for (const [key, node] of provided) { - if (!consumed.has(key)) yield { type: "unused", name: toName(key, node) } - } - for (const [key, node] of consumed) { - if (!provided.has(key)) yield { type: "missing", name: toName(key, node) } - } -} - export const nameOf = { function: functionToName } From c508ed45e29da9548d01931ee8396344014a83ca Mon Sep 17 00:00:00 2001 From: Ilya Olovyannikov Date: Mon, 13 Apr 2026 17:42:42 +0300 Subject: [PATCH 11/12] refactor: remove unnecessary lib dir from rule --- ...nforce-exhaustive-useUnit-destructuring.ts | 91 +++++++++++++++---- .../lib/index.ts | 64 ------------- 2 files changed, 74 insertions(+), 81 deletions(-) delete mode 100644 src/rules/enforce-exhaustive-useUnit-destructuring/lib/index.ts diff --git a/src/rules/enforce-exhaustive-useUnit-destructuring/enforce-exhaustive-useUnit-destructuring.ts b/src/rules/enforce-exhaustive-useUnit-destructuring/enforce-exhaustive-useUnit-destructuring.ts index a515357..2c3036a 100644 --- a/src/rules/enforce-exhaustive-useUnit-destructuring/enforce-exhaustive-useUnit-destructuring.ts +++ b/src/rules/enforce-exhaustive-useUnit-destructuring/enforce-exhaustive-useUnit-destructuring.ts @@ -1,12 +1,13 @@ -import { type TSESTree as Node } from "@typescript-eslint/utils" +import { type TSESTree as Node, AST_NODE_TYPES as NodeType } from "@typescript-eslint/utils" import { createRule } from "@/shared/create" import { PACKAGE_NAME } from "@/shared/package" -import { type MessageIds, check, listToKeyMap, shapeToKeyMap } from "./lib" - type Options = [] +type MessageIds = "unusedKey" | "missingKey" +type ValueNode = Node.Expression | Node.DestructuringPattern | null + type ShapeCall = Node.VariableDeclarator & { init: Node.CallExpression & { callee: Node.Identifier @@ -23,6 +24,66 @@ type ListCall = Node.VariableDeclarator & { id: Node.ArrayPattern } +function toName(key: string | number, node: ValueNode): string { + if (!node) return `` + if (node.type === NodeType.Identifier) return node.name + if (node.type === NodeType.Literal) return String(node.value) + if (node.type === NodeType.MemberExpression && node.property.type === NodeType.Identifier) { + return `${toName(key, node.object)}.${node.property.name}` + } + return `` +} + +export function* check( + provided: Map, + consumed: Map, +): Generator<{ type: "unused" | "missing"; name: string }> { + for (const [key, node] of provided) { + if (!consumed.has(key)) yield { type: "unused", name: toName(key, node) } + } + for (const [key, node] of consumed) { + if (!provided.has(key)) yield { type: "missing", name: toName(key, node) } + } +} + +function toKey(prop: Node.Property): string | number | null { + if (prop.computed) return null + if (prop.key.type === NodeType.Identifier) return prop.key.name + if (prop.key.type === NodeType.Literal) return prop.key.value + return null +} + +export function shapeToKeyMap( + shape: Node.ObjectPattern | Node.ObjectExpression, +): Map | null { + const map = new Map() + + for (const prop of shape.properties) { + if (prop.type !== NodeType.Property) return null + + const key = toKey(prop) + + if (key === null) return null + + map.set(key, prop.key) + } + + return map +} + +export function listToKeyMap(list: Node.ArrayPattern | Node.ArrayExpression): Map | null { + const map = new Map() + + for (const [index, element] of list.elements.entries()) { + if (element === null) continue + if (element.type === NodeType.RestElement || element.type === NodeType.SpreadElement) return null + + map.set(index, element as ValueNode) + } + + return map +} + const selector = { import: `ImportDeclaration[source.value=${PACKAGE_NAME.react}] > ImportSpecifier[imported.name=useUnit]`, variable: { @@ -59,34 +120,30 @@ export default createRule({ [`${selector.variable.shape}:has(> ${selector.call}:has(${selector.arg.shape}))`](node: ShapeCall): void { if (!importedAs.has(node.init.callee.name)) return - const objectArgument = node.init.arguments[0] - const objectPattern = node.id - - const provided = shapeToKeyMap(objectArgument) - const consumed = shapeToKeyMap(objectPattern) + const provided = shapeToKeyMap(node.init.arguments[0]) + const consumed = shapeToKeyMap(node.id) if (provided === null || consumed === null) return for (const { type, name } of check(provided, consumed)) { - if (type === "unused") context.report({ node: objectArgument, messageId: "unusedKey", data: { name } }) - else context.report({ node: objectPattern, messageId: "missingKey", data: { name } }) + if (type === "unused") + context.report({ node: node.init.arguments[0], messageId: "unusedKey", data: { name } }) + else context.report({ node: node.id, messageId: "missingKey", data: { name } }) } }, [`${selector.variable.list}:has(> ${selector.call}:has(${selector.arg.list}))`](node: ListCall): void { if (!importedAs.has(node.init.callee.name)) return - const arrayArgument = node.init.arguments[0] - const arrayPattern = node.id - - const provided = listToKeyMap(arrayArgument) - const consumed = listToKeyMap(arrayPattern) + const provided = listToKeyMap(node.init.arguments[0]) + const consumed = listToKeyMap(node.id) if (provided === null || consumed === null) return for (const { type, name } of check(provided, consumed)) { - if (type === "unused") context.report({ node: arrayArgument, messageId: "unusedKey", data: { name } }) - else context.report({ node: arrayPattern, messageId: "missingKey", data: { name } }) + if (type === "unused") + context.report({ node: node.init.arguments[0], messageId: "unusedKey", data: { name } }) + else context.report({ node: node.id, messageId: "missingKey", data: { name } }) } }, } diff --git a/src/rules/enforce-exhaustive-useUnit-destructuring/lib/index.ts b/src/rules/enforce-exhaustive-useUnit-destructuring/lib/index.ts deleted file mode 100644 index ec3809e..0000000 --- a/src/rules/enforce-exhaustive-useUnit-destructuring/lib/index.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { type TSESTree as Node, AST_NODE_TYPES as NodeType } from "@typescript-eslint/utils" - -export type MessageIds = "unusedKey" | "missingKey" -type ValueNode = Node.Expression | Node.DestructuringPattern | null - -function toName(key: string | number, node: ValueNode): string { - if (!node) return `` - if (node.type === NodeType.Identifier) return node.name - if (node.type === NodeType.Literal) return String(node.value) - if (node.type === NodeType.MemberExpression && node.property.type === NodeType.Identifier) { - return `${toName(key, node.object)}.${node.property.name}` - } - return `` -} - -export function* check( - provided: Map, - consumed: Map, -): Generator<{ type: "unused" | "missing"; name: string }> { - for (const [key, node] of provided) { - if (!consumed.has(key)) yield { type: "unused", name: toName(key, node) } - } - for (const [key, node] of consumed) { - if (!provided.has(key)) yield { type: "missing", name: toName(key, node) } - } -} - -function toKey(prop: Node.Property): string | number | null { - if (prop.computed) return null - if (prop.key.type === NodeType.Identifier) return prop.key.name - if (prop.key.type === NodeType.Literal) return prop.key.value - return null -} - -export function shapeToKeyMap( - shape: Node.ObjectPattern | Node.ObjectExpression, -): Map | null { - const map = new Map() - - for (const prop of shape.properties) { - if (prop.type !== NodeType.Property) return null - - const key = toKey(prop) - - if (key === null) return null - - map.set(key, prop.key) - } - - return map -} - -export function listToKeyMap(list: Node.ArrayPattern | Node.ArrayExpression): Map | null { - const map = new Map() - - for (const [index, element] of list.elements.entries()) { - if (element === null) continue - if (element.type === NodeType.RestElement || element.type === NodeType.SpreadElement) return null - - map.set(index, element as ValueNode) - } - - return map -} From b8243b7b589ffba608d1d359c83fab7cfe63fce6 Mon Sep 17 00:00:00 2001 From: Ilya Olovyannikov Date: Mon, 13 Apr 2026 17:44:43 +0300 Subject: [PATCH 12/12] chore(changeset): changeset added --- .changeset/slow-rocks-refuse.md | 5 +++++ .../enforce-exhaustive-useUnit-destructuring.ts | 8 +++----- 2 files changed, 8 insertions(+), 5 deletions(-) create mode 100644 .changeset/slow-rocks-refuse.md diff --git a/.changeset/slow-rocks-refuse.md b/.changeset/slow-rocks-refuse.md new file mode 100644 index 0000000..9fcdec3 --- /dev/null +++ b/.changeset/slow-rocks-refuse.md @@ -0,0 +1,5 @@ +--- +"eslint-plugin-effector": minor +--- + +Add new rule `enforce-exhaustive-useUnit-destructuring` diff --git a/src/rules/enforce-exhaustive-useUnit-destructuring/enforce-exhaustive-useUnit-destructuring.ts b/src/rules/enforce-exhaustive-useUnit-destructuring/enforce-exhaustive-useUnit-destructuring.ts index 2c3036a..892393c 100644 --- a/src/rules/enforce-exhaustive-useUnit-destructuring/enforce-exhaustive-useUnit-destructuring.ts +++ b/src/rules/enforce-exhaustive-useUnit-destructuring/enforce-exhaustive-useUnit-destructuring.ts @@ -34,7 +34,7 @@ function toName(key: string | number, node: ValueNode): string { return `` } -export function* check( +function* check( provided: Map, consumed: Map, ): Generator<{ type: "unused" | "missing"; name: string }> { @@ -53,9 +53,7 @@ function toKey(prop: Node.Property): string | number | null { return null } -export function shapeToKeyMap( - shape: Node.ObjectPattern | Node.ObjectExpression, -): Map | null { +function shapeToKeyMap(shape: Node.ObjectPattern | Node.ObjectExpression): Map | null { const map = new Map() for (const prop of shape.properties) { @@ -71,7 +69,7 @@ export function shapeToKeyMap( return map } -export function listToKeyMap(list: Node.ArrayPattern | Node.ArrayExpression): Map | null { +function listToKeyMap(list: Node.ArrayPattern | Node.ArrayExpression): Map | null { const map = new Map() for (const [index, element] of list.elements.entries()) {