feat(prefer-single-binding): add rule#176
feat(prefer-single-binding): add rule#176Olovyannikov wants to merge 7 commits intoeffector:masterfrom
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.
| 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
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