diff --git a/bun.lock b/bun.lock index f23be59..6f82ae9 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "eslint-plugin-react-refresh", diff --git a/src/only-export-components.test.ts b/src/only-export-components.test.ts index 166e1c1..24aa291 100755 --- a/src/only-export-components.test.ts +++ b/src/only-export-components.test.ts @@ -65,6 +65,16 @@ const valid = [ name: "styled components", code: "export const Foo = () => {}; export const Bar = styled.div`padding-bottom: 6px;`;", }, + { + name: "Curried custom HOC with object config", + code: "export const Flex = styled('div')({display: 'flex'});", + options: [{ customHOCs: ["styled"] }], + }, + { + name: "Curried custom HOC default export", + code: "export default styled('div')({display: 'flex'});", + options: [{ customHOCs: ["styled"] }], + }, { name: "Direct export variable", code: "export const foo = 3;", diff --git a/src/only-export-components.ts b/src/only-export-components.ts index 5ddcce5..8a348a9 100644 --- a/src/only-export-components.ts +++ b/src/only-export-components.ts @@ -78,15 +78,43 @@ export const onlyExportComponents: TSESLint.RuleModule< : undefined; const reactHOCs = ["memo", "forwardRef", ...customHOCs]; + // Prevent pathological AST shapes from creating an endless traversal when + // walking nested HOC calls. + const maxHOCCalleeDepth = 20; + const isHOCCallee = (node: TSESTree.Expression): boolean => { + let current: TSESTree.Expression | null = node; + let depth = 0; + while (current) { + if (depth++ >= maxHOCCalleeDepth) return false; + if (current.type === "Identifier") { + return reactHOCs.includes(current.name); + } + if ( + current.type === "MemberExpression" + && current.property.type === "Identifier" + ) { + return reactHOCs.includes(current.property.name); + } + if (current.type === "CallExpression") { + current = current.callee; + } else { + break; + } + depth += 1; + } + return false; + }; const canBeReactFunctionComponent = (init: TSESTree.Expression | null) => { if (!init) return false; const jsInit = skipTSWrapper(init); if (jsInit.type === "ArrowFunctionExpression") return true; if ( jsInit.type === "CallExpression" - && jsInit.callee.type === "Identifier" ) { - return reactHOCs.includes(jsInit.callee.name); + if (jsInit.callee.type === "Identifier") { + return reactHOCs.includes(jsInit.callee.name); + } + return isHOCCallee(jsInit.callee); } return false; }; @@ -160,20 +188,18 @@ export const onlyExportComponents: TSESLint.RuleModule< const isHOCCallExpression = ( node: TSESTree.CallExpression, ): boolean => { - const isCalleeHOC = - // support for react-redux - // export default connect(mapStateToProps, mapDispatchToProps)(...) - (node.callee.type === "CallExpression" - && node.callee.callee.type === "Identifier" - && node.callee.callee.name === "connect") - // React.memo(...) - || (node.callee.type === "MemberExpression" - && node.callee.property.type === "Identifier" - && reactHOCs.includes(node.callee.property.name)) - // memo(...) - || (node.callee.type === "Identifier" - && reactHOCs.includes(node.callee.name)); - if (!isCalleeHOC) return false; + const calleeIsCallExpression = node.callee.type === "CallExpression"; + const calleeIsConnectCallExpression = + calleeIsCallExpression + && node.callee.callee.type === "Identifier" + && node.callee.callee.name === "connect"; + const calleeIsHOC = isHOCCallee(node.callee); + if (calleeIsCallExpression && calleeIsHOC) { + // Calls to a curried HOC should be treated as components even if + // the last argument isn't itself a component. + return node.arguments.length > 0; + } + if (!calleeIsConnectCallExpression && !calleeIsHOC) return false; if (node.arguments.length === 0) return false; const arg = skipTSWrapper(node.arguments[0]); switch (arg.type) {