feat(prefer-single-binding): add rule#176
Conversation
✅ Deploy Preview for eslint-plugin ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
7edd618 to
15906fe
Compare
e12cac1 to
82797c1
Compare
82797c1 to
89651b2
Compare
89651b2 to
25440ef
Compare
25440ef to
c157176
Compare
kireevmp
left a comment
There was a problem hiding this comment.
Hey @Olovyannikov! I skimmed through the PR and before we dive into actual implementation specifics, let's work out what this rule should check for and what we'd consider a violation. Right now it seems there are a few edge cases that need considering.
| schema: [ | ||
| { | ||
| type: "object", | ||
| properties: { | ||
| allowSeparateStoresAndEvents: { type: "boolean", default: false }, | ||
| enforceStoresAndEventsSeparation: { type: "boolean", default: false }, | ||
| }, | ||
| additionalProperties: false, | ||
| }, | ||
| ], |
There was a problem hiding this comment.
allow and enforce settings are not actually independent, because you can't enforce separation while also disallowing it. These are better merged into a string flag, i.e. separation: "forbid" | "allow" | "enforce"
There was a problem hiding this comment.
Yes, you're right. I made a string flag now.
There was a problem hiding this comment.
You added three items here: forbid, allow and enforce like I suggested. Can you explain how allow is different during code generation from forbid and enforce 'separation"? And how would the user know clearly why allow works this way?
| additionalProperties: false, | ||
| }, | ||
| ], | ||
| fixable: "code", |
There was a problem hiding this comment.
The code fix output is not runtime equivalent to the input, so we shouldn't use autofix here. Since it modifies the runtime behavior, it must be a suggestion.
| let services: ParserServices | null = null | ||
| try { | ||
| const s = ESLintUtils.getParserServices(context, true) | ||
| if (s.program) services = s | ||
| } catch { | ||
| services = null | ||
| } |
There was a problem hiding this comment.
The plugin generally requires TypeScript, and eslint-typescript guidelines recommend against rules that change behavior based on type information availability. So I suggest directly getting services as you need where you need without try/catch handling and other conditionals.
| "FunctionDeclaration": onFunctionEnter, | ||
| "FunctionExpression": onFunctionEnter, | ||
| "ArrowFunctionExpression": onFunctionEnter, | ||
|
|
||
| "FunctionDeclaration:exit": onFunctionExit, | ||
| "FunctionExpression:exit": onFunctionExit, | ||
| "ArrowFunctionExpression:exit": onFunctionExit, |
There was a problem hiding this comment.
Consider merging selectors into a single "logical" one FunctionDeclaration, FunctionExpression, ArrowFunctionExpression
There was a problem hiding this comment.
Indeed, you can simply list them separated by commas. Did it, thanks.
| "effector/no-unnecessary-duplication": "warn", | ||
| "effector/no-useless-methods": "error", | ||
| "effector/no-watch": "warn", | ||
| "effector/prefer-single-binding": "warn", |
There was a problem hiding this comment.
- This is a
reactrule, notrecommendedone – it only concerns React users and not Vue users, for example - Since this is a stylistic rule (enforces code style, not correctness), we can't really include it into
reactpreset either
There was a problem hiding this comment.
How about the style group?
There was a problem hiding this comment.
That'd be something close to react-stylistic preset, yeah. But I'm not sure we want to make a new preset just for one rule. Let's ponder on this once implementation is close to completion.
| meta: { | ||
| type: "suggestion", | ||
| docs: { | ||
| description: "Recommend using a single useUnit call instead of multiple", |
There was a problem hiding this comment.
I'm a bit concerned with rule expectation mismatch here. It declares that it "recommends a single useUnit call", however
- This rule does work with about non-destructuring calls
const a = useUnit($a)and non-combining callsconst { a, b } = useUnit(model)with property access - There are no tests illustrating how this rule works with
Effects - There are no tests that combines both array form and object form in a single component
- There are no tests that play with variable positioning, for example where the unit consumed is not defined statically
const { a } = useUnit({ a: $a }) const { $b } = useContext(MyModel) const { b } = useUnit({ b: $b })
- This rule does not declare how it works with
@@unitShape(i.e. routers, queries) - With a proper setting, it actually enforces 2
useUnitcalls with separation, so not a "single" call - I've also not found tests demonstrating how this rule works with aliasing
const { a } = useUnit({ a: $a }); const { a: b } = useUnit({ a: $b })
My question here is – what are the expectations in these cases? Is there a sound "fix" we can provide for all of them? And should we actually check and report those cases?
There was a problem hiding this comment.
Yes, I will add more cases.
There was a problem hiding this comment.
Can we take a step back and work on this in comments first? It would be much easier to discuss conceptual solutions without diving into code other than examples. I'd love your opinions on what solutions, if any, are available for every listed case
There was a problem hiding this comment.
Aliasing works - I've added tests for this.
Also, we can't offer a fix for all cases; for such cases, we'll simply mark it as a warning.
Regarding effects, there's an issue suggesting creating a rule that prohibits working with effects directly. If that rule is included in the recommended rule, you can omit that case here, but by default, I currently separate calls when using separation enforce.
What about UnitShape - I'm not sure what the correct approach would be - highlight it as a warning? Or skip it, since it operates according to special rules?
Examples:
separation: 'forbid'
// Store + Event
// ESLint: Multiple useUnit calls detected. Consider combining them into a single call for better performance. (effector/prefer-single-binding)
const products = useUnit(CartModel.$cart);
const onProductRemoveFromCart = useUnit(AddToCartModel.cartProductTotalRemoved);// Store + Event Array Shaped
// ESLint: Multiple useUnit calls detected. Consider combining them into a single call for better performance. (effector/prefer-single-binding)
// Suggest to merge this calls included
const [products] = useUnit([CartModel.$cartProducts]);
const [onProductRemoveFromCart] = useUnit([AddToCartModel.cartProductTotalRemoved]);// Store + Event Object Shaped
// ESLint: Multiple useUnit calls detected. Consider combining them into a single call for better performance. (effector/prefer-single-binding)
// Suggest to merge this calls included
const { products } = useUnit({
products: CartModel.$cartProducts,
});
const { onProductRemoveFromCart } = useUnit({
onProductRemoveFromCart: AddToCartModel.cartProductTotalRemoved,
});// Mixed shapes - Array + Object
// ESLint: Multiple useUnit calls detected. Consider combining them into a single call for better performance. (effector/prefer-single-binding)
const [products] = useUnit([CartModel.$cartProducts]);
const { onProductRemoveFromCart } = useUnit({
onProductRemoveFromCart: AddToCartModel.cartProductTotalRemoved,
});// Using alias
// ESLint: Multiple useUnit calls detected. Consider combining them into a single call for better performance. (effector/prefer-single-binding)
const { products } = useUnitEffector({
products: CartModel.$cartProducts,
});
const [onProductRemoveFromCart] = useUnitEffector([AddToCartModel.cartProductTotalRemoved]);// named destructuring
// ESLint: Multiple useUnit calls detected. Consider combining them into a single call for better performance. (effector/prefer-single-binding)
const { a } = useUnitEffector({
a: CartModel.$cartProducts,
});
const { a: b } = useUnitEffector({
a: AddToCartModel.cartProductTotalRemoved,
});There was a problem hiding this comment.
With both @@unitShape and a pre-build useUnit(model) where the argument is not a unit or shape of units, I'd suggest we fully exclude them from calculation. These can never be soundly "combined" so they're logically not subject to rule enforcement / separation.
With Store + Event basic case, this is actually the most common example of "invalid" code when developers duplicate useUnit calls without using destructuring, common copy-pasting. So we ought to provide a suggestion for this simplest case.
With mixed shapes - Array + Object case we can also provide a suggestion by treating
const [products] = useUnit([CartModel.$cartProducts]);
// as equivalent to
const { products } = useUnit({ products: CartModel.$cartProducts });and merging with other destructuring patterns.
This case will always provide a suggestion unless either a rest property is used or variable names are duplicated in scope.
Using aliases adds a layer of complexity, however the accepting identifier is bound to be unique due to scoping rules, so
const { a: b } = useUnitEffector({ a: CartModel.$cartProducts });
// can be treated as
const { b } = useUnitEffector({ b: CartModel.$cartProducts });for the sake of suggestion generation.
So the most notably here are the cases we aren't able to provide a suggestion:
@@unitShapeanduseUnit(model)– ignored by the rule due to unsound combination,- duplicate identifiers to prevent invalid JS from being generated, or
- rest property and spread operator since we can't track
property -> unitmapping.
Other cases should theoretically provide a valid suggestion.
There was a problem hiding this comment.
There's also a useUnit ordering problem since it can be applied to dynamically acquired Units, see example above with useContext. So a question is how would we treat the following code?
const a = useUnit($a) // (1)
const $b = useContext(MyModel) // (2)
const b = useUnit($b) // (3)Should those be kept separate, suppressing the warning, or do we generate a suggestion for
const $b = useContext(MyModel)
const { a, b } = useUnit({ a: $a, b: $b })And if we do so, how do we track if a has been used between (1) and (3)?
It seems even for basic cases this rule would also unfortunately require variable scope analysis to detect if units are declared in a current scope or a surrounding scope to prevent issues with temporal dead zone.
| }, | ||
| } as const | ||
|
|
||
| function getTypeFromChecker(node: Node.Expression, services: ParserServices): UnitType { |
There was a problem hiding this comment.
Let's use a shared typecheck helper is in this rule instead of custom checks?
There was a problem hiding this comment.
Cool! Found it, using it! :)
| } | ||
| } | ||
|
|
||
| function scoreTypes(types: UnitType[]): UnitType { |
There was a problem hiding this comment.
I'm not sure why we need to score something here and pre-group? Would it be possible to just collect the list of mappings unit -> local ident that is grouped later, with no regard of who was first or second, completely regenerating all useUnit calls?
There was a problem hiding this comment.
I agree, it looks unreliable. I'll see what I can do.
a7b21d9 to
2898b5b
Compare
kireevmp
left a comment
There was a problem hiding this comment.
Hey @Olovyannikov – thanks for taking the time to come back to polish. I see good progress here, I think there's a solid foundation now with normalization concept in place. What's left is to apply it consistently everywhere across the rule, to actually use that structure. The mental model here seems to be correct, which is great news.
If you're up to do another pass, I'd appreciate you also reviewing the test suite. Due to large rule size, under-employed normalization and ultimately duplicated code paths, the coverage is below 80% and the suite may not cover every corner case, something we really need here to be sure rule is sound.
| type Fixer = RuleFixer | ||
| type SourceCode = Context["sourceCode"] | ||
| type Scope = ReturnType<SourceCode["getScope"]> |
There was a problem hiding this comment.
Why do we need to compute these aliases when plain imports are available?
Fixer => RuleFixer
SourceCode => TSESLint.SourceCode
Scope => TSESLint.Scope.Scope
Let's strive to use built-ins as much as possible, without reinventing the wheel 🙏
| shape: "ObjectExpression.arguments", | ||
| list: "ArrayExpression.arguments", | ||
| }, | ||
| } as const |
There was a problem hiding this comment.
Why did you think to add as const? It does not seem to be relevant at all for type system here.
| /** Unit expression nodes of a call, regardless of whether it can be normalized (used for hoisting analysis). */ | ||
| function unitExpressionNodes(call: UseUnitCall): Node.Expression[] { | ||
| const argument = call.init.arguments[0] | ||
| if (!argument || argument.type === NodeType.SpreadElement) return [] |
There was a problem hiding this comment.
Consider narrowing the selector on ESLint side to only include useUnit calls with Object, Array expressions or Identifier as first argument. May save couple of ifs across the rule.
| if (argument.type === NodeType.ArrayExpression) { | ||
| return argument.elements.filter((el): el is Node.Expression => el !== null && el.type !== NodeType.SpreadElement) | ||
| } | ||
| if (argument.type === NodeType.ObjectExpression) { | ||
| return argument.properties | ||
| .filter((p): p is Node.Property => p.type === NodeType.Property) | ||
| .map((p) => p.value as Node.Expression) | ||
| } |
There was a problem hiding this comment.
Let's avoid explicit type guards since TS can faithfully infer those now, modern style. These now only add extra noise.
| if (argument.type === NodeType.ObjectExpression) { | ||
| return argument.properties | ||
| .filter((p): p is Node.Property => p.type === NodeType.Property) | ||
| .map((p) => p.value as Node.Expression) |
There was a problem hiding this comment.
Can we avoid a type cast? Looking at code, there's no guarantee made that non-expression arguments are excluded, so this is not sound.
| 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}`))] |
There was a problem hiding this comment.
This seems to eventually just drop unknown properties from useUnit, potentially generating invalid destructive code. Consider, for some reason, we couldn't detect type of a unit (let's say it resolved to any). Under no circumstances can we delete useUnit($somethingThatIsAny) – best-case we ought to filter it out and keep as-is, worst-case – do nothing with this useUnit.
| ...(mergeable | ||
| ? { | ||
| suggest: [ | ||
| { | ||
| messageId: "multipleUseUnit", | ||
| fix: (fixer) => generateMergeFix(fixer, captured, context), | ||
| }, | ||
| ], | ||
| } | ||
| : {}), |
There was a problem hiding this comment.
| ...(mergeable | |
| ? { | |
| suggest: [ | |
| { | |
| messageId: "multipleUseUnit", | |
| fix: (fixer) => generateMergeFix(fixer, captured, context), | |
| }, | |
| ], | |
| } | |
| : {}), | |
| suggest: mergeable | |
| ? [{ messageId: "multipleUseUnit", fix: (fixer) => generateMergeFix(fixer, captured, context) }] | |
| : undefined, |
| if (!arg || arg.type === NodeType.SpreadElement) return | ||
| // Only non-destructuring calls over a single unit can be merged; a collection argument | ||
| // (`useUnit([...])` / `useUnit({...})`) or a non-unit argument (e.g. @@unitShape) is ignored. | ||
| if (arg.type === NodeType.ArrayExpression || arg.type === NodeType.ObjectExpression) return |
There was a problem hiding this comment.
Consolidate paths - if you're triggering on all calls after all, there's no reason to have three selectors if one suffices.
| "prefer-useUnit": preferUseUnit, | ||
| "prefer-single-binding": preferSingleBinding, |
There was a problem hiding this comment.
Ordering
| "prefer-useUnit": preferUseUnit, | |
| "prefer-single-binding": preferSingleBinding, | |
| "prefer-single-binding": preferSingleBinding, | |
| "prefer-useUnit": preferUseUnit, |
| const style = { | ||
| "effector/prefer-single-binding": "warn", | ||
| } satisfies TSESLint.Linter.RulesRecord |
There was a problem hiding this comment.
Let's postpone adding a whole new style preset for now, please. Adding a preset is easy, removing not so much. I'd rather be cautious here, if you allow that.
…inding Collapse the three parallel traversals (extractBindings, unitExpressionNodes, generateSeparationFix) into one analyze() pass that yields a single primitive: an identifier bound to the unit expression passed to useUnit. Every helper now consumes that structure, and source text is read only while building a fix. Resolve the soundness issues raised in review: - module units declared below the component are no longer a false TDZ hazard; only locals declared at/after the anchor block hoisting - multi-declarator statements, let/var and type-annotated bindings are reported without a suggestion instead of producing a crashing or invalid fix - the split fix keeps units whose type is undetermined instead of dropping them - duplicate destructuring keys no longer silently drop a binding - merging finds mergeable calls below one that cannot be hoisted - enforce now merges same-type calls as well as splitting mixed ones Drop the computed type aliases, the unsound `as Expression` cast and the redundant `as const`/type guards in favour of the built-in ts-eslint types. Tests cover the new edge cases; branch coverage rises from 79% to 89%.
…e cases Add a comparison of forbid/allow/enforce code generation, spell out which calls are ignored (@@unitShape, non-unit args, temporal dead zone) and which are reported without a suggestion (multi-declarator, let, type annotations, etc.).
prefer-single-binding stays available as effector/prefer-single-binding but is no longer bundled in a preset — removing a preset after release is painful, so hold off on adding one. Also order prefer-single-binding before prefer-useUnit.
Closes #116
Why is this important?
Performance
Each
useUnitcall creates its own subscription management overhead. Combining them reduces:Code clarity
A single
useUnitcall makes it easier to:Example
Separate stores and events calls
If you want to separate stores and events as independent groups, you can use option
separationWhen set to
allow, allows separateuseUnitcalls for stores and events, but still enforces combining multiple calls within each group.Example