From 38d64428b44309d8c4ae6ddce51dfd58661e0ff5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peter=20Ja=C5=A1ko?= Date: Thu, 11 Dec 2025 12:57:03 +0100 Subject: [PATCH 1/5] Allow non-leading underscores --- src/only-export-components.test.ts | 4 ++++ src/only-export-components.ts | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/only-export-components.test.ts b/src/only-export-components.test.ts index f3ac18e..314db25 100755 --- a/src/only-export-components.test.ts +++ b/src/only-export-components.test.ts @@ -29,6 +29,10 @@ const valid = [ name: "Direct export AF component with number", code: "export const Foo2 = () => {};", }, + { + name: "Direct export AF component with underscore", + code: "export const Foo_ = () => {};", + }, { name: "Direct export uppercase function", code: "export function CMS() {};", diff --git a/src/only-export-components.ts b/src/only-export-components.ts index 48fdbc6..5ddcce5 100644 --- a/src/only-export-components.ts +++ b/src/only-export-components.ts @@ -1,7 +1,7 @@ import type { TSESLint } from "@typescript-eslint/utils"; import type { TSESTree } from "@typescript-eslint/types"; -const reactComponentNameRE = /^[A-Z][a-zA-Z0-9]*$/u; +const reactComponentNameRE = /^[A-Z][a-zA-Z0-9_]*$/u; export const onlyExportComponents: TSESLint.RuleModule< | "exportAll" From eb451871670f23652e051e942870520abd957b98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peter=20Ja=C5=A1ko?= Date: Thu, 11 Dec 2025 13:04:14 +0100 Subject: [PATCH 2/5] Add negative test case for underscore --- src/only-export-components.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/only-export-components.test.ts b/src/only-export-components.test.ts index 314db25..166e1c1 100755 --- a/src/only-export-components.test.ts +++ b/src/only-export-components.test.ts @@ -230,6 +230,11 @@ const invalid = [ code: "export const foo = () => {}; export const Bar = () => {};", errorId: "namedExport", }, + { + name: "Component and underscored component", + code: "export const _Foo = () => {}; export const Foo = () => {};", + errorId: "namedExport", + }, { name: "Component and function with allowConstantExport", code: "export const foo = () => {}; export const Bar = () => {};", From 83560c8330d0854d6123f2fc5a76f6fd2657c26b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 17 Dec 2025 10:23:49 +0000 Subject: [PATCH 3/5] Initial plan From 96d3498cb347e304889f348d35b8600e4172013d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 17 Dec 2025 10:29:20 +0000 Subject: [PATCH 4/5] Handle curried HOC components Co-authored-by: jaskp <35872026+jaskp@users.noreply.github.com> --- bun.lock | 1 + src/only-export-components.test.ts | 10 ++++++++++ src/only-export-components.ts | 24 +++++++++++++++++------- 3 files changed, 28 insertions(+), 7 deletions(-) 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..a41db22 100644 --- a/src/only-export-components.ts +++ b/src/only-export-components.ts @@ -78,6 +78,19 @@ export const onlyExportComponents: TSESLint.RuleModule< : undefined; const reactHOCs = ["memo", "forwardRef", ...customHOCs]; + const isHOCCallee = (node: TSESTree.Expression): boolean => { + if (node.type === "Identifier") return reactHOCs.includes(node.name); + if ( + node.type === "MemberExpression" + && node.property.type === "Identifier" + ) { + return reactHOCs.includes(node.property.name); + } + if (node.type === "CallExpression") { + return isHOCCallee(node.callee); + } + return false; + }; const canBeReactFunctionComponent = (init: TSESTree.Expression | null) => { if (!init) return false; const jsInit = skipTSWrapper(init); @@ -166,15 +179,12 @@ export const onlyExportComponents: TSESLint.RuleModule< (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)); + || isHOCCallee(node.callee); if (!isCalleeHOC) return false; if (node.arguments.length === 0) return false; + if (node.callee.type === "CallExpression" && isHOCCallee(node.callee)) { + return true; + } const arg = skipTSWrapper(node.arguments[0]); switch (arg.type) { case "Identifier": From 2b28d15e68ff47dc0c530d0600e54486a1ec0f8f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 17 Dec 2025 10:39:23 +0000 Subject: [PATCH 5/5] Refine curried HOC detection Co-authored-by: jaskp <35872026+jaskp@users.noreply.github.com> --- src/only-export-components.ts | 60 ++++++++++++++++++++++------------- 1 file changed, 38 insertions(+), 22 deletions(-) diff --git a/src/only-export-components.ts b/src/only-export-components.ts index a41db22..8a348a9 100644 --- a/src/only-export-components.ts +++ b/src/only-export-components.ts @@ -78,16 +78,29 @@ 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 => { - if (node.type === "Identifier") return reactHOCs.includes(node.name); - if ( - node.type === "MemberExpression" - && node.property.type === "Identifier" - ) { - return reactHOCs.includes(node.property.name); - } - if (node.type === "CallExpression") { - return isHOCCallee(node.callee); + 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; }; @@ -97,9 +110,11 @@ export const onlyExportComponents: TSESLint.RuleModule< 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; }; @@ -173,18 +188,19 @@ 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") - || isHOCCallee(node.callee); - if (!isCalleeHOC) return false; - if (node.arguments.length === 0) return false; - if (node.callee.type === "CallExpression" && isHOCCallee(node.callee)) { - return true; + 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) { case "Identifier":