diff --git a/src/index.ts b/src/index.ts index 21eba7d..22915ed 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,6 +21,7 @@ import noUnnecessaryCombination from "./rules/no-unnecessary-combination/no-unne import noUnnecessaryDuplication from "./rules/no-unnecessary-duplication/no-unnecessary-duplication" import noUselessMethods from "./rules/no-useless-methods/no-useless-methods" import noWatch from "./rules/no-watch/no-watch" +import preferSingleBinding from "./rules/prefer-single-binding/prefer-single-binding" 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" @@ -49,6 +50,7 @@ const base = { "no-useless-methods": noUselessMethods, "no-watch": noWatch, "prefer-useUnit": preferUseUnit, + "prefer-single-binding": preferSingleBinding, "require-pickup-in-persist": requirePickupInPersist, "strict-effect-handlers": strictEffectHandlers, }, @@ -60,6 +62,7 @@ const legacyConfigs = { react: { rules: ruleset.react }, future: { rules: ruleset.future }, patronum: { rules: ruleset.patronum }, + style: { rules: ruleset.style }, } const self = base as unknown as ESLint.Plugin @@ -70,6 +73,7 @@ const flatConfigs: Record = { react: { plugins: { effector: self }, rules: ruleset.react }, future: { plugins: { effector: self }, rules: ruleset.future }, patronum: { plugins: { effector: self }, rules: ruleset.patronum }, + style: { plugins: { effector: self }, rules: ruleset.style }, } const plugin = base as { diff --git a/src/rules/prefer-single-binding/prefer-single-binding.md b/src/rules/prefer-single-binding/prefer-single-binding.md new file mode 100644 index 0000000..1740b65 --- /dev/null +++ b/src/rules/prefer-single-binding/prefer-single-binding.md @@ -0,0 +1,366 @@ +# effector/prefer-single-binding + +[Related documentation](https://effector.dev/en/api/effector-react/useunit/) + +Recommends combining multiple `useUnit` calls into a single call for better performance and cleaner code. + +## Rule Details + +This rule detects when multiple `useUnit` hooks are called in the same component and suggests combining them into a single call. + +Multiple `useUnit` calls can lead to: +- **Performance overhead**: Each `useUnit` creates separate subscriptions without batch-updates +- **Code duplication**: Repetitive hook calls make code harder to read +- **Maintenance issues**: Harder to track all units used in a component + +### Limitations + +This rule does not handle the following cases: +- Mixed array and object forms in the same component are reported but no suggestion is provided +- `@@unitShape` protocol (used by routers, queries, etc.) + +Non-destructuring calls like `const store = useUnit($store)` are partially handled: if such calls appear +alongside destructuring calls in the same component, the rule will report them. However, no suggestion +is provided for this case since the fix is ambiguous. + +### Examples + +```tsx +// 👎 incorrect - multiple useUnit calls +const Component = () => { + const [store] = useUnit([$store]); + const [event] = useUnit([$event]); + + return ; +}; +``` + +```tsx +// 👍 correct - single useUnit call +const Component = () => { + const [store, event] = useUnit([$store, $event]); + + return ; +}; +``` + +## Options + +This rule accepts an options object with the following properties: + +```typescript +type Options = { + separation?: "forbid" | "allow" | "enforce"; +}; +``` + +### `separation` + +**Default:** `"forbid"` + +Controls how the rule handles separation of stores, events and effects into different `useUnit` calls. + +| Value | Behavior | +|---|---| +| `"forbid"` | All `useUnit` calls must be combined into one (default) | +| `"allow"` | Stores, events and effects may be in separate calls, but multiple calls of the same type must be combined | +| `"enforce"` | A single `useUnit` call must not mix stores, events and effects | + +Unit types are determined using TypeScript type information. The rule requires TypeScript to be configured in your project. + +#### `separation: "forbid"` (default) + +All `useUnit` calls in a component must be combined into a single call regardless of unit types. + +Non-destructuring calls (`const store = useUnit($store)`) are also reported when they appear alongside +other `useUnit` calls, but no suggestion is provided for them. + +```tsx +// 👎 incorrect - multiple destructuring calls +const Component = () => { + const [userName] = useUnit([$userName]); + const [updateUser] = useUnit([updateUserEvent]); + return null; +}; + +// 👎 incorrect - non-destructuring call alongside destructuring call +const Component = () => { + const userName = useUnit($userName); + const [updateUser] = useUnit([updateUserEvent]); + return null; +}; + +// 👍 correct +const Component = () => { + const [userName, updateUser] = useUnit([$userName, updateUserEvent]); + return null; +}; +``` + +#### `separation: "allow"` + +Stores, events and effects may live in separate `useUnit` calls, but multiple calls of the same type must be combined. Non-destructuring calls are not reported under this option. + +```tsx +// 👎 incorrect - multiple store calls +const Component = () => { + const [userName] = useUnit([$userName]); + const [userAge] = useUnit([$userAge]); + const [updateUser] = useUnit([updateUserEvent]); + return null; +}; + +// 👍 correct - stores combined, events separate +const Component = () => { + const [userName, userAge] = useUnit([$userName, $userAge]); + const [updateUser] = useUnit([updateUserEvent]); + return null; +}; +``` + +#### `separation: "enforce"` + +A single `useUnit` call must not contain a mix of stores, events and effects. Each call must contain only one unit type. Non-destructuring calls are not reported under this option. + +```tsx +// 👎 incorrect - mixed stores and events +const Component = () => { + const [value, setValue] = useUnit([$store, event]); + return null; +}; + +// 👍 correct - separated by type +const Component = () => { + const [value] = useUnit([$store]); + const [setValue] = useUnit([event]); + return null; +}; +``` + +Works with object form too: + +```tsx +// 👎 incorrect +const Component = () => { + const { value, setValue } = useUnit({ value: $store, setValue: event }); + return null; +}; + +// 👍 correct +const Component = () => { + const { value } = useUnit({ value: $store }); + const { setValue } = useUnit({ setValue: event }); + return null; +}; +``` + +## Import aliases + +The rule correctly handles aliased imports: + +```tsx +import { useUnit as useUnitEffector } from "effector-react"; + +// 👎 incorrect +const Component = () => { + const [store] = useUnitEffector([$store]); + const [event] = useUnitEffector([event]); + return null; +}; + +// 👍 correct +const Component = () => { + const [store, event] = useUnitEffector([$store, event]); + return null; +}; +``` + +## Configuration examples + +### Strict single call (default) +```javascript +// eslint.config.js +export default { + rules: { + 'effector/prefer-single-binding': 'warn' + } +}; +``` + +### Allow stores/events separation +```javascript +// eslint.config.js +export default { + rules: { + 'effector/prefer-single-binding': ['warn', { + separation: 'allow' + }] + } +}; +``` + +### Enforce stores/events separation +```javascript +// eslint.config.js +export default { + rules: { + 'effector/prefer-single-binding': ['warn', { + separation: 'enforce' + }] + } +}; +``` + +## Suggestions + +This rule provides **suggestions** (not auto-fixes) because merging or splitting `useUnit` calls may +change the order of subscriptions which can affect runtime behavior. Suggestions can be applied +manually via your editor or via `--fix-type suggestion` flag: + +```bash +eslint --fix-type suggestion your-file.tsx +``` + +### Default behavior (`separation: "forbid"`) + +Suggests combining all `useUnit` calls into a single one: + +```tsx +// Before +const [value] = useUnit([$store]); +const [setValue] = useUnit([event]); + +// After applying suggestion +const [value, setValue] = useUnit([$store, event]); +``` + +### `separation: "enforce"` + +Suggests splitting mixed `useUnit` calls into separate calls per unit type: + +```tsx +// Before +const [value, setValue] = useUnit([$store, event]); + +// After applying suggestion +const [value] = useUnit([$store]); +const [setValue] = useUnit([event]); +``` + +### `separation: "allow"` + +Suggests combining multiple calls of the same unit type: + +```tsx +// Before +const [value1] = useUnit([$store1]); +const [value2] = useUnit([$store2]); +const [handler] = useUnit([event]); + +// After applying suggestion +const [value1, value2] = useUnit([$store1, $store2]); +const [handler] = useUnit([event]); +``` + +## Real-world example + +```tsx +import { createEvent, createStore } from "effector"; +import { useUnit } from "effector-react"; + +const $userName = createStore("John"); +const $userEmail = createStore("john@example.com"); +const $isLoading = createStore(false); +const updateName = createEvent(); +const updateEmail = createEvent(); + +// 👎 incorrect - scattered useUnit calls +const UserProfile = () => { + const [userName] = useUnit([$userName]); + const [userEmail] = useUnit([$userEmail]); + const [isLoading] = useUnit([$isLoading]); + const [handleUpdateName] = useUnit([updateName]); + const [handleUpdateEmail] = useUnit([updateEmail]); + + return ( +
+ {isLoading ? ( +

Loading...

+ ) : ( + <> + handleUpdateName(e.target.value)} /> + handleUpdateEmail(e.target.value)} /> + + )} +
+ ); +}; + +// 👍 correct - single useUnit call (separation: "forbid") +const UserProfile = () => { + const [userName, userEmail, isLoading, handleUpdateName, handleUpdateEmail] = useUnit([ + $userName, + $userEmail, + $isLoading, + updateName, + updateEmail, + ]); + + return ( +
+ {isLoading ? ( +

Loading...

+ ) : ( + <> + handleUpdateName(e.target.value)} /> + handleUpdateEmail(e.target.value)} /> + + )} +
+ ); +}; + +// 👍 correct - separated by type (separation: "allow" or "enforce") +const UserProfile = () => { + const [userName, userEmail, isLoading] = useUnit([$userName, $userEmail, $isLoading]); + const [handleUpdateName, handleUpdateEmail] = useUnit([updateName, updateEmail]); + + return ( +
+ {isLoading ? ( +

Loading...

+ ) : ( + <> + handleUpdateName(e.target.value)} /> + handleUpdateEmail(e.target.value)} /> + + )} +
+ ); +}; +``` + +## When Not To Use It + +Disable the rule per-file if you need conditional `useUnit` calls for specific reasons: + +```tsx +/* eslint-disable effector/prefer-single-binding */ +const Component = () => { + const [userStore] = useUnit([$userStore]); + + if (!userStore) return null; + + const [settingsStore] = useUnit([$settingsStore]); + + return null; +}; +/* eslint-enable effector/prefer-single-binding */ +``` + +Note that even in these cases, consider refactoring to a single `useUnit` call for better performance. + +## References + +- [useUnit API documentation](https://effector.dev/en/api/effector-react/useunit/) +- [Effector React hooks best practices](https://effector.dev/en/api/effector-react/) diff --git a/src/rules/prefer-single-binding/prefer-single-binding.test.ts b/src/rules/prefer-single-binding/prefer-single-binding.test.ts new file mode 100644 index 0000000..44cc4e1 --- /dev/null +++ b/src/rules/prefer-single-binding/prefer-single-binding.test.ts @@ -0,0 +1,820 @@ +import { RuleTester } from "@typescript-eslint/rule-tester" +import { parser } from "typescript-eslint" + +import { tsx } from "@/shared/tag" + +import rule from "./prefer-single-binding" + +const ruleTester = new RuleTester({ + languageOptions: { + parser, + parserOptions: { + projectService: { allowDefaultProject: ["*.tsx"], defaultProject: "tsconfig.fixture.json" }, + ecmaFeatures: { jsx: true }, + }, + }, +}) + +ruleTester.run("prefer-single-binding", rule, { + valid: [ + { + name: "nocheck: @@unitShape call is ignored", + code: tsx` + import { useUnit } from "effector-react" + declare const CartModel: { + "@@unitShape": () => { products: string[] } + } + const Component = () => { + const { products } = useUnit(CartModel) + return null + } + `, + }, + { + name: "nocheck: @@unitShape call alongside destructuring call is ignored", + code: tsx` + import { useUnit } from "effector-react" + import { type Store } from "effector" + declare const $store: Store + declare const CartModel: { + "@@unitShape": () => { products: string[] } + } + const Component = () => { + const { products } = useUnit(CartModel) + const [value] = useUnit([$store]) + return null + } + `, + }, + { + name: "alias: single useUnit call with alias", + code: tsx` + import { useUnit as useUnitEffector } from "effector-react" + const Component = () => { + const [store, event] = useUnitEffector([$store, event]) + return null + } + `, + }, + { + name: "nocheck: single plain call without other useUnit calls is ignored", + code: tsx` + import { useUnit } from "effector-react" + import { type Store } from "effector" + declare const CartModel: { $cartProducts: Store } + const Component = () => { + const products = useUnit(CartModel.$cartProducts) + return null + } + `, + }, + { + name: "nocheck: plain calls with separation allow are ignored", + code: tsx` + import { useUnit } from "effector-react" + import { type Store, type EventCallable } from "effector" + declare const CartModel: { $cartProducts: Store } + declare const AddToCartModel: { cartProductTotalRemoved: EventCallable } + const Component = () => { + const products = useUnit(CartModel.$cartProducts) + const onProductRemoveFromCart = useUnit(AddToCartModel.cartProductTotalRemoved) + return null + } + `, + options: [{ separation: "allow" }], + }, + { + name: "nocheck: plain calls with separation enforce are ignored", + code: tsx` + import { useUnit } from "effector-react" + import { type Store, type EventCallable } from "effector" + declare const CartModel: { $cartProducts: Store } + declare const AddToCartModel: { cartProductTotalRemoved: EventCallable } + const Component = () => { + const products = useUnit(CartModel.$cartProducts) + const onProductRemoveFromCart = useUnit(AddToCartModel.cartProductTotalRemoved) + return null + } + `, + options: [{ separation: "enforce" }], + }, + { + name: "array: single useUnit call", + code: tsx` + import { useUnit } from "effector-react" + const Component = () => { + const [store, event] = useUnit([$store, event]) + return null + } + `, + }, + { + name: "object: single useUnit call", + code: tsx` + import { useUnit } from "effector-react" + const Component = () => { + const { store, event } = useUnit({ store: $store, event: event }) + return null + } + `, + }, + { + name: "nocheck: useUnit outside of component", + code: tsx` + import { useUnit } from "effector-react" + const store = useUnit([$store]) + const event = useUnit([event]) + `, + }, + { + name: "separation enforce: already separated stores and events", + code: tsx` + import { useUnit } from "effector-react" + import { type Store, type EventCallable } from "effector" + declare const $store: Store + declare const event: EventCallable + const Component = () => { + const [store] = useUnit([$store]) + const [ev] = useUnit([event]) + return null + } + `, + options: [{ separation: "enforce" }], + }, + { + name: "separation allow: stores and events in separate calls", + code: tsx` + import { useUnit } from "effector-react" + import { type Store, type EventCallable } from "effector" + declare const HelpFormModel: { + $isFormSent: Store + sentFormChanged: EventCallable + submitHelpForm: EventCallable + } + const Component = () => { + const [isFormSent] = useUnit([HelpFormModel.$isFormSent]) + const [sent, submit] = useUnit([HelpFormModel.sentFormChanged, HelpFormModel.submitHelpForm]) + return null + } + `, + options: [{ separation: "allow" }], + }, + { + name: "separation allow: effects in separate call", + code: tsx` + import { useUnit } from "effector-react" + import { type Store, type Effect } from "effector" + declare const $store: Store + declare const fx: Effect + const Component = () => { + const [store] = useUnit([$store]) + const [run] = useUnit([fx]) + return null + } + `, + options: [{ separation: "allow" }], + }, + ], + invalid: [ + { + name: "mixed array and object forms are reported without suggestion", + code: tsx` + import { useUnit } from "effector-react" + import { type Store } from "effector" + declare const $store1: Store + declare const $store2: Store + const Component = () => { + const [a] = useUnit([$store1]) + const { b } = useUnit({ b: $store2 }) + return null + } + `, + errors: [{ messageId: "multipleUseUnit", suggestions: [] }], + }, + { + name: "mixed array and object forms are reported without suggestion", + code: tsx` + import { useUnit } from "effector-react" + import { type Store, type EventCallable } from "effector" + declare const $store: Store + declare const event: EventCallable + const Component = () => { + const [value] = useUnit([$store]) + const { handler } = useUnit({ handler: event }) + return null + } + `, + errors: [{ messageId: "multipleUseUnit", suggestions: [] }], + }, + { + name: "alias: multiple useUnit calls with alias", + code: tsx` + import { useUnit as useUnitEffector } from "effector-react" + const Component = () => { + const [store] = useUnitEffector([$store]) + const [event] = useUnitEffector([event]) + return null + } + `, + errors: [ + { + messageId: "multipleUseUnit", + suggestions: [ + { + messageId: "multipleUseUnit", + output: tsx` + import { useUnit as useUnitEffector } from "effector-react" + const Component = () => { + const [store, event] = useUnitEffector([$store, event]) + return null + } + `, + }, + ], + }, + ], + }, + { + name: "plain calls alongside destructured calls are reported", + code: tsx` + import { useUnit } from "effector-react" + import { type Store, type EventCallable } from "effector" + declare const CartModel: { $cartProducts: Store } + declare const AddToCartModel: { cartProductTotalRemoved: EventCallable } + const Component = () => { + const products = useUnit(CartModel.$cartProducts) + const onProductRemoveFromCart = useUnit(AddToCartModel.cartProductTotalRemoved) + const [something] = useUnit([$something]) + return null + } + `, + errors: [{ messageId: "singleUnitWithoutDestructuring" }, { messageId: "singleUnitWithoutDestructuring" }], + }, + { + name: "single plain call alongside destructured call is reported", + code: tsx` + import { useUnit } from "effector-react" + import { type Store, type EventCallable } from "effector" + declare const CartModel: { $cartProducts: Store } + const Component = () => { + const products = useUnit(CartModel.$cartProducts) + const [something] = useUnit([$something]) + return null + } + `, + errors: [{ messageId: "singleUnitWithoutDestructuring" }], + }, + { + name: "multiple plain calls without destructured calls are reported", + code: tsx` + import { useUnit } from "effector-react" + import { type Store, type EventCallable } from "effector" + declare const CartModel: { $cartProducts: Store } + declare const AddToCartModel: { cartProductTotalRemoved: EventCallable } + const Component = () => { + const products = useUnit(CartModel.$cartProducts) + const onProductRemoveFromCart = useUnit(AddToCartModel.cartProductTotalRemoved) + return null + } + `, + errors: [{ messageId: "multipleUseUnit" }, { messageId: "multipleUseUnit" }], + }, + { + name: "plain array call alongside plain scalar call are both reported", + code: tsx` + import { useUnit } from "effector-react" + import { type Store, type EventCallable } from "effector" + declare const CartModel: { $cartProducts: Store } + declare const AddToCartModel: { cartProductTotalRemoved: EventCallable } + const Component = () => { + const products = useUnit([CartModel.$cartProducts]) + const onProductRemoveFromCart = useUnit(AddToCartModel.cartProductTotalRemoved) + return null + } + `, + errors: [{ messageId: "multipleUseUnit" }, { messageId: "multipleUseUnit" }], + }, + { + name: "array: two useUnit calls", + code: tsx` + import { useUnit } from "effector-react" + const Component = () => { + const [store] = useUnit([$store]) + const [event] = useUnit([event]) + return null + } + `, + errors: [ + { + messageId: "multipleUseUnit", + suggestions: [ + { + messageId: "multipleUseUnit", + output: tsx` + import { useUnit } from "effector-react" + const Component = () => { + const [store, event] = useUnit([$store, event]) + return null + } + `, + }, + ], + }, + ], + }, + { + name: "array: three useUnit calls", + code: tsx` + import { useUnit } from "effector-react" + const Component = () => { + const [store] = useUnit([$store]) + const [event] = useUnit([event]) + const [another] = useUnit([$another]) + return null + } + `, + errors: [ + { + messageId: "multipleUseUnit", + suggestions: [ + { + messageId: "multipleUseUnit", + output: tsx` + import { useUnit } from "effector-react" + const Component = () => { + const [store, event, another] = useUnit([$store, event, $another]) + return null + } + `, + }, + ], + }, + { + messageId: "multipleUseUnit", + suggestions: [ + { + messageId: "multipleUseUnit", + output: tsx` + import { useUnit } from "effector-react" + const Component = () => { + const [store, event, another] = useUnit([$store, event, $another]) + return null + } + `, + }, + ], + }, + ], + }, + { + name: "object: two useUnit calls", + code: tsx` + import { useUnit } from "effector-react" + const Component = () => { + const { store } = useUnit({ store: $store }) + const { event } = useUnit({ event: event }) + return null + } + `, + errors: [ + { + messageId: "multipleUseUnit", + suggestions: [ + { + messageId: "multipleUseUnit", + output: tsx` + import { useUnit } from "effector-react" + const Component = () => { + const { store, event } = useUnit({ store: $store, event: event }) + return null + } + `, + }, + ], + }, + ], + }, + { + name: "array: multiple useUnit calls with multiple elements each", + code: tsx` + import { useUnit } from "effector-react" + const Component = () => { + const [store1, store2] = useUnit([$store1, $store2]) + const [event1, event2] = useUnit([event1, event2]) + return null + } + `, + errors: [ + { + messageId: "multipleUseUnit", + suggestions: [ + { + messageId: "multipleUseUnit", + output: tsx` + import { useUnit } from "effector-react" + const Component = () => { + const [store1, store2, event1, event2] = useUnit([$store1, $store2, event1, event2]) + return null + } + `, + }, + ], + }, + ], + }, + { + name: "array: all calls merged regardless of type", + code: tsx` + import { useUnit } from "effector-react" + const Component = () => { + const [isFormSent] = useUnit([HelpFormModel.$isFormSent]) + const [sent] = useUnit([HelpFormModel.sentFormChanged]) + const [submit] = useUnit([HelpFormModel.submitHelpForm]) + return null + } + `, + errors: [ + { + messageId: "multipleUseUnit", + suggestions: [ + { + messageId: "multipleUseUnit", + /* INFO: fixer produces a single-line output intentionally; prettier will reformat it in real projects */ + // prettier-ignore + output: tsx` + import { useUnit } from "effector-react" + const Component = () => { + const [isFormSent, sent, submit] = useUnit([HelpFormModel.$isFormSent, HelpFormModel.sentFormChanged, HelpFormModel.submitHelpForm]) + return null + } + `, + }, + ], + }, + { + messageId: "multipleUseUnit", + suggestions: [ + { + messageId: "multipleUseUnit", + /* INFO: fixer produces a single-line output intentionally; prettier will reformat it in real projects */ + // prettier-ignore + output: tsx` + import { useUnit } from "effector-react" + const Component = () => { + const [isFormSent, sent, submit] = useUnit([HelpFormModel.$isFormSent, HelpFormModel.sentFormChanged, HelpFormModel.submitHelpForm]) + return null + } + `, + }, + ], + }, + ], + }, + { + name: "separation allow: multiple calls of same type are merged", + code: tsx` + import { useUnit } from "effector-react" + import { type Store, type EventCallable } from "effector" + declare const HelpFormModel: { + $isFormSent: Store + sentFormChanged: EventCallable + submitHelpForm: EventCallable + } + const Component = () => { + const [isFormSent] = useUnit([HelpFormModel.$isFormSent]) + const [sent] = useUnit([HelpFormModel.sentFormChanged]) + const [submit] = useUnit([HelpFormModel.submitHelpForm]) + return null + } + `, + options: [{ separation: "allow" }], + errors: [ + { + messageId: "multipleUseUnit", + suggestions: [ + { + messageId: "multipleUseUnit", + output: tsx` + import { useUnit } from "effector-react" + import { type Store, type EventCallable } from "effector" + declare const HelpFormModel: { + $isFormSent: Store + sentFormChanged: EventCallable + submitHelpForm: EventCallable + } + const Component = () => { + const [isFormSent] = useUnit([HelpFormModel.$isFormSent]) + const [sent, submit] = useUnit([HelpFormModel.sentFormChanged, HelpFormModel.submitHelpForm]) + return null + } + `, + }, + ], + }, + ], + }, + { + name: "separation allow: multiple store calls and multiple event calls are each merged", + code: tsx` + import { useUnit } from "effector-react" + import { type Store, type EventCallable } from "effector" + declare const HelpFormModel: { + $isFormSent: Store + $isLoading: Store + submitHelpForm: EventCallable + } + const Component = () => { + const [isFormSent] = useUnit([HelpFormModel.$isFormSent]) + const [isLoading] = useUnit([HelpFormModel.$isLoading]) + const [submit] = useUnit([HelpFormModel.submitHelpForm]) + return null + } + `, + options: [{ separation: "allow" }], + errors: [ + { + messageId: "multipleUseUnit", + suggestions: [ + { + messageId: "multipleUseUnit", + output: tsx` + import { useUnit } from "effector-react" + import { type Store, type EventCallable } from "effector" + declare const HelpFormModel: { + $isFormSent: Store + $isLoading: Store + submitHelpForm: EventCallable + } + const Component = () => { + const [isFormSent, isLoading] = useUnit([HelpFormModel.$isFormSent, HelpFormModel.$isLoading]) + const [submit] = useUnit([HelpFormModel.submitHelpForm]) + return null + } + `, + }, + ], + }, + ], + }, + { + name: "separation allow: mixed naming patterns grouped correctly", + code: tsx` + import { useUnit } from "effector-react" + import { type Store, type EventCallable } from "effector" + declare const Model: { + $isVisible: Store + $hasError: Store + onClick: EventCallable + handleSubmit: EventCallable + } + const Component = () => { + const [isVisible] = useUnit([Model.$isVisible]) + const [hasError] = useUnit([Model.$hasError]) + const [onClick] = useUnit([Model.onClick]) + const [handleSubmit] = useUnit([Model.handleSubmit]) + return null + } + `, + options: [{ separation: "allow" }], + errors: [ + { + messageId: "multipleUseUnit", + suggestions: [ + { + messageId: "multipleUseUnit", + output: tsx` + import { useUnit } from "effector-react" + import { type Store, type EventCallable } from "effector" + declare const Model: { + $isVisible: Store + $hasError: Store + onClick: EventCallable + handleSubmit: EventCallable + } + const Component = () => { + const [isVisible, hasError] = useUnit([Model.$isVisible, Model.$hasError]) + const [onClick] = useUnit([Model.onClick]) + const [handleSubmit] = useUnit([Model.handleSubmit]) + return null + } + `, + }, + ], + }, + { + messageId: "multipleUseUnit", + suggestions: [ + { + messageId: "multipleUseUnit", + output: tsx` + import { useUnit } from "effector-react" + import { type Store, type EventCallable } from "effector" + declare const Model: { + $isVisible: Store + $hasError: Store + onClick: EventCallable + handleSubmit: EventCallable + } + const Component = () => { + const [isVisible] = useUnit([Model.$isVisible]) + const [hasError] = useUnit([Model.$hasError]) + const [onClick, handleSubmit] = useUnit([Model.onClick, Model.handleSubmit]) + return null + } + `, + }, + ], + }, + ], + }, + { + name: "separation enforce: array: mixed stores and events", + code: tsx` + import { useUnit } from "effector-react" + import { type Store, type EventCallable } from "effector" + declare const $store: Store + declare const event: EventCallable + const Component = () => { + const [value, setValue] = useUnit([$store, event]) + return null + } + `, + options: [{ separation: "enforce" }], + errors: [ + { + messageId: "mixedStoresAndEvents", + suggestions: [ + { + messageId: "mixedStoresAndEvents", + output: tsx` + import { useUnit } from "effector-react" + import { type Store, type EventCallable } from "effector" + declare const $store: Store + declare const event: EventCallable + const Component = () => { + const [value] = useUnit([$store]) + const [setValue] = useUnit([event]) + return null + } + `, + }, + ], + }, + ], + }, + { + name: "separation enforce: array: mixed stores and effects", + code: tsx` + import { useUnit } from "effector-react" + import { type Store, type Effect } from "effector" + declare const $store: Store + declare const fx: Effect + const Component = () => { + const [value, run] = useUnit([$store, fx]) + return null + } + `, + options: [{ separation: "enforce" }], + errors: [ + { + messageId: "mixedStoresAndEvents", + suggestions: [ + { + messageId: "mixedStoresAndEvents", + output: tsx` + import { useUnit } from "effector-react" + import { type Store, type Effect } from "effector" + declare const $store: Store + declare const fx: Effect + const Component = () => { + const [value] = useUnit([$store]) + const [run] = useUnit([fx]) + return null + } + `, + }, + ], + }, + ], + }, + { + name: "separation enforce: array: mixed stores and events with multiple items", + code: tsx` + import { useUnit } from "effector-react" + import { type Store, type EventCallable } from "effector" + declare const $store1: Store + declare const $store2: Store + declare const event1: EventCallable + declare const event2: EventCallable + const Component = () => { + const [value1, value2, handler1, handler2] = useUnit([$store1, $store2, event1, event2]) + return null + } + `, + options: [{ separation: "enforce" }], + errors: [ + { + messageId: "mixedStoresAndEvents", + suggestions: [ + { + messageId: "mixedStoresAndEvents", + output: tsx` + import { useUnit } from "effector-react" + import { type Store, type EventCallable } from "effector" + declare const $store1: Store + declare const $store2: Store + declare const event1: EventCallable + declare const event2: EventCallable + const Component = () => { + const [value1, value2] = useUnit([$store1, $store2]) + const [handler1, handler2] = useUnit([event1, event2]) + return null + } + `, + }, + ], + }, + ], + }, + { + name: "separation enforce: object: mixed stores and events", + code: tsx` + import { useUnit } from "effector-react" + import { type Store, type EventCallable } from "effector" + declare const $store: Store + declare const event: EventCallable + const Component = () => { + const { value, setValue } = useUnit({ value: $store, setValue: event }) + return null + } + `, + options: [{ separation: "enforce" }], + errors: [ + { + messageId: "mixedStoresAndEvents", + suggestions: [ + { + messageId: "mixedStoresAndEvents", + output: tsx` + import { useUnit } from "effector-react" + import { type Store, type EventCallable } from "effector" + declare const $store: Store + declare const event: EventCallable + const Component = () => { + const { value } = useUnit({ value: $store }) + const { setValue } = useUnit({ setValue: event }) + return null + } + `, + }, + ], + }, + ], + }, + { + name: "separation enforce: array: real model example", + code: tsx` + import { useUnit } from "effector-react" + import { type Store, type EventCallable } from "effector" + declare const FormModel: { + $isFormSent: Store + submitForm: EventCallable + resetForm: EventCallable + } + const Component = () => { + const [isFormSent, submit, reset] = useUnit([ + FormModel.$isFormSent, + FormModel.submitForm, + FormModel.resetForm, + ]) + return null + } + `, + options: [{ separation: "enforce" }], + errors: [ + { + messageId: "mixedStoresAndEvents", + suggestions: [ + { + messageId: "mixedStoresAndEvents", + output: tsx` + import { useUnit } from "effector-react" + import { type Store, type EventCallable } from "effector" + declare const FormModel: { + $isFormSent: Store + submitForm: EventCallable + resetForm: EventCallable + } + const Component = () => { + const [isFormSent] = useUnit([FormModel.$isFormSent]) + const [submit, reset] = useUnit([FormModel.submitForm, FormModel.resetForm]) + return null + } + `, + }, + ], + }, + ], + }, + ], +}) diff --git a/src/rules/prefer-single-binding/prefer-single-binding.ts b/src/rules/prefer-single-binding/prefer-single-binding.ts new file mode 100644 index 0000000..4aa8abe --- /dev/null +++ b/src/rules/prefer-single-binding/prefer-single-binding.ts @@ -0,0 +1,508 @@ +import { ESLintUtils, type TSESTree as Node, AST_NODE_TYPES as NodeType } from "@typescript-eslint/utils" +import type { RuleContext, RuleFix, RuleFixer } from "@typescript-eslint/utils/ts-eslint" + +import { createRule } from "@/shared/create" +import { isType } from "@/shared/is" +import { PACKAGE_NAME } from "@/shared/package" + +type MessageIds = "multipleUseUnit" | "mixedStoresAndEvents" | "singleUnitWithoutDestructuring" +type Options = [{ separation?: "forbid" | "allow" | "enforce" }?] +type Context = RuleContext +type Fixer = RuleFixer +type ParserServices = ReturnType +type GetNodeType = (node: Node.Expression | null | undefined) => UnitType + +type UseUnitCall = { + statement: Node.VariableDeclaration + declarator: Node.VariableDeclarator + init: Node.CallExpression + id: Node.VariableDeclarator["id"] +} + +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 +} + +type PlainCall = { + init: Node.CallExpression +} + +type UnitType = "store" | "event" | "effect" | "unknown" + +type UnitBinding = { + call: UseUnitCall + unitNode: Node.Expression + identNode: Node.ArrayPattern["elements"][number] + propNode: Node.Property | null +} + +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", + }, + plain: "VariableDeclarator[id.type=Identifier]", +} as const + +function getTypeFromChecker(node: Node.Expression, services: ParserServices): UnitType { + try { + const checker = services.program?.getTypeChecker() + const tsNode = services.esTreeNodeToTSNodeMap.get(node) + if (!tsNode) return "unknown" + const type = checker?.getTypeAtLocation(tsNode) + + if (!type || !services.program) return "unknown" + + if (isType.store(type, services.program)) return "store" + if (isType.event(type, services.program)) return "event" + if (isType.effect(type, services.program)) return "effect" + + return "unknown" + } catch { + return "unknown" + } +} + +function collectBindings(calls: UseUnitCall[]): UnitBinding[] { + const bindings: UnitBinding[] = [] + + for (const call of calls) { + const argument = call.init.arguments[0] + if (!argument || argument.type === NodeType.SpreadElement) continue + + if (argument.type === NodeType.ArrayExpression && call.id.type === NodeType.ArrayPattern) { + const elements = argument.elements + const destructured = call.id.elements + for (let i = 0; i < elements.length; i++) { + const el = elements[i] + if (!el || el.type === NodeType.SpreadElement) continue + bindings.push({ + call, + unitNode: el, + identNode: destructured[i] ?? null, + propNode: null, + }) + } + } + + if (argument.type === NodeType.ObjectExpression && call.id.type === NodeType.ObjectPattern) { + for (const prop of argument.properties) { + if (prop.type !== NodeType.Property) continue + bindings.push({ + call, + unitNode: prop.value as Node.Expression, + identNode: null, + propNode: prop, + }) + } + } + } + + return bindings +} + +function groupBindingsByType(bindings: UnitBinding[], getNodeType: GetNodeType): Record { + const groups: Record = { store: [], event: [], effect: [], unknown: [] } + for (const binding of bindings) { + const type = getNodeType(binding.unitNode) + groups[type].push(binding) + } + return groups +} + +function hasMixedTypes(call: UseUnitCall, getNodeType: GetNodeType): boolean { + const argument = call.init.arguments[0] + if (!argument || argument.type === NodeType.SpreadElement) return false + + const types = new Set() + + if (argument.type === NodeType.ArrayExpression) { + for (const el of argument.elements) { + if (!el || el.type === NodeType.SpreadElement) continue + const t = getNodeType(el) + if (t !== "unknown") types.add(t) + } + } + + if (argument.type === NodeType.ObjectExpression) { + for (const prop of argument.properties) { + if (prop.type !== NodeType.Property) continue + const t = getNodeType(prop.value as Node.Expression) + if (t !== "unknown") types.add(t) + } + } + + return types.size > 1 +} + +function removeStatement( + fixer: Fixer, + sourceCode: Context["sourceCode"], + statement: Node.VariableDeclaration, +): RuleFix { + const range = statement.range + let startIndex = range[0] + const lineStart = sourceCode.text.lastIndexOf("\n", startIndex - 1) + 1 + const textBefore = sourceCode.text.slice(lineStart, startIndex) + + if (/^\s*$/.test(textBefore)) startIndex = lineStart + + const endIndex = range[1] + const nextChar = sourceCode.text[endIndex] + const removeEnd = nextChar === "\n" || nextChar === "\r" ? endIndex + 1 : endIndex + + return fixer.removeRange([startIndex, removeEnd]) +} + +function generateMergeFix(fixer: Fixer, calls: UseUnitCall[], context: Context): RuleFix[] | null { + const sourceCode = context.sourceCode + const firstCall = calls[0] + const firstArg = firstCall?.init.arguments[0] + if (!firstArg || firstArg.type === NodeType.SpreadElement) return null + + const calleeName = firstCall.init.callee.type === NodeType.Identifier ? firstCall.init.callee.name : "useUnit" + + const isArrayForm = firstArg.type === NodeType.ArrayExpression + const isObjectForm = firstArg.type === NodeType.ObjectExpression + + const allSameForm = calls.every((call) => { + const arg = call.init.arguments[0] + if (!arg || arg.type === NodeType.SpreadElement) return false + return isArrayForm ? arg.type === NodeType.ArrayExpression : arg.type === NodeType.ObjectExpression + }) + + if (!allSameForm) return null + + const fixes: RuleFix[] = [] + const [first, ...rest] = calls + if (!first) return null + + if (isArrayForm) { + const allElements: string[] = [] + const allDestructured: string[] = [] + + for (const call of calls) { + const arg = call.init.arguments[0] + if (arg?.type === NodeType.ArrayExpression) { + for (const el of arg.elements) { + if (el) allElements.push(sourceCode.getText(el)) + } + } + if (call.id.type === NodeType.ArrayPattern) { + for (const el of call.id.elements) { + if (el) allDestructured.push(sourceCode.getText(el)) + } + } + } + + fixes.push( + fixer.replaceText( + first.statement, + `const [${allDestructured.join(", ")}] = ${calleeName}([${allElements.join(", ")}])`, + ), + ) + } else if (isObjectForm) { + const allProperties: string[] = [] + const allDestructured: string[] = [] + + for (const call of calls) { + const arg = call.init.arguments[0] + if (arg?.type === NodeType.ObjectExpression) { + for (const prop of arg.properties) allProperties.push(sourceCode.getText(prop)) + } + if (call.id.type === NodeType.ObjectPattern) { + for (const prop of call.id.properties) allDestructured.push(sourceCode.getText(prop)) + } + } + + fixes.push( + fixer.replaceText( + first.statement, + `const { ${allDestructured.join(", ")} } = ${calleeName}({ ${allProperties.join(", ")} })`, + ), + ) + } + + for (const call of rest) fixes.push(removeStatement(fixer, sourceCode, call.statement)) + + return fixes +} + +function generateSeparationFix(fixer: Fixer, call: UseUnitCall, context: Context, getNodeType: GetNodeType): RuleFix[] { + const sourceCode = context.sourceCode + const argument = call.init.arguments[0] + if (!argument || argument.type === NodeType.SpreadElement) return [] + + const calleeName = call.init.callee.type === NodeType.Identifier ? call.init.callee.name : "useUnit" + + const range = call.statement.range + const lineStart = sourceCode.text.lastIndexOf("\n", range[0] - 1) + 1 + const indent = sourceCode.text.slice(lineStart, range[0]) + + if (argument.type === NodeType.ObjectExpression) { + const properties = argument.properties.filter((p): p is Node.Property => p.type === NodeType.Property) + + const byType = { + store: properties.filter((p) => getNodeType(p.value as Node.Expression) === "store"), + event: properties.filter((p) => getNodeType(p.value as Node.Expression) === "event"), + effect: properties.filter((p) => getNodeType(p.value as Node.Expression) === "effect"), + } + + const toKeyName = (p: Node.Property) => + p.key.type === NodeType.Identifier ? p.key.name : sourceCode.getText(p.key) + + const lines = Object.values(byType) + .filter((group) => group.length > 0) + .map( + (group) => + `const { ${group.map(toKeyName).join(", ")} } = ${calleeName}({ ${group.map((p) => sourceCode.getText(p)).join(", ")} })`, + ) + + return [fixer.replaceText(call.statement, lines.join(`\n${indent}`))] + } + + if (argument.type === NodeType.ArrayExpression && call.id.type === NodeType.ArrayPattern) { + const elements = argument.elements + const destructured = call.id.elements + + const indexed = elements + .map((el, i) => ({ el, i })) + .filter((x): x is { el: Node.Expression; i: number } => x.el !== null && x.el.type !== NodeType.SpreadElement) + + const byType = { + store: indexed.filter(({ el }) => getNodeType(el) === "store"), + event: indexed.filter(({ el }) => getNodeType(el) === "event"), + effect: indexed.filter(({ el }) => getNodeType(el) === "effect"), + } + + const getName = (i: number) => { + const el = destructured[i] + return el ? sourceCode.getText(el) : null + } + + const lines = Object.values(byType) + .filter((group) => group.length > 0) + .map((group) => { + const names = group.map(({ i }) => getName(i)).filter((x): x is string => x !== null) + const units = group.map(({ el }) => sourceCode.getText(el)) + return `const [${names.join(", ")}] = ${calleeName}([${units.join(", ")}])` + }) + + return [fixer.replaceText(call.statement, lines.join(`\n${indent}`))] + } + + return [] +} + +function reportMultipleCalls(context: Context, calls: UseUnitCall[]): void { + const [, ...rest] = calls + const captured = [...calls] // явно захватываем копию чтобы замыкание не мутировало + for (const call of rest) { + context.report({ + node: call.init, + messageId: "multipleUseUnit", + suggest: [ + { + messageId: "multipleUseUnit", + fix: (fixer) => generateMergeFix(fixer, captured, context), + }, + ], + }) + } +} + +export default createRule({ + name: "prefer-single-binding", + meta: { + type: "suggestion", + hasSuggestions: true, + docs: { + description: + "Recommend using a single useUnit call instead of multiple. Non-destructuring calls, @@unitShape and mixed array/object forms are not handled.", + }, + messages: { + multipleUseUnit: + "Multiple useUnit calls detected. Consider combining them into a single call for better performance.", + mixedStoresAndEvents: + "useUnit call contains both stores and events. Consider separating them into different calls.", + singleUnitWithoutDestructuring: + "useUnit called without destructuring alongside other useUnit calls. Consider combining all useUnit calls into a single destructured call.", + }, + schema: [ + { + type: "object", + properties: { + separation: { + type: "string", + enum: ["forbid", "allow", "enforce"], + default: "forbid", + }, + }, + additionalProperties: false, + }, + ], + }, + defaultOptions: [], + create(context) { + const importedAs = new Set() + const separation = context.options[0]?.separation ?? "forbid" + const useUnitCallsStack: UseUnitCall[][] = [] + const plainCallsStack: PlainCall[][] = [] + + const services = ESLintUtils.getParserServices(context) + + const getNodeType: GetNodeType = (node) => { + if (!node) return "unknown" + return getTypeFromChecker(node, services) + } + + const onFunctionEnter = (): void => { + useUnitCallsStack.push([]) + plainCallsStack.push([]) + } + + const onFunctionExit = (): void => { + const plainCalls = plainCallsStack.pop() + const useUnitCalls = useUnitCallsStack.pop() + if (!useUnitCalls) return + + if (separation === "forbid" && plainCalls && plainCalls.length > 0 && useUnitCalls.length > 0) { + for (const call of plainCalls) { + context.report({ + node: call.init, + messageId: "singleUnitWithoutDestructuring", + }) + } + } + + if (separation === "forbid" && plainCalls && plainCalls.length > 1 && useUnitCalls.length === 0) { + for (const call of plainCalls) { + context.report({ + node: call.init, + messageId: "multipleUseUnit", + }) + } + } + + if (useUnitCalls.length === 0) return + + if (separation === "enforce") { + for (const call of useUnitCalls) { + if (hasMixedTypes(call, getNodeType)) { + context.report({ + node: call.init, + messageId: "mixedStoresAndEvents", + suggest: [ + { + messageId: "mixedStoresAndEvents", + fix: (fixer) => generateSeparationFix(fixer, call, context, getNodeType), + }, + ], + }) + } + } + return + } + + if (useUnitCalls.length <= 1) return + + const getForm = (call: UseUnitCall): "array" | "object" | "unknown" => { + const arg = call.init.arguments[0] + if (!arg || arg.type === NodeType.SpreadElement) return "unknown" + if (arg.type === NodeType.ArrayExpression) return "array" + if (arg.type === NodeType.ObjectExpression) return "object" + return "unknown" + } + + if (separation === "allow") { + const bindings = collectBindings(useUnitCalls) + const groups = groupBindingsByType(bindings, getNodeType) + for (const group of Object.values(groups)) { + const groupCalls = [...new Set(group.map((b) => b.call))] + if (groupCalls.length <= 1) continue + const form = getForm(groupCalls[0]!) + const allSameForm = groupCalls.every((c) => getForm(c) === form) + if (!allSameForm) continue + reportMultipleCalls(context, groupCalls) + } + return + } + + // separation === "forbid" + if (useUnitCalls.length <= 1) return + + const form = getForm(useUnitCalls[0]!) + const allSameForm = useUnitCalls.every((c) => getForm(c) === form) + + if (allSameForm) { + reportMultipleCalls(context, useUnitCalls) + } else { + const [, ...rest] = useUnitCalls + for (const call of rest) { + context.report({ + node: call.init, + messageId: "multipleUseUnit", + }) + } + } + } + + return { + [selector.import]: (node: Node.ImportSpecifier) => void importedAs.add(node.local.name), + + "FunctionDeclaration, FunctionExpression, ArrowFunctionExpression": onFunctionEnter, + + "FunctionDeclaration:exit": onFunctionExit, + "FunctionExpression:exit": onFunctionExit, + "ArrowFunctionExpression:exit": onFunctionExit, + + [`${selector.variable.shape}:has(> ${selector.call}:has(${selector.arg.shape}))`](node: ShapeCall): void { + if (!importedAs.has(node.init.callee.name)) return + const current = useUnitCallsStack.at(-1) + if (!current) return + current.push({ statement: node.parent, declarator: node, init: node.init, id: node.id }) + }, + + [`${selector.variable.list}:has(> ${selector.call}:has(${selector.arg.list}))`](node: ListCall): void { + if (!importedAs.has(node.init.callee.name)) return + const current = useUnitCallsStack.at(-1) + if (!current) return + current.push({ statement: node.parent, declarator: node, init: node.init, id: node.id }) + }, + + [`${selector.plain}:has(> ${selector.call})`]( + node: Node.VariableDeclarator & { init: Node.CallExpression }, + ): void { + if (node.init.arguments.length !== 1) return + const arg = node.init.arguments[0] + if (!arg || arg.type === NodeType.SpreadElement) return + if (arg.type === NodeType.ArrayExpression && node.id.type === NodeType.ArrayPattern) return + if (arg.type === NodeType.ObjectExpression && node.id.type === NodeType.ObjectPattern) return + const callee = node.init.callee + if (callee.type !== NodeType.Identifier) return + if (!importedAs.has(callee.name)) return + const current = plainCallsStack.at(-1) + if (!current) return + current.push({ init: node.init }) + }, + } + }, +}) diff --git a/src/ruleset.ts b/src/ruleset.ts index 64c19dd..3bdaa2a 100644 --- a/src/ruleset.ts +++ b/src/ruleset.ts @@ -36,4 +36,8 @@ const future = { "effector/no-domain-unit-creators": "warn", } satisfies TSESLint.Linter.RulesRecord -export const ruleset = { recommended, patronum, scope, react, future } +const style = { + "effector/prefer-single-binding": "warn", +} satisfies TSESLint.Linter.RulesRecord + +export const ruleset = { recommended, patronum, scope, react, future, style }