From d687cb36f45d09f9543b081b3aa0fc89fa5a09f0 Mon Sep 17 00:00:00 2001 From: Ilya Olovyannikov Date: Tue, 9 Dec 2025 12:56:00 +0300 Subject: [PATCH 1/2] feat(rule): add new rule for destructured units --- .gitignore | 4 +- config/react.js | 1 + index.js | 1 + .../use-unit-destructuring.js | 139 +++++++++++++++ .../use-unit-destructuring.md | 1 + .../use-unit-destructuring.test.js | 161 ++++++++++++++++++ 6 files changed, 306 insertions(+), 1 deletion(-) 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 00f4c21..4b7d641 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,6 @@ coverage docs/.vitepress/dist docs/**/__*.md docs/changelog.md -docs/.vitepress/cache \ No newline at end of file +docs/.vitepress/cache +.idea +**/*.xml \ No newline at end of file diff --git a/config/react.js b/config/react.js index 975fff9..31356e2 100644 --- a/config/react.js +++ b/config/react.js @@ -3,5 +3,6 @@ module.exports = { "effector/enforce-gate-naming-convention": "error", "effector/mandatory-scope-binding": "error", "effector/prefer-useUnit": "warn", + "effector/use-unit-destructuring": "warn", }, }; diff --git a/index.js b/index.js index 66b7952..f820ec9 100644 --- a/index.js +++ b/index.js @@ -20,6 +20,7 @@ module.exports = { "prefer-useUnit": require("./rules/prefer-useUnit/prefer-useUnit"), "require-pickup-in-persist": require("./rules/require-pickup-in-persist/require-pickup-in-persist"), "no-patronum-debug": require("./rules/no-patronum-debug/no-patronum-debug"), + "use-unit-destructuring": require("./rules/use-unit-destructuring/use-unit-destructuring"), }, configs: { recommended: require("./config/recommended"), 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 9accf5e0dd9bf16b6631149c5958f6c9850924b7 Mon Sep 17 00:00:00 2001 From: Ilya Olovyannikov Date: Tue, 9 Dec 2025 13:01:33 +0300 Subject: [PATCH 2/2] 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