-
Notifications
You must be signed in to change notification settings - Fork 31
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Created components-return-once rule to address #24.
- Loading branch information
1 parent
6561ff2
commit 063e63c
Showing
2 changed files
with
225 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,170 @@ | ||
import { TSESTree as T, TSESLint, ASTUtils } from "@typescript-eslint/utils"; | ||
import type { FunctionNode } from "../utils"; | ||
|
||
const isNothing = (node?: T.Node): boolean => { | ||
if (!node) { | ||
return true; | ||
} | ||
switch (node.type) { | ||
case "Literal": | ||
return ([null, undefined, false, ""] as Array<unknown>).includes(node.value); | ||
case "JSXFragment": | ||
return !node.children || node.children.every(isNothing); | ||
default: | ||
return false; | ||
} | ||
}; | ||
|
||
const getLocLength = (loc: T.SourceLocation) => loc.end.line - loc.start.line + 1; | ||
|
||
const rule: TSESLint.RuleModule<"noEarlyReturn" | "noConditionalReturn", []> = { | ||
meta: { | ||
type: "problem", | ||
docs: { | ||
recommended: "error", | ||
description: | ||
"Disallow early returns in components. Solid components only run once, and so conditionals should be inside JSX.", | ||
url: "https://github.com/joshwilsonvu/eslint-plugin-solid/blob/main/docs/components-return-once.md", | ||
}, | ||
fixable: "code", | ||
schema: [], | ||
messages: { | ||
noEarlyReturn: | ||
"Solid components run once, so an early return breaks reactivity. Move the condition inside a JSX element, such as a fragment or <Show />.", | ||
noConditionalReturn: | ||
"Solid components run once, so a conditional return breaks reactivity. Move the condition inside a JSX element, such as a fragment or <Show />.", | ||
}, | ||
}, | ||
create(context) { | ||
const functionStack: Array<{ | ||
/** switched to true by :exit if the current function is detected to be a component */ | ||
isComponent: boolean; | ||
lastReturn: T.ReturnStatement | undefined; | ||
earlyReturns: Array<T.ReturnStatement>; | ||
}> = []; | ||
const putIntoJSX = (node: T.Node): string => { | ||
const text = context.getSourceCode().getText(node); | ||
return node.type === "JSXElement" || node.type === "JSXFragment" ? text : `{${text}}`; | ||
}; | ||
const currentFunction = () => functionStack[functionStack.length - 1]; | ||
const onFunctionEnter = (node: FunctionNode) => { | ||
const getLastReturn = () => { | ||
if (node.body.type === "BlockStatement") { | ||
const { length } = node.body.body; | ||
const last = length && node.body.body[length - 1]; | ||
if (last && last.type === "ReturnStatement") { | ||
return last; | ||
} | ||
} | ||
}; | ||
functionStack.push({ isComponent: false, lastReturn: getLastReturn(), earlyReturns: [] }); | ||
}; | ||
const onFunctionExit = (node: FunctionNode) => { | ||
if ( | ||
node.parent?.type === "JSXExpressionContainer" // "render props" aren't components | ||
) { | ||
currentFunction().isComponent = false; | ||
} | ||
if (currentFunction().isComponent) { | ||
currentFunction().earlyReturns.forEach((earlyReturn) => { | ||
context.report({ | ||
node: earlyReturn, | ||
messageId: "noEarlyReturn", | ||
}); | ||
}); | ||
|
||
const argument = currentFunction().lastReturn?.argument; | ||
if (argument?.type === "ConditionalExpression") { | ||
const sourceCode = context.getSourceCode(); | ||
context.report({ | ||
node: argument.parent!, | ||
messageId: "noConditionalReturn", | ||
fix: (fixer) => { | ||
const { test, consequent, alternate } = argument; | ||
const conditions = [{ test, consequent }]; | ||
let fallback = alternate; | ||
|
||
while (fallback.type === "ConditionalExpression") { | ||
conditions.push({ test: fallback.test, consequent: fallback.consequent }); | ||
fallback = fallback.alternate; | ||
} | ||
|
||
if (conditions.length >= 2) { | ||
// we have a nested ternary, use <Switch><Match /></Switch> | ||
const fallbackStr = !isNothing(fallback) | ||
? ` fallback={${sourceCode.getText(fallback)}}` | ||
: ""; | ||
return fixer.replaceText( | ||
argument, | ||
`<Switch${fallbackStr}>\n${conditions | ||
.map( | ||
({ test, consequent }) => | ||
`<Match when={${sourceCode.getText(test)}}>${putIntoJSX( | ||
consequent | ||
)}</Match>` | ||
) | ||
.join("\n")}\n</Switch>` | ||
); | ||
} | ||
if (isNothing(consequent)) { | ||
// we have a single ternary and the consequent is nothing. Negate the condition and use a <Show>. | ||
return fixer.replaceText( | ||
argument, | ||
`<Show when={!(${sourceCode.getText(test)})}>${putIntoJSX(alternate)}</Show>` | ||
); | ||
} | ||
if ( | ||
isNothing(fallback) || | ||
getLocLength(consequent.loc) >= getLocLength(alternate.loc) * 1.5 | ||
) { | ||
// we have a standard ternary, and the alternate is a bit shorter in LOC than the consequent, which | ||
// should be enough to tell that it's logically a fallback instead of an equal branch. | ||
const fallbackStr = !isNothing(fallback) | ||
? ` fallback={${sourceCode.getText(fallback)}}` | ||
: ""; | ||
return fixer.replaceText( | ||
argument, | ||
`<Show when={${sourceCode.getText(test)}}${fallbackStr}>${putIntoJSX( | ||
consequent | ||
)}</Show>` | ||
); | ||
} | ||
|
||
// we have a standard ternary, but no signal from the user as to which branch is the "fallback" and | ||
// which is the children. Move the whole conditional inside a JSX fragment. | ||
return fixer.replaceText(argument, `<>${putIntoJSX(argument)}</>`); | ||
}, | ||
}); | ||
} | ||
} | ||
|
||
// Pop on exit | ||
functionStack.pop(); | ||
}; | ||
return { | ||
FunctionDeclaration: onFunctionEnter, | ||
FunctionExpression: onFunctionEnter, | ||
ArrowFunctionExpression: onFunctionEnter, | ||
"FunctionDeclaration:exit": onFunctionExit, | ||
"FunctionExpression:exit": onFunctionExit, | ||
"ArrowFunctionExpression:exit": onFunctionExit, | ||
JSXElement() { | ||
if (functionStack.length) { | ||
currentFunction().isComponent = true; | ||
} | ||
}, | ||
JSXFragment() { | ||
if (functionStack.length) { | ||
currentFunction().isComponent = true; | ||
} | ||
}, | ||
ReturnStatement(node) { | ||
if (functionStack.length && node !== currentFunction().lastReturn) { | ||
currentFunction().earlyReturns.push(node); | ||
} | ||
}, | ||
}; | ||
}, | ||
}; | ||
|
||
export default rule; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,55 @@ | ||
import { run } from "../ruleTester"; | ||
import rule from "../../src/rules/components-return-once"; | ||
|
||
export const cases = run("components-return-once", rule, { | ||
valid: [], | ||
invalid: [ | ||
// Early returns | ||
{ | ||
code: `function Component() { | ||
if (true) { | ||
return <div />; | ||
}; | ||
return <span />; | ||
}`, | ||
errors: [{ messageId: "noEarlyReturn" }], | ||
}, | ||
// Balanced ternaries | ||
{ | ||
code: `function Component() { | ||
return Math.random() > 0.5 ? <div>Big!</div> : <div>Small!</div>; | ||
}`, | ||
errors: [{ messageId: "noConditionalReturn" }], | ||
output: `function Component() { | ||
return <>{Math.random() > 0.5 ? <div>Big!</div> : <div>Small!</div>}</>; | ||
}`, | ||
}, | ||
{ | ||
code: `function Component() { | ||
return Math.random() > 0.5 ? <div>Big!</div> : "Small!"; | ||
}`, | ||
errors: [{ messageId: "noConditionalReturn" }], | ||
output: `function Component() { | ||
return <>{Math.random() > 0.5 ? <div>Big!</div> : "Small!"}</>; | ||
}`, | ||
}, | ||
// Ternaries with clear fallback | ||
{ | ||
code: `function Component() { | ||
return Math.random() > 0.5 ? ( | ||
<div> | ||
Big! | ||
No, really big! | ||
</div> | ||
) : <div>Small!</div>; | ||
}`, | ||
errors: [{ messageId: "noConditionalReturn" }], | ||
output: `function Component() { | ||
return <Show when={Math.random() > 0.5} fallback={<div>Small!</div>}><div> | ||
Big! | ||
No, really big! | ||
</div></Show>; | ||
}`, | ||
}, | ||
], | ||
}); |