diff --git a/src/index.ts b/src/index.ts index 21eba7d..c90a50d 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" @@ -48,6 +49,7 @@ const base = { "no-unnecessary-duplication": noUnnecessaryDuplication, "no-useless-methods": noUselessMethods, "no-watch": noWatch, + "prefer-single-binding": preferSingleBinding, "prefer-useUnit": preferUseUnit, "require-pickup-in-persist": requirePickupInPersist, "strict-effect-handlers": strictEffectHandlers, 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..1668e70 --- /dev/null +++ b/src/rules/prefer-single-binding/prefer-single-binding.md @@ -0,0 +1,400 @@ +# 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 + +Array, object and non-destructuring forms are all supported and can be merged together. +Mixed array and object forms collapse into the form of the **first** array/object call +(an all-non-destructuring group merges into the object form), and a non-destructuring +`const store = useUnit($store)` is treated as the binding `(store, $store)`. + +### Ignored calls + +The rule never reports — and never tries to combine — calls that cannot be soundly merged: + +- The `@@unitShape` protocol (used by routers, queries, etc.) and any `useUnit(arg)` whose + argument is **not** a single unit (e.g. `const products = useUnit(CartModel)` or + `const list = useUnit([$a, $b])` without destructuring). +- Calls separated by an intervening declaration that a later unit depends on, where merging + would move a unit above its definition (a temporal dead zone). For example, a unit obtained + from `useContext` between two `useUnit` calls — those calls are left untouched: + + ```tsx + const a = useUnit($a); + const $b = useContext(MyContext); // $b is declared between the calls + const b = useUnit($b); // merging would hoist $b above its declaration + ``` + +### No suggestion + +A call is still reported as redundant, but **no** auto-merge suggestion is offered when a +rewrite would not be sound: + +- the destructuring can't be losslessly rebuilt — rest elements (`[a, ...rest]`), array holes, + defaults (`{ a = 1 }`), nested/computed/duplicate keys, or an argument key that isn't destructured; +- merging would produce two variables with the same name; +- the declaration isn't a sole `const` declarator — multiple declarators + (`const a = useUnit($a), b = useUnit($b)`), `let`/`var`, or a type-annotated binding + (`const a: Foo = useUnit($a)`), all of which a merge would silently break. + +Renamed object keys are normalized to the local variable name on merge, so +`const { a: store } = useUnit({ a: $store })` is treated as `(store, $store)`. Type casts and +non-null assertions on the unit (`useUnit($a as Store)`, `useUnit($a!)`) are preserved. + +### 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 | Result | Forbids | +|---|---|---| +| `"forbid"` | exactly **one** `useUnit` call per component | every extra call | +| `"allow"` | one call **per unit type** at most; a mixed call is left as-is | only same-type duplicate calls | +| `"enforce"` | exactly **one call per unit type** | same-type duplicates **and** mixed calls | + +The three modes differ only in code generation: + +- `forbid` merges everything into a single call. +- `allow` merges calls that are entirely one type (two store-calls → one), but never touches a mixed call or splits across types — it only removes same-type duplication. +- `enforce` does what `allow` does **and** splits a mixed call into one call per type. The end state is one call per type. + +Unit types are determined using TypeScript type information. The rule requires TypeScript to be configured in your project. A call whose unit type can't be determined is never merged or split (the rule won't guess at an unknown unit). + +#### `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 merged together with the rest — this is +the most common form of accidental duplication from copy-pasting. + +```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 participate in their type's group like any other call. + +```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"` + +Each `useUnit` call must contain exactly one unit type. A mixed call is split by type, and — like `allow` — multiple calls of the same type are merged, so the component ends up with one call per type. Non-destructuring calls hold a single unit, so they are never split. + +```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..e727b6a --- /dev/null +++ b/src/rules/prefer-single-binding/prefer-single-binding.test.ts @@ -0,0 +1,1333 @@ +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: non-destructuring call with a collection argument is 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 + } + `, + }, + { + name: "nocheck: a unit acquired between calls is not merged (temporal dead zone)", + code: tsx` + import { useUnit } from "effector-react" + import { type Store } from "effector" + declare const $a: Store + declare const useScopedStore: () => Store + const Component = () => { + const a = useUnit($a) + const $b = useScopedStore() + const b = useUnit($b) + 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" }], + }, + { + name: "object: computed key is ignored (cannot be normalized)", + code: tsx` + import { useUnit } from "effector-react" + import { type Store } from "effector" + declare const $a: Store + declare const key: string + const Component = () => { + const { [key]: a } = useUnit({ [key]: $a }) + return null + } + `, + }, + { + name: "allow: a call with an undetectable unit type is left alone", + code: tsx` + import { useUnit } from "effector-react" + import { type Store } from "effector" + declare const $a: Store + declare const whatever: unknown + const Component = () => { + const [a] = useUnit([$a]) + const [b] = useUnit([whatever as never]) + return null + } + `, + options: [{ separation: "allow" }], + }, + ], + invalid: [ + { + name: "mixed forms: array first merges into array form", + 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: [ + { + messageId: "multipleUseUnit", + output: tsx` + import { useUnit } from "effector-react" + import { type Store } from "effector" + declare const $store1: Store + declare const $store2: Store + const Component = () => { + const [a, b] = useUnit([$store1, $store2]) + return null + } + `, + }, + ], + }, + ], + }, + { + name: "mixed forms: object first merges into object form", + code: tsx` + import { useUnit } from "effector-react" + import { type Store } from "effector" + declare const $store1: Store + declare const $store2: Store + const Component = () => { + const { b } = useUnit({ b: $store2 }) + const [a] = useUnit([$store1]) + return null + } + `, + errors: [ + { + messageId: "multipleUseUnit", + suggestions: [ + { + messageId: "multipleUseUnit", + output: tsx` + import { useUnit } from "effector-react" + import { type Store } from "effector" + declare const $store1: Store + declare const $store2: Store + const Component = () => { + const { b, a } = useUnit({ b: $store2, a: $store1 }) + return null + } + `, + }, + ], + }, + ], + }, + { + name: "mixed forms: array first, object with renamed key", + 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: onClick } = useUnit({ handler: event }) + return null + } + `, + errors: [ + { + messageId: "multipleUseUnit", + suggestions: [ + { + messageId: "multipleUseUnit", + 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, onClick] = useUnit([$store, event]) + return null + } + `, + }, + ], + }, + ], + }, + { + name: "mixed forms: rest element in destructuring is 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, ...others] = useUnit([$store1]) + const { b } = useUnit({ b: $store2 }) + return null + } + `, + errors: [{ messageId: "multipleUseUnit", suggestions: [] }], + }, + { + name: "object: renamed keys are normalized to the local variable name", + code: tsx` + import { useUnit } from "effector-react" + import { type Store, type EventCallable } from "effector" + declare const $store: Store + declare const submitted: EventCallable + const Component = () => { + const { a: store } = useUnit({ a: $store }) + const { b: onSubmit } = useUnit({ b: submitted }) + return null + } + `, + errors: [ + { + messageId: "multipleUseUnit", + suggestions: [ + { + messageId: "multipleUseUnit", + output: tsx` + import { useUnit } from "effector-react" + import { type Store, type EventCallable } from "effector" + declare const $store: Store + declare const submitted: EventCallable + const Component = () => { + const { store, onSubmit } = useUnit({ store: $store, onSubmit: submitted }) + return null + } + `, + }, + ], + }, + ], + }, + { + name: "duplicate local names across block scopes are reported without suggestion", + code: tsx` + import { useUnit } from "effector-react" + import { type Store } from "effector" + declare const $a: Store + declare const $b: Store + declare const cond: boolean + const Component = () => { + const [value] = useUnit([$a]) + if (cond) { + const [value] = useUnit([$b]) + } + 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([eventUnit]) + return null + } + `, + errors: [ + { + messageId: "multipleUseUnit", + suggestions: [ + { + messageId: "multipleUseUnit", + output: tsx` + import { useUnit as useUnitEffector } from "effector-react" + const Component = () => { + const [store, event] = useUnitEffector([$store, eventUnit]) + return null + } + `, + }, + ], + }, + ], + }, + { + name: "plain calls alongside destructured calls are merged", + code: tsx` + import { useUnit } from "effector-react" + import { type Store, type EventCallable } from "effector" + declare const CartModel: { $cartProducts: Store } + declare const AddToCartModel: { cartProductTotalRemoved: EventCallable } + declare const $something: Store + const Component = () => { + const products = useUnit(CartModel.$cartProducts) + const onProductRemoveFromCart = useUnit(AddToCartModel.cartProductTotalRemoved) + const [something] = useUnit([$something]) + 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" + import { type Store, type EventCallable } from "effector" + declare const CartModel: { $cartProducts: Store } + declare const AddToCartModel: { cartProductTotalRemoved: EventCallable } + declare const $something: Store + const Component = () => { + const [products, onProductRemoveFromCart, something] = useUnit([CartModel.$cartProducts, AddToCartModel.cartProductTotalRemoved, $something]) + 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" + import { type Store, type EventCallable } from "effector" + declare const CartModel: { $cartProducts: Store } + declare const AddToCartModel: { cartProductTotalRemoved: EventCallable } + declare const $something: Store + const Component = () => { + const [products, onProductRemoveFromCart, something] = useUnit([CartModel.$cartProducts, AddToCartModel.cartProductTotalRemoved, $something]) + return null + } + `, + }, + ], + }, + ], + }, + { + name: "single plain call alongside destructured call is merged", + code: tsx` + import { useUnit } from "effector-react" + import { type Store } from "effector" + declare const CartModel: { $cartProducts: Store } + declare const $something: Store + const Component = () => { + const products = useUnit(CartModel.$cartProducts) + const [something] = useUnit([$something]) + return null + } + `, + errors: [ + { + messageId: "multipleUseUnit", + suggestions: [ + { + messageId: "multipleUseUnit", + output: tsx` + import { useUnit } from "effector-react" + import { type Store } from "effector" + declare const CartModel: { $cartProducts: Store } + declare const $something: Store + const Component = () => { + const [products, something] = useUnit([CartModel.$cartProducts, $something]) + return null + } + `, + }, + ], + }, + ], + }, + { + name: "multiple plain calls merge into object form", + 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", + 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" + import { type Store, type EventCallable } from "effector" + declare const CartModel: { $cartProducts: Store } + declare const AddToCartModel: { cartProductTotalRemoved: EventCallable } + const Component = () => { + const { products, onProductRemoveFromCart } = useUnit({ products: CartModel.$cartProducts, onProductRemoveFromCart: AddToCartModel.cartProductTotalRemoved }) + return null + } + `, + }, + ], + }, + ], + }, + { + name: "array: two useUnit calls", + code: tsx` + import { useUnit } from "effector-react" + const Component = () => { + const [store] = useUnit([$store]) + const [event] = useUnit([eventUnit]) + return null + } + `, + errors: [ + { + messageId: "multipleUseUnit", + suggestions: [ + { + messageId: "multipleUseUnit", + output: tsx` + import { useUnit } from "effector-react" + const Component = () => { + const [store, event] = useUnit([$store, eventUnit]) + return null + } + `, + }, + ], + }, + ], + }, + { + name: "array: three useUnit calls", + code: tsx` + import { useUnit } from "effector-react" + const Component = () => { + const [store] = useUnit([$store]) + const [event] = useUnit([eventUnit]) + 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, eventUnit, $another]) + return null + } + `, + }, + ], + }, + { + messageId: "multipleUseUnit", + suggestions: [ + { + messageId: "multipleUseUnit", + output: tsx` + import { useUnit } from "effector-react" + const Component = () => { + const [store, event, another] = useUnit([$store, eventUnit, $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: eventUnit }) + return null + } + `, + errors: [ + { + messageId: "multipleUseUnit", + suggestions: [ + { + messageId: "multipleUseUnit", + output: tsx` + import { useUnit } from "effector-react" + const Component = () => { + const { store, event } = useUnit({ store: $store, event: eventUnit }) + 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([eventUnit1, eventUnit2]) + 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, eventUnit1, eventUnit2]) + 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: same-type calls in mixed forms are merged into the first form", + 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 + } + `, + options: [{ separation: "allow" }], + errors: [ + { + messageId: "multipleUseUnit", + suggestions: [ + { + messageId: "multipleUseUnit", + output: tsx` + import { useUnit } from "effector-react" + import { type Store } from "effector" + declare const $store1: Store + declare const $store2: Store + const Component = () => { + const [a, b] = useUnit([$store1, $store2]) + 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 + } + `, + }, + ], + }, + ], + }, + { + name: "module units declared below the component are still mergeable", + code: tsx` + import { useUnit } from "effector-react" + import { createStore } from "effector" + const Component = () => { + const a = useUnit($a) + const b = useUnit($b) + return null + } + const $a = createStore(0) + const $b = createStore(1) + `, + errors: [ + { + messageId: "multipleUseUnit", + suggestions: [ + { + messageId: "multipleUseUnit", + output: tsx` + import { useUnit } from "effector-react" + import { createStore } from "effector" + const Component = () => { + const { a, b } = useUnit({ a: $a, b: $b }) + return null + } + const $a = createStore(0) + const $b = createStore(1) + `, + }, + ], + }, + ], + }, + { + name: "a unit acquired mid-body blocks the anchor but calls below it still merge", + code: tsx` + import { useUnit } from "effector-react" + import { type Store } from "effector" + declare const $a: Store + declare const useScopedStore: () => Store + const Component = () => { + const a = useUnit($a) + const $mid = useScopedStore() + const b = useUnit($mid) + const c = useUnit($mid) + return null + } + `, + errors: [ + { + messageId: "multipleUseUnit", + suggestions: [ + { + messageId: "multipleUseUnit", + output: tsx` + import { useUnit } from "effector-react" + import { type Store } from "effector" + declare const $a: Store + declare const useScopedStore: () => Store + const Component = () => { + const a = useUnit($a) + const $mid = useScopedStore() + const { b, c } = useUnit({ b: $mid, c: $mid }) + return null + } + `, + }, + ], + }, + ], + }, + { + name: "multiple declarators in one statement are reported without a suggestion", + code: tsx` + import { useUnit } from "effector-react" + import { type Store } from "effector" + declare const $a: Store + declare const $b: Store + const Component = () => { + const a = useUnit($a), + b = useUnit($b) + return null + } + `, + errors: [{ messageId: "multipleUseUnit", suggestions: [] }], + }, + { + name: "let declarations are reported without a suggestion", + code: tsx` + import { useUnit } from "effector-react" + import { type Store } from "effector" + declare const $a: Store + declare const $b: Store + const Component = () => { + let a = useUnit($a) + let b = useUnit($b) + return null + } + `, + errors: [{ messageId: "multipleUseUnit", suggestions: [] }], + }, + { + name: "a type-annotated binding is reported without a suggestion", + code: tsx` + import { useUnit } from "effector-react" + import { type Store } from "effector" + declare const $a: Store + declare const $b: Store + const Component = () => { + const a: boolean = useUnit($a) + const b = useUnit($b) + return null + } + `, + errors: [{ messageId: "multipleUseUnit", suggestions: [] }], + }, + { + name: "a duplicate destructuring key keeps both bindings (reported without a suggestion)", + code: tsx` + import { useUnit } from "effector-react" + import { type Store } from "effector" + declare const $a: Store + declare const $b: Store + const Component = () => { + const { value: x, value: y } = useUnit({ value: $a }) + const [z] = useUnit([$b]) + return null + } + `, + errors: [{ messageId: "multipleUseUnit", suggestions: [] }], + }, + { + name: "separation enforce: same-type calls are merged", + code: tsx` + import { useUnit } from "effector-react" + import { type Store } from "effector" + declare const $a: Store + declare const $b: Store + const Component = () => { + const [a] = useUnit([$a]) + const [b] = useUnit([$b]) + return null + } + `, + options: [{ separation: "enforce" }], + errors: [ + { + messageId: "multipleUseUnit", + suggestions: [ + { + messageId: "multipleUseUnit", + output: tsx` + import { useUnit } from "effector-react" + import { type Store } from "effector" + declare const $a: Store + declare const $b: Store + const Component = () => { + const [a, b] = useUnit([$a, $b]) + return null + } + `, + }, + ], + }, + ], + }, + { + name: "a default in the destructuring pattern is reported without a suggestion", + code: tsx` + import { useUnit } from "effector-react" + import { type Store } from "effector" + declare const $a: Store + declare const $b: Store + const Component = () => { + const { a = false } = useUnit({ a: $a }) + const [b] = useUnit([$b]) + return null + } + `, + errors: [{ messageId: "multipleUseUnit", suggestions: [] }], + }, + { + name: "an argument key that is not destructured is reported without a suggestion", + code: tsx` + import { useUnit } from "effector-react" + import { type Store } from "effector" + declare const $a: Store + declare const $b: Store + const Component = () => { + const { a } = useUnit({ a: $a, b: $b }) + const [c] = useUnit([$b]) + return null + } + `, + errors: [{ messageId: "multipleUseUnit", suggestions: [] }], + }, + { + name: "separation enforce: a mixed let call is reported without a 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 = () => { + let { value, run } = useUnit({ value: $store, run: event }) + return null + } + `, + options: [{ separation: "enforce" }], + errors: [{ messageId: "mixedStoresAndEvents", suggestions: [] }], + }, + ], +}) 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..fb442c9 --- /dev/null +++ b/src/rules/prefer-single-binding/prefer-single-binding.ts @@ -0,0 +1,383 @@ +import { ESLintUtils, type TSESTree as Node, AST_NODE_TYPES as NodeType } from "@typescript-eslint/utils" +import type { RuleContext, RuleFix, RuleFixer, Scope, SourceCode } 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" +type Options = [{ separation?: "forbid" | "allow" | "enforce" }?] +type Context = RuleContext +type UnitType = "store" | "event" | "effect" | "unknown" +type GetNodeType = (node: Node.Node) => UnitType +type Form = "array" | "object" | "plain" + +/** The rule's primitive: a component-scope identifier bound to the unit expression passed to `useUnit`. */ +type Binding = { unit: Node.Expression; boundTo: Node.Identifier } + +type UseUnitCall = { + /** the enclosing statement, replaced/removed when fixing */ + declaration: Node.VariableDeclaration + /** the `useUnit(...)` call, used as the report location */ + report: Node.CallExpression + callee: string + form: Form + /** every unit expression in the call (always complete, even when `normalized` is false) */ + units: Node.Expression[] + /** identifier ↔ unit pairs; complete and lossless only when `normalized` */ + bindings: Binding[] + /** `bindings` losslessly represent the destructuring (no holes, rest, defaults, computed or duplicate keys) */ + normalized: boolean + /** safe to rewrite the statement: a sole `const` declarator without a type annotation */ + fixable: boolean +} + +const selectorImport = `ImportDeclaration[source.value=${PACKAGE_NAME.react}] > ImportSpecifier[imported.name=useUnit]` +const selectorCall = "VariableDeclarator:has(> CallExpression.init[arguments.length=1][callee.type=Identifier])" + +function keyName(key: Node.Property["key"]): string | null { + if (key.type === NodeType.Identifier) return key.name + if (key.type === NodeType.Literal) return String(key.value) + return null +} + +type Analysis = { form: Form; units: Node.Expression[]; bindings: Binding[]; normalized: boolean } + +/** + * Walks a `useUnit` declarator once and normalizes it. `units` lists every unit expression; `bindings` + * pairs each with its destructured identifier. `normalized` is false when the destructuring can't be + * losslessly rebuilt — holes, rest/spread, defaults, computed or duplicate keys, nested patterns, or a + * non-destructuring call over a collection. Returns null when the declarator isn't a useUnit form we handle. + */ +function analyze(declarator: Node.VariableDeclarator): Analysis | null { + const init = declarator.init + if (init?.type !== NodeType.CallExpression) return null + const argument = init.arguments[0] + if (!argument || argument.type === NodeType.SpreadElement) return null + const id = declarator.id + + if (id.type === NodeType.ArrayPattern && argument.type === NodeType.ArrayExpression) { + const units: Node.Expression[] = [] + const bindings: Binding[] = [] + let normalized = argument.elements.length === id.elements.length + + argument.elements.forEach((element, index) => { + if (!element || element.type === NodeType.SpreadElement) { + normalized = false + return + } + units.push(element) + const target = id.elements[index] + if (target?.type === NodeType.Identifier) bindings.push({ unit: element, boundTo: target }) + else normalized = false + }) + + return { form: "array", units, bindings, normalized } + } + + if (id.type === NodeType.ObjectPattern && argument.type === NodeType.ObjectExpression) { + const localByKey = new Map() + let normalized = true + + for (const property of id.properties) { + if (property.type !== NodeType.Property || property.computed) { + normalized = false + continue + } + const key = keyName(property.key) + if (key === null || property.value.type !== NodeType.Identifier) { + normalized = false + continue + } + if (localByKey.has(key)) normalized = false // e.g. const { value: x, value: y } = useUnit({ value: $ }) + localByKey.set(key, property.value) + } + + const units: Node.Expression[] = [] + const bindings: Binding[] = [] + + for (const property of argument.properties) { + if (property.type !== NodeType.Property || property.computed) { + normalized = false + continue + } + // Property values inside an ObjectExpression are expressions at runtime; exclude the pattern + // members the shared `Property` type carries so `value` narrows to Expression without a cast. + const value = property.value + if ( + value.type === NodeType.AssignmentPattern || + value.type === NodeType.ArrayPattern || + value.type === NodeType.ObjectPattern || + value.type === NodeType.TSEmptyBodyFunctionExpression + ) { + normalized = false + continue + } + units.push(value) + const key = keyName(property.key) + const local = key === null ? undefined : localByKey.get(key) + if (local) bindings.push({ unit: value, boundTo: local }) + else normalized = false + } + + return { form: "object", units, bindings, normalized } + } + + // non-destructuring call: const x = useUnit($unit) + if (id.type === NodeType.Identifier) { + if (argument.type === NodeType.ArrayExpression || argument.type === NodeType.ObjectExpression) return null + return { form: "plain", units: [argument], bindings: [{ unit: argument, boundTo: id }], normalized: true } + } + + return null +} + +/** + * Distinct unit type of a call: "mixed" when it spans several known types; "unknown" when any unit's + * type is undetermined (so it's never merged or split — we must not guess at an unknown unit). + */ +function classify(call: UseUnitCall, getNodeType: GetNodeType): UnitType | "mixed" { + const types = new Set(call.units.map(getNodeType)) + const known = [...types].filter((type) => type !== "unknown") + if (known.length > 1) return "mixed" + if (known.length === 1 && !types.has("unknown")) return known[0]! + return "unknown" +} + +/** + * Whether `unit` references a variable declared inside the enclosing component at or after `anchorStart`. + * Merging hoists every unit to the anchor's position, so such a reference would hit a temporal dead zone. + * Variables from outer scopes (module units, imports, params) are always initialized by render time and safe. + */ +function dependsOnLaterLocal(unit: Node.Expression, anchorStart: number, functionEnd: number, sourceCode: SourceCode) { + const within = (node: Node.Node) => node.range[0] >= unit.range[0] && node.range[1] <= unit.range[1] + + const visit = (scope: Scope.Scope): boolean => { + for (const reference of scope.references) { + if (!within(reference.identifier)) continue + const defs = reference.resolved?.defs ?? [] + if (defs.some((def) => def.node.range[0] >= anchorStart && def.node.range[1] <= functionEnd)) return true + } + return scope.childScopes.some(visit) + } + + return visit(sourceCode.getScope(unit)) +} + +/** Renders one `useUnit` statement from a set of bindings in the given form. */ +function lineFor(form: Form, bindings: Binding[], callee: string, sourceCode: SourceCode): string { + const names = bindings.map((binding) => sourceCode.getText(binding.boundTo)) + if (form === "array") { + const units = bindings.map((binding) => sourceCode.getText(binding.unit)) + return `const [${names.join(", ")}] = ${callee}([${units.join(", ")}])` + } + const properties = bindings.map( + (binding) => `${sourceCode.getText(binding.boundTo)}: ${sourceCode.getText(binding.unit)}`, + ) + return `const { ${names.join(", ")} } = ${callee}({ ${properties.join(", ")} })` +} + +/** The form a merged group collapses into: the first array/object call, or object form for all-plain groups. */ +function targetForm(group: UseUnitCall[]): Form { + for (const call of group) if (call.form !== "plain") return call.form + return "object" +} + +function indentOf(statement: Node.VariableDeclaration, sourceCode: SourceCode): string { + const lineStart = sourceCode.text.lastIndexOf("\n", statement.range[0] - 1) + 1 + return sourceCode.text.slice(lineStart, statement.range[0]) +} + +function removeStatement(fixer: RuleFixer, sourceCode: SourceCode, statement: Node.VariableDeclaration): RuleFix { + let start = statement.range[0] + const lineStart = sourceCode.text.lastIndexOf("\n", start - 1) + 1 + if (/^\s*$/.test(sourceCode.text.slice(lineStart, start))) start = lineStart + + const end = statement.range[1] + const nextChar = sourceCode.text[end] + return fixer.removeRange([start, nextChar === "\n" || nextChar === "\r" ? end + 1 : end]) +} + +function mergeFix(fixer: RuleFixer, group: UseUnitCall[], sourceCode: SourceCode): RuleFix[] { + const [anchor, ...rest] = group + const bindings = group.flatMap((call) => call.bindings) + const fixes = [ + fixer.replaceText(anchor!.declaration, lineFor(targetForm(group), bindings, anchor!.callee, sourceCode)), + ] + for (const call of rest) fixes.push(removeStatement(fixer, sourceCode, call.declaration)) + return fixes +} + +function splitFix(fixer: RuleFixer, call: UseUnitCall, sourceCode: SourceCode, getNodeType: GetNodeType): RuleFix[] { + const order: UnitType[] = ["store", "event", "effect", "unknown"] + const byType = new Map() + for (const binding of call.bindings) { + const type = getNodeType(binding.unit) + const group = byType.get(type) ?? byType.set(type, []).get(type)! + group.push(binding) + } + + const indent = indentOf(call.declaration, sourceCode) + const lines = order + .filter((type) => byType.has(type)) + .map((type) => lineFor(call.form, byType.get(type)!, call.callee, sourceCode)) + + return [fixer.replaceText(call.declaration, lines.join(`\n${indent}`))] +} + +/** Names bound across the group are unique, so a merged destructuring stays valid JS. */ +function hasUniqueNames(group: UseUnitCall[]): boolean { + const names = group.flatMap((call) => call.bindings.map((binding) => binding.boundTo.name)) + return new Set(names).size === names.length +} + +/** + * Reports redundant `useUnit` calls that should collapse into one. Calls whose units depend on a + * declaration between the anchor and themselves can't be hoisted, so they start a fresh group instead — + * leaving legitimately ordered code untouched while still surfacing mergeable calls further down. + */ +function reportMerge(context: Context, candidates: UseUnitCall[], functionEnd: number): void { + const sourceCode = context.sourceCode + let pool = candidates + + while (pool.length > 1) { + const [anchor, ...rest] = pool + const anchorStart = anchor!.declaration.range[0] + const group = [anchor!] + const leftover: UseUnitCall[] = [] + + for (const call of rest) { + const hoistable = call.units.every((unit) => !dependsOnLaterLocal(unit, anchorStart, functionEnd, sourceCode)) + ;(hoistable ? group : leftover).push(call) + } + + if (group.length > 1) { + const mergeable = group.every((call) => call.normalized && call.fixable) && hasUniqueNames(group) + for (const call of group.slice(1)) { + context.report({ + node: call.report, + messageId: "multipleUseUnit", + suggest: mergeable + ? [{ messageId: "multipleUseUnit", fix: (fixer) => mergeFix(fixer, group, sourceCode) }] + : undefined, + }) + } + } + + pool = leftover + } +} + +export default createRule({ + name: "prefer-single-binding", + meta: { + type: "suggestion", + hasSuggestions: true, + docs: { + description: + "Recommend combining multiple useUnit calls into a single call. The @@unitShape protocol and useUnit calls whose argument is not a unit are ignored.", + }, + 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.", + }, + 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 stack: { calls: UseUnitCall[]; functionEnd: number }[] = [] + + const services = ESLintUtils.getParserServices(context) + const getNodeType: GetNodeType = (node) => { + const type = services.getTypeAtLocation(node) + 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" + } + + const onFunctionExit = (): void => { + const frame = stack.pop() + if (!frame || frame.calls.length === 0) return + const { calls, functionEnd } = frame + + if (separation === "forbid") { + reportMerge(context, calls, functionEnd) + return + } + + // allow & enforce both merge same-type calls; enforce additionally splits mixed ones. + const byType: Record<"store" | "event" | "effect", UseUnitCall[]> = { store: [], event: [], effect: [] } + for (const call of calls) { + const type = classify(call, getNodeType) + if (type !== "mixed" && type !== "unknown") byType[type].push(call) + } + for (const group of Object.values(byType)) reportMerge(context, group, functionEnd) + + if (separation === "enforce") { + for (const call of calls) { + if (classify(call, getNodeType) !== "mixed") continue + context.report({ + node: call.report, + messageId: "mixedStoresAndEvents", + suggest: + call.normalized && call.fixable + ? [ + { + messageId: "mixedStoresAndEvents", + fix: (fixer) => splitFix(fixer, call, context.sourceCode, getNodeType), + }, + ] + : undefined, + }) + } + } + } + + return { + [selectorImport]: (node: Node.ImportSpecifier) => void importedAs.add(node.local.name), + + "FunctionDeclaration, FunctionExpression, ArrowFunctionExpression": (node: Node.FunctionLike) => + void stack.push({ calls: [], functionEnd: node.range[1] }), + + "FunctionDeclaration:exit": onFunctionExit, + "FunctionExpression:exit": onFunctionExit, + "ArrowFunctionExpression:exit": onFunctionExit, + + [selectorCall](node: Node.VariableDeclarator): void { + const init = node.init + if (init?.type !== NodeType.CallExpression) return + const callee = init.callee + if (callee.type !== NodeType.Identifier || !importedAs.has(callee.name)) return + if (node.parent.type !== NodeType.VariableDeclaration) return + + // A non-destructuring call is only relevant when its single argument is a unit; this drops + // `useUnit(model)` (@@unitShape), `useUnit(notAUnit)` and `useUnit([...])` over a collection. + if (node.id.type === NodeType.Identifier) { + const argument = init.arguments[0] + if (!argument || argument.type === NodeType.SpreadElement || getNodeType(argument) === "unknown") return + } + + const analysis = analyze(node) + if (!analysis) return + + const declaration = node.parent + const fixable = declaration.kind === "const" && declaration.declarations.length === 1 && !node.id.typeAnnotation + + stack.at(-1)?.calls.push({ declaration, report: init, callee: callee.name, fixable, ...analysis }) + }, + } + }, +})