feat(use-unit-destructuring): add new rule for destructured units#175
feat(use-unit-destructuring): add new rule for destructured units#175Olovyannikov wants to merge 9 commits intoeffector:masterfrom
Conversation
✅ Deploy Preview for eslint-plugin ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
ddd9966 to
9accf5e
Compare
9accf5e to
6c9cd69
Compare
80edafd to
e8e65b7
Compare
kireevmp
left a comment
There was a problem hiding this comment.
@Olovyannikov Thanks for taking the time to submit this! The rule logic seems to be solid 👍
There are a couple of good opportunities to improve this though – I'd appreciate it if you can take a look at my comments
src/rules/use-unit-destructuring/use-unit-destructuring.test.ts
Outdated
Show resolved
Hide resolved
src/rules/prefer-useUnit-destructuring/prefer-useUnit-destructuring.test.ts
Show resolved
Hide resolved
src/rules/prefer-useUnit-destructuring/prefer-useUnit-destructuring.test.ts
Show resolved
Hide resolved
| "ImportDeclaration"(node): void { | ||
| if (node.source.value !== "effector-react") return | ||
|
|
||
| for (const specifier of node.specifiers) { | ||
| if ( | ||
| specifier.type === AST_NODE_TYPES.ImportSpecifier && | ||
| specifier.imported.type === AST_NODE_TYPES.Identifier && | ||
| specifier.imported.name === "useUnit" | ||
| ) { | ||
| importedAs.add(specifier.local.name) | ||
| } | ||
| } | ||
| }, |
There was a problem hiding this comment.
Manual traversal of all specifiers can be avoided given the following selector, which is widely accepted in many other rules
const selector = {
import: `ImportDeclaration[source.value=${PACKAGE_NAME.react}] > ImportSpecifier[imported.name=useUnit]`,
}This not only finds specific instances of useUnit aliases, but also supports other effector-react imports via regex such as effector-react/scope (deprecated but still used).
With this, the code reduces to
return {
[selector.import]: (node: TSESTree.ImportSpecifier) => void importedAs.add(node.local.name),| "VariableDeclarator[id.type='ObjectPattern'] > CallExpression[arguments.length=1][callee.type='Identifier']"( | ||
| node: TSESTree.CallExpression, | ||
| ): void { | ||
| if (!importedAs.has((node.callee as TSESTree.Identifier).name)) return | ||
| const argument = node.arguments[0] | ||
|
|
||
| if (argument?.type !== AST_NODE_TYPES.ObjectExpression) return | ||
| const parent = node.parent as TSESTree.VariableDeclarator | ||
|
|
||
| if (parent.id.type !== AST_NODE_TYPES.ObjectPattern) return |
There was a problem hiding this comment.
Many of these checks can be delegated to esquery as discussed previously. It also allows us to select a specific subject (with ESLint via :has selector)
const selector = {
variable: { shape: "VariableDeclarator[id.type=ObjectPattern]", list: "VariableDeclarator[id.type=ArrayPattern]" },
call: "CallExpression.init[arguments.length=1][callee.type=Identifier]",
arg: { shape: "ObjectExpression.arguments", list: "ArrayExpression.arguments" },
}The above group of selectors allows detecting all two combinations via
return {
[`${selector.variable.shape}:has(> ${selector.call}:has(${selector.arg.shape}))`]: (node: ShapeCall): void => { ... },
[`${selector.variable.list}:has(> ${selector.call}:has(${selector.arg.list}))`]: (node: ListCall): void => { ... },
}And these selectors provide the following TS guarantees
type ListCall = Node.VariableDeclarator & {
init: Node.CallExpression & { callee: Node.Identifier; arguments: [Node.ArrayExpression] }
id: Node.ArrayPattern
}
type ShapeCall = Node.VariableDeclarator & {
init: Node.CallExpression & { callee: Node.Identifier; arguments: [Node.ObjectExpression] }
id: Node.ObjectPattern
}Which completely eliminates the need for all guards except importedAs.has
| function getPropertyKey(prop: TSESTree.Property | TSESTree.RestElement | TSESTree.SpreadElement): string | null { | ||
| if (prop.type !== AST_NODE_TYPES.Property) return null | ||
|
|
||
| if (prop.key.type === AST_NODE_TYPES.Identifier && !prop.computed) { | ||
| return prop.key.name | ||
| } | ||
|
|
||
| if (prop.key.type === AST_NODE_TYPES.Literal && typeof prop.key.value === "string" && !prop.computed) { | ||
| return prop.key.value | ||
| } | ||
|
|
||
| return null | ||
| } |
There was a problem hiding this comment.
This function can be moved out of create to be static (rather than re-created on each file) and also simplified by type-guarding against computed first
type ShapeProperty = Node.Property | Node.RestElement | Node.ObjectLiteralElement
function toKey(prop: ShapeProperty): string | number | null {
if (prop.type !== NodeType.Property || prop.computed) return null
if (prop.key.type === NodeType.Identifier) return prop.key.name
if (prop.key.type === NodeType.Literal) return prop.key.value
return null
}| function getObjectKeys( | ||
| objectArgument: TSESTree.ObjectExpression, | ||
| objectPattern: TSESTree.ObjectPattern, | ||
| ): { argumentKeys: string[]; destructuredKeys: string[]; keyToName: Map<string, string> } { | ||
| const argumentKeys = objectArgument.properties.map(getPropertyKey).filter((key): key is string => key !== null) | ||
|
|
||
| const destructuredKeys = objectPattern.properties.map(getPropertyKey).filter((key): key is string => key !== null) | ||
|
|
||
| const keyToName = new Map(argumentKeys.map((key) => [key, key])) | ||
|
|
||
| return { argumentKeys, destructuredKeys, keyToName } | ||
| } |
There was a problem hiding this comment.
- This function does not capture anything from
contextas well, so can be moved out. - I don't see a specific reason to work with both
argumentanddestructuredkeys in the same function, given that the logic is the same. It would be best to reuse the same logic by calling it twice, separately on lefthand and righthand side. - We should also build in the rest/spread logic by bailing early on any "weird" usage where we can't track what's provided to
useUnitand what's consumed by the component. - We can also postpone
keyToNamegeneration until we've found the violation and ready to report
The shape -> key mapper can then be simplified to work along these lines
type ValueNode = Node.Property["key"]
function shapeToKeyMap(shape: Node.ObjectPattern | Node.ObjectExpression): Map<string | number, ValueNode> | null {
const map = new Map<string | number, ValueNode>()
for (const prop of shape.properties) {
const key = toKey(prop)
if (key === null || prop.type === NodeType.RestElement || prop.type === NodeType.SpreadElement)
return null /* can't see what is consumed/provided, so skip to reduce false positives */
else map.set(key, prop.key)
}
return map
}This is called twice, first for ObjectPattern and then for ObjectExpression. The prop.key is stored for later to allow for formatting the error, but is untouched until that is needed.
| function getArrayKeys( | ||
| arrayArgument: TSESTree.ArrayExpression, | ||
| arrayPattern: TSESTree.ArrayPattern, | ||
| ): { argumentKeys: string[]; destructuredKeys: string[]; keyToName: Map<string, string> } { | ||
| const argumentKeys: string[] = [] | ||
| const keyToName = new Map<string, string>() | ||
|
|
||
| arrayArgument.elements.forEach((el, i) => { | ||
| if (el === null || el.type === AST_NODE_TYPES.SpreadElement) return | ||
|
|
||
| const key = String(i) | ||
| argumentKeys.push(key) | ||
|
|
||
| if (el.type === AST_NODE_TYPES.Identifier) { | ||
| keyToName.set(key, el.name) | ||
| } else if (el.type === AST_NODE_TYPES.MemberExpression) { | ||
| keyToName.set(key, context.sourceCode.getText(el)) | ||
| } else { | ||
| keyToName.set(key, key) | ||
| } | ||
| }) | ||
|
|
||
| const destructuredKeys = arrayPattern.elements | ||
| .map((el, i) => (el !== null && el.type !== AST_NODE_TYPES.RestElement ? String(i) : null)) | ||
| .filter((key): key is string => key !== null) | ||
|
|
||
| return { argumentKeys, destructuredKeys, keyToName } | ||
| } |
There was a problem hiding this comment.
- Same thoughts here – no
contextcapture suggests moving out ofcreateand working with both lefthand and righthand sides makes no sense when we can call the function twice. context.sourceCode.getText(node)is dependent on source code formatting and is not reliable, since it may produce "new line" character or other stuff we're not ready to work with. One other rule currently handles this by traversing theMemberExpressionand joining the path recursively.- This logic should also consider bailing on spread and rest elements, since those completely remove our ability to judge what's provided and consumed.
With these thoughts, consider the following rough implementation
type ValueNode = Exclude<Node.DestructuringPattern, Node.RestElement> | null
function listToKeyMap(list: Node.ArrayPattern | Node.ArrayExpression): Map<number, ValueNode> | null {
const map = new Map<number, ValueNode>()
for (const [index, element] of list.elements.entries())
if (element?.type === NodeType.RestElement || element?.type === NodeType.SpreadElement) return null
else if (element === null) continue /* leave a hole as non-consuming */
else map.set(index, element)
return map
}| valid: [ | ||
| { | ||
| name: "All keys were destructured (object shape)", | ||
| code: ` |
There was a problem hiding this comment.
Every test case in this repo uses ts or tsx helper from "@/shared/tag" which trims spaces and newlines. It also works really nice with prettier-plugin-embed to provide automatic formatting (i.e. remove semicolons).
For intended cases like quoted "setValue" use // prettier-ignore
src/rules/use-unit-destructuring/use-unit-destructuring.test.ts
Outdated
Show resolved
Hide resolved
src/index.ts
Outdated
| "use-unit-destructuring": useUnitDestructuring, | ||
| "require-pickup-in-persist": requirePickupInPersist, | ||
| "strict-effect-handlers": strictEffectHandlers, |
There was a problem hiding this comment.
Would you also kindly sort this rule list alphabetically, like with the imports?
8676017 to
61efbe7
Compare
23f77aa to
ed112f1
Compare
ed112f1 to
aa98a41
Compare
kireevmp
left a comment
There was a problem hiding this comment.
@Olovyannikov Thank you for committing your time to polish this PR! I think these are just final cosmetic touches on code location and consistency. Once fixed I believe we'll be in good shape to merge.
| @@ -0,0 +1,157 @@ | |||
| --- | |||
| description: Ensures that all units passed to useUnit are properly destructured to avoid unused subscriptions and implicit re-renders. | |||
There was a problem hiding this comment.
| description: Ensures that all units passed to useUnit are properly destructured to avoid unused subscriptions and implicit re-renders. | |
| description: Ensure all units passed to useUnit are properly destructured to avoid unused subscriptions and implicit re-renders |
Just a small touch-up to remove the dot and use correct form.
| unusedKey: 'Property "{{key}}" is passed but not destructured.', | ||
| missingKey: 'Property "{{key}}" is destructured but not passed in the unit object.', |
There was a problem hiding this comment.
| unusedKey: 'Property "{{key}}" is passed but not destructured.', | |
| missingKey: 'Property "{{key}}" is destructured but not passed in the unit object.', | |
| unusedKey: 'Property "{{name}}" is passed but not destructured.', | |
| missingKey: 'Property "{{name}}" is destructured but not passed in the unit object.', |
Very much optional, but this will remove the data: { key: name } in favor of data: { name } shorthand.
| create(context) { | ||
| const importedAs = new Set<string>() | ||
|
|
||
| function handleObjectPattern(objectArgument: Node.ObjectExpression, objectPattern: Node.ObjectPattern): void { |
There was a problem hiding this comment.
Is there a reason to keep those handle* in separate functions, or would it be possible to just inline those in the visitor?
| } as const | ||
|
|
||
| export default createRule<Options, MessageIds>({ | ||
| name: "prefer-useUnit-destructuring", |
There was a problem hiding this comment.
A question on rule naming – why were you inclined to change the previous name useUnit-destructuring and add prefer-?
In case with prefer-useUnit, the community prefers useUnit over useStore and others.
With these rule, I believe there's nothing to prefer. Rather, if a rule name prefix is wanted, I'd suggest ensure-useUnit-destructuring becase we ensure all units are destructured. What do you think? Which option you think is best here?
There was a problem hiding this comment.
Why are these new rule-specific functions placed in a shared file that explicitly only houses createRule? These are very rule-specific, so I'd be inclined to keep them in the test rule file.
| for (const prop of shape.properties) { | ||
| if (prop.type === AST_NODE_TYPES.RestElement || prop.type === AST_NODE_TYPES.SpreadElement) return null | ||
| const key = toKey(prop) | ||
| if (key === null) return null | ||
| map.set(key, prop.key as ValueNode) | ||
| } |
There was a problem hiding this comment.
toKey already covers the RestElement and SpreadElement checks via prop.type !== AST_NODE_TYPES.Property. So in my previous comment with the rough implementation the if was solely as a type guard.
key acts as the discriminator in this case. Consider either type cast (which is sound) or other way to force TS to narrow down the value.
for (const prop of shape.properties) {
const key = toKey(prop)
if (key === null) return null
else map.set(key, (prop as Node.Property).key)
}There's also an option to replace Rest/SpreadElement check with the prop.type !== AST_NODE_TYPES.Property check inside the loop, removing it from toKey and narrowing down the argument type of toKey to reflect that.
| for (const [index, element] of list.elements.entries()) { | ||
| if (element?.type === AST_NODE_TYPES.RestElement || element?.type === AST_NODE_TYPES.SpreadElement) return null | ||
| if (element === null) continue |
There was a problem hiding this comment.
Might make sense to reverse check to get rid of .? operator
| for (const [index, element] of list.elements.entries()) { | |
| if (element?.type === AST_NODE_TYPES.RestElement || element?.type === AST_NODE_TYPES.SpreadElement) return null | |
| if (element === null) continue | |
| for (const [index, element] of list.elements.entries()) { | |
| if (element === null) continue | |
| if (element.type === AST_NODE_TYPES.RestElement || element.type === AST_NODE_TYPES.SpreadElement) return null |
There was a problem hiding this comment.
This new code in this file is also very rule-specific, consider moving back to the rule file.
| meta: { | ||
| type: "problem", | ||
| docs: { | ||
| description: "Ensure destructured properties match the passed unit object/array", |
There was a problem hiding this comment.
| description: "Ensure destructured properties match the passed unit object/array", | |
| description: "Ensure all units passed to useUnit are properly destructured.", |
It's best to keep this very close to frontmatter in docs, and also mention useUnit.
| @@ -1,3 +1,6 @@ | |||
| .idea | |||
| **/*.xml | |||
There was a problem hiding this comment.
It's rather blunt to ignore all XML files, even though we don't use them at the moment. Can this be more specific, and preferably under a named section like ignores below?
Problem: implicit subscriptions when forgot remove unused subscriptions inside react components
e.g.:
Why is this important?
Implicit subscriptions can lead to:
Rule Details
This rule enforces that: