diff --git a/CHANGELOG.md b/CHANGELOG.md index c83f027..67ff529 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,54 @@ # Changelog +## 0.5.0 + +### Breaking changes + +- Packages now ships as ESM and requires ESLint 9 + node 20 +- Validation of HOCs calls is now more strict, you may need to add some HOCs to the `customHOCs` option +- Configs are now functions that return the config object with passed options merged with the base options of that config + +Example: + +```js +import { defineConfig } from "eslint/config"; +import reactRefresh from "eslint-plugin-react-refresh"; + +export default defineConfig( + /* Main config */ + reactRefresh.configs.vite({ customHOCs: ["connect"] }), +); +``` + +### Why + +This version follows a revamp of the internal logic to better make the difference between random call expressions like `export const Enum = Object.keys(Record)` and actual React HOC calls like `export const MemoComponent = memo(Component)`. (fixes [#93](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/issues/93)) + +The rule now handles ternaries and patterns like `export default customHOC(props)(Component)` which makes it able to correctly support files like [this one](https://github.com/eclipse-apoapsis/ort-server/blob/ddfc624ce71b9f2ca6bad9b8c82d4c3249dd9c8b/ui/src/routes/__root.tsx) given this config: + +```json +{ + "react-refresh/only-export-components": [ + "warn", + { "customHOCs": ["createRootRouteWithContext"] } + ] +} +``` + +> [!NOTE] +> Actually createRoute functions from TanStack Router are not React HOCs, they return route objects that [fake to be a memoized component](https://github.com/TanStack/router/blob/8628d0189412ccb8d3a01840aa18bac8295e18c8/packages/react-router/src/route.tsx#L263) but are not. When only doing `createRootRoute({ component: Foo })`, HMR will work fine, but as soon as you add a prop to the options that is not a React component, HMR will not work. I would recommend to avoid adding any TanStack function to `customHOCs` it you want to preserve good HMR in the long term. [Bluesky thread](https://bsky.app/profile/arnaud-barre.bsky.social/post/3ma5h5tf2sk2e). + +Because I'm not 100% sure this new logic doesn't introduce any false positive, this is done in a major-like version. This also give me the occasion to remove the hardcoded `connect` from the rule. If you are using `connect` from `react-redux`, you should now add it to `customHOCs` like this: + +```json +{ + "react-refresh/only-export-components": [ + "warn", + { "customHOCs": ["connect"] } + ] +} +``` + ## 0.4.26 - Revert changes to fix [#93](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/issues/93) (fixes [#95](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/issues/95)) diff --git a/README.md b/README.md index ea9a0cf..df99bb3 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ import reactRefresh from "eslint-plugin-react-refresh"; export default defineConfig( /* Main config */ - reactRefresh.configs.recommended, + reactRefresh.configs.recommended(), // Or reactRefresh.configs.vite for Vite users ); ``` @@ -52,11 +52,11 @@ import reactRefresh from "eslint-plugin-react-refresh"; export default defineConfig( /* Main config */ - reactRefresh.configs.vite, + reactRefresh.configs.vite(), ); ``` -### Next config (v0.4.21) +### Next config This allows exports like `fetchCache` and `revalidate` which are used in Page or Layout components and don't trigger a full page reload. @@ -66,7 +66,7 @@ import reactRefresh from "eslint-plugin-react-refresh"; export default defineConfig( /* Main config */ - reactRefresh.configs.next, + reactRefresh.configs.next(), ); ``` @@ -87,17 +87,6 @@ export default defineConfig({ }); ``` -### Legacy config - -```jsonc -{ - "plugins": ["react-refresh"], - "rules": { - "react-refresh/only-export-components": "error", - }, -} -``` - ## Examples These examples are from enabling `react-refresh/only-exports-components`. @@ -152,20 +141,33 @@ These options are all present on `react-refresh/only-exports-components`. ```ts interface Options { + customHOCs?: string[]; allowExportNames?: string[]; allowConstantExport?: boolean; - customHOCs?: string[]; checkJS?: boolean; } const defaultOptions: Options = { + customHOCs: [], allowExportNames: [], allowConstantExport: false, - customHOCs: [], checkJS: false, }; ``` +### customHOCs (v0.4.15) + +If you're exporting a component wrapped in a custom HOC, you can use this option to avoid false positives. + +```json +{ + "react-refresh/only-export-components": [ + "error", + { "customHOCs": ["observer", "withAuth"] } + ] +} +``` + ### allowExportNames (v0.4.4) > Default: `[]` @@ -218,16 +220,3 @@ If you're using JSX inside `.js` files (which I don't recommend because it force "react-refresh/only-export-components": ["error", { "checkJS": true }] } ``` - -### customHOCs (v0.4.15) - -If you're exporting a component wrapped in a custom HOC, you can use this option to avoid false positives. - -```json -{ - "react-refresh/only-export-components": [ - "error", - { "customHOCs": ["observer", "withAuth"] } - ] -} -``` diff --git a/package.json b/package.json index ecb118b..7c6f1c8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "eslint-plugin-react-refresh", - "version": "0.4.26", + "version": "0.5.0", "type": "module", "license": "MIT", "scripts": { @@ -15,7 +15,7 @@ "experimentalOperatorPosition": "start" }, "peerDependencies": { - "eslint": ">=8.40" + "eslint": ">=9" }, "devDependencies": { "@arnaud-barre/eslint-config": "^6.1.2", diff --git a/scripts/bundle.ts b/scripts/bundle.ts index e39912f..8410e73 100755 --- a/scripts/bundle.ts +++ b/scripts/bundle.ts @@ -12,7 +12,8 @@ await build({ entryPoints: ["src/index.ts"], outdir: "dist", platform: "node", - target: "node14", + format: "esm", + target: "node20", external: Object.keys(packageJSON.peerDependencies), }); @@ -27,12 +28,16 @@ writeFileSync( description: "Validate that your components can safely be updated with Fast Refresh", version: packageJSON.version, - type: "commonjs", + type: "module", author: "Arnaud Barré (https://github.com/ArnaudBarre)", license: packageJSON.license, repository: "github:ArnaudBarre/eslint-plugin-react-refresh", - main: "index.js", - types: "index.d.ts", + exports: { + ".": { + types: "./index.d.ts", + default: "./index.js", + }, + }, keywords: [ "eslint", "eslint-plugin", diff --git a/src/index.ts b/src/index.ts index d8e8622..a9a728d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,5 @@ import { onlyExportComponents } from "./only-export-components.ts"; +import type { OnlyExportComponentsOptions } from "./types.d.ts"; export const rules = { "only-export-components": onlyExportComponents, @@ -6,56 +7,59 @@ export const rules = { const plugin = { rules }; -export const configs = { - recommended: { - name: "react-refresh/recommended", - plugins: { "react-refresh": plugin }, - rules: { "react-refresh/only-export-components": "error" }, - }, - vite: { - name: "react-refresh/vite", +const buildConfig = + ({ + name, + baseOptions, + }: { + name: string; + baseOptions: OnlyExportComponentsOptions; + }) => + (options?: OnlyExportComponentsOptions) => ({ + name: `react-refresh/${name}`, plugins: { "react-refresh": plugin }, rules: { "react-refresh/only-export-components": [ "error", - { allowConstantExport: true }, + { ...baseOptions, ...options }, ], }, - }, - next: { - name: "react-refresh/next", - plugins: { "react-refresh": plugin }, - rules: { - "react-refresh/only-export-components": [ - "error", - { - allowExportNames: [ - // https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config - "experimental_ppr", - "dynamic", - "dynamicParams", - "revalidate", - "fetchCache", - "runtime", - "preferredRegion", - "maxDuration", - // https://nextjs.org/docs/app/api-reference/functions/generate-metadata - "metadata", - "generateMetadata", - // https://nextjs.org/docs/app/api-reference/functions/generate-viewport - "viewport", - "generateViewport", - // https://nextjs.org/docs/app/api-reference/functions/generate-image-metadata - "generateImageMetadata", - // https://nextjs.org/docs/app/api-reference/functions/generate-sitemaps - "generateSitemaps", - // https://nextjs.org/docs/app/api-reference/functions/generate-static-params - "generateStaticParams", - ], - }, + }); + +export const configs = { + recommended: buildConfig({ name: "recommended", baseOptions: {} }), + vite: buildConfig({ + name: "vite", + baseOptions: { allowConstantExport: true }, + }), + next: buildConfig({ + name: "next", + baseOptions: { + allowExportNames: [ + // https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config + "experimental_ppr", + "dynamic", + "dynamicParams", + "revalidate", + "fetchCache", + "runtime", + "preferredRegion", + "maxDuration", + // https://nextjs.org/docs/app/api-reference/functions/generate-metadata + "metadata", + "generateMetadata", + // https://nextjs.org/docs/app/api-reference/functions/generate-viewport + "viewport", + "generateViewport", + // https://nextjs.org/docs/app/api-reference/functions/generate-image-metadata + "generateImageMetadata", + // https://nextjs.org/docs/app/api-reference/functions/generate-sitemaps + "generateSitemaps", + // https://nextjs.org/docs/app/api-reference/functions/generate-static-params + "generateStaticParams", ], }, - }, + }), }; // Probably not needed, but keep for backwards compatibility diff --git a/src/only-export-components.test.ts b/src/only-export-components.test.ts index 166e1c1..538492f 100755 --- a/src/only-export-components.test.ts +++ b/src/only-export-components.test.ts @@ -1,10 +1,16 @@ import parser from "@typescript-eslint/parser"; import { RuleTester } from "eslint"; import { onlyExportComponents } from "./only-export-components.ts"; +import type { OnlyExportComponentsOptions } from "./types.d.ts"; const ruleTester = new RuleTester({ languageOptions: { parser } }); -const valid = [ +const valid: { + name: string; + code: string; + filename?: string; + options?: OnlyExportComponentsOptions; +}[] = [ { name: "Direct export named component", code: "export function Foo() {};", @@ -64,6 +70,27 @@ const valid = [ { name: "styled components", code: "export const Foo = () => {}; export const Bar = styled.div`padding-bottom: 6px;`;", + options: { customHOCs: ["styled"] }, + }, + { + name: "styled components", + code: "export const Foo = () => {}; export const Flex = styled.div({ display: 'flex' });", + options: { customHOCs: ["styled"] }, + }, + { + name: "Curried HOC with styled (object form)", + code: "export const Foo = () => {}; export const Flex = styled('div')({display: 'flex'});", + options: { customHOCs: ["styled"] }, + }, + { + name: "Curried HOC with styled (template literal form)", + code: "export const Foo = () => {}; export const Flex = styled('div')`display: flex;`;", + options: { customHOCs: ["styled"] }, + }, + { + name: "Curried HOC only first call", + code: "export const Foo = () => {}; export const Flex = styled('div');", + options: { customHOCs: ["styled"] }, }, { name: "Direct export variable", @@ -133,48 +160,48 @@ const valid = [ name: "Mixed export in JS without react import", code: "export const foo = () => {}; export const Bar = () => {};", filename: "Test.js", - options: [{ checkJS: true }], + options: { checkJS: true }, }, { name: "Component and number constant with allowConstantExport", code: "export const foo = 4; export const Bar = () => {};", - options: [{ allowConstantExport: true }], + options: { allowConstantExport: true }, }, { name: "Component and negative number constant with allowConstantExport", code: "export const foo = -4; export const Bar = () => {};", - options: [{ allowConstantExport: true }], + options: { allowConstantExport: true }, }, { name: "Component and string constant with allowConstantExport", code: "export const CONSTANT = 'Hello world'; export const Foo = () => {};", - options: [{ allowConstantExport: true }], + options: { allowConstantExport: true }, }, { name: "Component and template literal with allowConstantExport", // eslint-disable-next-line no-template-curly-in-string code: "const foo = 'world'; export const CONSTANT = `Hello ${foo}`; export const Foo = () => {};", - options: [{ allowConstantExport: true }], + options: { allowConstantExport: true }, }, { name: "Component and allowed export", code: "export const loader = () => {}; export const Bar = () => {};", - options: [{ allowExportNames: ["loader", "meta"] }], + options: { allowExportNames: ["loader", "meta"] }, }, { name: "Component and allowed function export", code: "export function loader() {}; export const Bar = () => {};", - options: [{ allowExportNames: ["loader", "meta"] }], + options: { allowExportNames: ["loader", "meta"] }, }, { name: "Only allowed exports without component", code: "export const loader = () => {}; export const meta = { title: 'Home' };", - options: [{ allowExportNames: ["loader", "meta"] }], + options: { allowExportNames: ["loader", "meta"] }, }, { name: "Component and viewport export for Next.js", code: "export const viewport = { width: 'device-width', initialScale: 1 }; export const Page = () => {};", - options: [{ allowExportNames: ["viewport"] }], + options: { allowExportNames: ["viewport"] }, }, { name: "Export as default", @@ -183,6 +210,7 @@ const valid = [ { name: "Allow connect from react-redux", code: "const MyComponent = () => {}; export default connect(() => ({}))(MyComponent);", + options: { customHOCs: ["connect"] }, }, { name: "Two components, one of them with 'Context' in its name", @@ -199,7 +227,7 @@ const valid = [ { name: "Custom HOCs like mobx's observer", code: "const MyComponent = () => {}; export default observer(MyComponent);", - options: [{ customHOCs: ["observer"] }], + options: { customHOCs: ["observer"] }, }, { name: "Local constant with component casing and non component function", @@ -208,7 +236,7 @@ const valid = [ { name: "Component and as const constant with allowConstantExport", code: "export const MyComponent = () => {}; export const MENU_WIDTH = 232 as const;", - options: [{ allowConstantExport: true }], + options: { allowConstantExport: true }, }, { name: "Type assertion in memo export", @@ -222,9 +250,40 @@ const valid = [ name: "Nested memo HOC", code: "export const MyComponent = () => {}; export default memo(forwardRef(MyComponent));", }, + { + name: "Allow ternaries if both branches are components", + code: "export const Devtools = import.meta.env.PROD ? () => null : React.lazy(() => import('devtools')); export const OtherComponent = () => {};", + }, + { + name: "TanStack Router", + code: "const RootComponent = () => {}; export const Route = createRootRoute()({ component: RootComponent });", + options: { customHOCs: ["createRootRoute"] }, + }, + { + name: "Rename export", + code: "export const Link = () => {}; export const RenamedLink = Link;", + }, + { + name: "Type instantiation expression", + code: "export const Link = () => {}; export const TypedLink = Link;", + }, + { + name: "Class component", + code: "export class MyComponent extends React.Component { render() { return
Hello
; } }", + }, + { + name: "Export default class component", + code: "export default class MyComponent extends Component { render() { return
Hello
; } }", + }, ]; -const invalid = [ +const invalid: { + name: string; + code: string; + errorId: string; + filename?: string; + options?: OnlyExportComponentsOptions; +}[] = [ { name: "Component and function", code: "export const foo = () => {}; export const Bar = () => {};", @@ -239,7 +298,7 @@ const invalid = [ name: "Component and function with allowConstantExport", code: "export const foo = () => {}; export const Bar = () => {};", errorId: "namedExport", - options: [{ allowConstantExport: true }], + options: { allowConstantExport: true }, }, { name: "Component and variable (direct export)", @@ -303,19 +362,19 @@ const invalid = [ export const CONSTANT = 3; export const Foo = () => {}; `, filename: "Test.js", - options: [{ checkJS: true }], + options: { checkJS: true }, errorId: "namedExport", }, { name: "export default compose", - code: "export default compose()(MainView);", + code: "const MainView = () => {}; export default compose()(MainView);", filename: "Test.jsx", - errorId: "anonymousExport", + errorId: "localComponents", }, { name: "Component and export non in allowExportNames", code: "export const loader = () => {}; export const Bar = () => {}; export const foo = () => {};", - options: [{ allowExportNames: ["loader", "meta"] }], + options: { allowExportNames: ["loader", "meta"] }, errorId: "namedExport", }, { @@ -336,7 +395,27 @@ const invalid = [ { name: "should be invalid when custom HOC is used without adding it to the rule configuration", code: "const MyComponent = () => {}; export default observer(MyComponent);", - errorId: ["localComponents", "anonymousExport"], + errorId: "localComponents", + }, + { + name: "Object.keys", + code: "const MyComponent = () => {}; export const ENUM = Object.keys(TABLE) as EnumType[];", + errorId: "localComponents", + }, + { + name: "Don't allow ternaries if a branch is not a component", + code: "export const DevtoolsNotComponentInProd = import.meta.env.PROD ? null : React.lazy(() => import('devtools')); export const OtherComponent = () => {};", + errorId: "namedExport", + }, + { + name: "Component and non React Class component", + code: "export const Foo = () => {}; export class MyComponent { bar() { return
Hello
; } }", + errorId: "namedExport", + }, + { + name: "Export default anonymous class component", + code: "export default class { bar() { return
Hello
; } }", + errorId: "anonymousExport", }, ]; @@ -349,14 +428,20 @@ const it = (name: string, cases: Parameters[2]) => { ); }; -for (const { name, code, filename, options = [] } of valid) { +for (const { name, code, filename, options } of valid) { it(name, { - valid: [{ filename: filename ?? "Test.jsx", code, options }], + valid: [ + { + code, + filename: filename ?? "Test.tsx", + options: options ? [options] : [], + }, + ], invalid: [], }); } -for (const { name, code, errorId, filename, options = [] } of invalid) { +for (const { name, code, errorId, filename, options } of invalid) { it(name, { valid: [], invalid: [ @@ -366,7 +451,7 @@ for (const { name, code, errorId, filename, options = [] } of invalid) { errors: Array.isArray(errorId) ? errorId.map((messageId) => ({ messageId })) : [{ messageId: errorId }], - options, + options: options ? [options] : [], }, ], }); diff --git a/src/only-export-components.ts b/src/only-export-components.ts index 5ddcce5..8ed16b4 100644 --- a/src/only-export-components.ts +++ b/src/only-export-components.ts @@ -1,5 +1,6 @@ import type { TSESLint } from "@typescript-eslint/utils"; import type { TSESTree } from "@typescript-eslint/types"; +import type { OnlyExportComponentsOptions } from "./types.d.ts"; const reactComponentNameRE = /^[A-Z][a-zA-Z0-9_]*$/u; @@ -10,15 +11,7 @@ export const onlyExportComponents: TSESLint.RuleModule< | "noExport" | "localComponents" | "reactContext", - | [] - | [ - { - allowExportNames?: string[]; - allowConstantExport?: boolean; - customHOCs?: string[]; - checkJS?: boolean; - }, - ] + [] | [OnlyExportComponentsOptions] > = { meta: { messages: { @@ -40,9 +33,9 @@ export const onlyExportComponents: TSESLint.RuleModule< { type: "object", properties: { + customHOCs: { type: "array", items: { type: "string" } }, allowExportNames: { type: "array", items: { type: "string" } }, allowConstantExport: { type: "boolean" }, - customHOCs: { type: "array", items: { type: "string" } }, checkJS: { type: "boolean" }, }, additionalProperties: false, @@ -52,9 +45,9 @@ export const onlyExportComponents: TSESLint.RuleModule< defaultOptions: [], create: (context) => { const { + customHOCs = [], allowExportNames, allowConstantExport = false, - customHOCs = [], checkJS = false, } = context.options[0] ?? {}; const filename = context.filename; @@ -77,16 +70,102 @@ export const onlyExportComponents: TSESLint.RuleModule< ? new Set(allowExportNames) : undefined; - const reactHOCs = ["memo", "forwardRef", ...customHOCs]; - const canBeReactFunctionComponent = (init: TSESTree.Expression | null) => { - if (!init) return false; - const jsInit = skipTSWrapper(init); - if (jsInit.type === "ArrowFunctionExpression") return true; + const validHOCs = ["memo", "forwardRef", "lazy", ...customHOCs]; + const getHocName = ( + node: TSESTree.CallExpression | TSESTree.TaggedTemplateExpression, + ): string | undefined => { + const callee = node.type === "CallExpression" ? node.callee : node.tag; + // react-redux: connect(mapStateToProps, mapDispatchToProps)(...); + // TanStack: createRootRoute()({ component: Foo }); + // styled-components: styled('div')`display: flex;`; or styled('div')({ display: 'flex' }); if ( - jsInit.type === "CallExpression" - && jsInit.callee.type === "Identifier" + callee.type === "CallExpression" + && callee.callee.type === "Identifier" ) { - return reactHOCs.includes(jsInit.callee.name); + return getHocName(callee); + } + // React.memo(...) + // styled.div`display: flex;`; or styled.div({ display: 'flex' }); + if (callee.type === "MemberExpression") { + if ( + callee.property.type === "Identifier" + && validHOCs.includes(callee.property.name) + ) { + return callee.property.name; + } + if ( + callee.object.type === "Identifier" + && validHOCs.includes(callee.object.name) + ) { + return callee.object.name; + } + } + + // memo(...) + if (callee.type === "Identifier") { + return callee.name; + } + return undefined; + }; + + const isCallExpressionReactComponent = ( + node: TSESTree.CallExpression, + ): boolean | "needName" => { + const hocName = getHocName(node); + if (!hocName || !validHOCs.includes(hocName)) return false; + const validateArgument = hocName === "memo" || hocName === "forwardRef"; + if (!validateArgument) return true; + if (node.arguments.length === 0) return false; + const arg = skipTSWrapper(node.arguments[0]); + switch (arg.type) { + case "Identifier": + // memo(Component) + return reactComponentNameRE.test(arg.name); + case "FunctionExpression": + case "ArrowFunctionExpression": + if (!arg.id) return "needName"; + // memo(function Component() {}) + return reactComponentNameRE.test(arg.id.name); + case "CallExpression": + // memo(forwardRef(...)) + return isCallExpressionReactComponent(arg); + default: + return false; + } + }; + + const isExpressionReactComponent = ( + expressionParam: TSESTree.Expression, + ): boolean | "needName" => { + const exp = skipTSWrapper(expressionParam); + if (exp.type === "Identifier") { + return reactComponentNameRE.test(exp.name); + } + if ( + exp.type === "ArrowFunctionExpression" + || exp.type === "FunctionExpression" + ) { + if (exp.params.length > 2) return false; + if (!exp.id?.name) return "needName"; + return reactComponentNameRE.test(exp.id.name); + } + if (exp.type === "ConditionalExpression") { + const consequent = isExpressionReactComponent(exp.consequent); + const alternate = isExpressionReactComponent(exp.alternate); + if (consequent === false || alternate === false) return false; + if (consequent === "needName" || alternate === "needName") { + return "needName"; + } + return true; + } + if (exp.type === "CallExpression") { + return isCallExpressionReactComponent(exp); + } + // Support styled-components + if (exp.type === "TaggedTemplateExpression") { + const hocName = getHocName(exp); + if (!hocName || !validHOCs.includes(hocName)) return false; + return "needName"; } return false; }; @@ -97,126 +176,100 @@ export const onlyExportComponents: TSESLint.RuleModule< let hasReactExport = false; let reactIsInScope = false; const localComponents: TSESTree.Identifier[] = []; - const nonComponentExports: ( - | TSESTree.BindingName - | TSESTree.StringLiteral - )[] = []; + const nonComponentExports: TSESTree.ExportDeclaration[] = []; const reactContextExports: TSESTree.Identifier[] = []; const handleExportIdentifier = ( identifierNode: TSESTree.BindingName | TSESTree.StringLiteral, - isFunction?: boolean, - init?: TSESTree.Expression | null, + initParam?: TSESTree.Expression, ) => { if (identifierNode.type !== "Identifier") { nonComponentExports.push(identifierNode); return; } if (allowExportNamesSet?.has(identifierNode.name)) return; - if ( - allowConstantExport - && init - && constantExportExpressions.has(skipTSWrapper(init).type) - ) { - return; - } - if (isFunction) { - if (reactComponentNameRE.test(identifierNode.name)) { - hasReactExport = true; - } else { - nonComponentExports.push(identifierNode); - } - } else { - if ( - init - && init.type === "CallExpression" - // createContext || React.createContext - && ((init.callee.type === "Identifier" - && init.callee.name === "createContext") - || (init.callee.type === "MemberExpression" - && init.callee.property.type === "Identifier" - && init.callee.property.name === "createContext")) - ) { - reactContextExports.push(identifierNode); - return; - } - if ( - init - // Switch to allowList? - && notReactComponentExpression.has(init.type) - ) { - nonComponentExports.push(identifierNode); - return; - } + if (!initParam) { if (reactComponentNameRE.test(identifierNode.name)) { hasReactExport = true; } else { nonComponentExports.push(identifierNode); } + return; } - }; - 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; - if (node.arguments.length === 0) return false; - const arg = skipTSWrapper(node.arguments[0]); - switch (arg.type) { - case "Identifier": - // memo(Component) - return true; - case "FunctionExpression": - if (!arg.id) return false; - // memo(function Component() {}) - handleExportIdentifier(arg.id, true); - return true; - case "CallExpression": - // memo(forwardRef(...)) - return isHOCCallExpression(arg); - default: - return false; + const init = skipTSWrapper(initParam); + if (allowConstantExport && constantExportExpressions.has(init.type)) { + return; + } + + if ( + init.type === "CallExpression" + // createContext || React.createContext + && ((init.callee.type === "Identifier" + && init.callee.name === "createContext") + || (init.callee.type === "MemberExpression" + && init.callee.property.type === "Identifier" + && init.callee.property.name === "createContext")) + ) { + reactContextExports.push(identifierNode); + return; + } + + const isReactComponent = + reactComponentNameRE.test(identifierNode.name) + && isExpressionReactComponent(init); + + if (isReactComponent === false) { + nonComponentExports.push(identifierNode); + } else { + hasReactExport = true; } }; const handleExportDeclaration = (node: TSESTree.ExportDeclaration) => { if (node.type === "VariableDeclaration") { for (const variable of node.declarations) { - handleExportIdentifier( - variable.id, - canBeReactFunctionComponent(variable.init), - variable.init, - ); + if (variable.init === null) { + nonComponentExports.push(variable.id); + continue; + } + handleExportIdentifier(variable.id, variable.init); } } else if (node.type === "FunctionDeclaration") { if (node.id === null) { context.report({ messageId: "anonymousExport", node }); } else { - handleExportIdentifier(node.id, true); + handleExportIdentifier(node.id); } - } else if (node.type === "CallExpression") { - const isValid = isHOCCallExpression(node); - if (isValid) { + } else if (node.type === "ClassDeclaration") { + if (node.id === null) { + context.report({ messageId: "anonymousExport", node }); + } else if ( + reactComponentNameRE.test(node.id.name) + && node.superClass !== null + && node.body.body.some( + (item) => + item.type === "MethodDefinition" + && item.key.type === "Identifier" + && item.key.name === "render", + ) + ) { hasReactExport = true; } else { + nonComponentExports.push(node.id); + } + } else if (node.type === "CallExpression") { + const result = isCallExpressionReactComponent(node); + if (result === false) { + nonComponentExports.push(node); + } else if (result === "needName") { context.report({ messageId: "anonymousExport", node }); + } else { + hasReactExport = true; } - } else if (node.type === "TSEnumDeclaration") { - nonComponentExports.push(node.id); + } else { + nonComponentExports.push(node); } }; @@ -231,6 +284,7 @@ export const onlyExportComponents: TSESLint.RuleModule< if ( declaration.type === "VariableDeclaration" || declaration.type === "FunctionDeclaration" + || declaration.type === "ClassDeclaration" || declaration.type === "CallExpression" ) { handleExportDeclaration(declaration); @@ -260,7 +314,8 @@ export const onlyExportComponents: TSESLint.RuleModule< if ( variable.id.type === "Identifier" && reactComponentNameRE.test(variable.id.name) - && canBeReactFunctionComponent(variable.init) + && variable.init !== null + && isExpressionReactComponent(variable.init) !== false ) { localComponents.push(variable.id); } @@ -304,7 +359,13 @@ export const onlyExportComponents: TSESLint.RuleModule< }; const skipTSWrapper = (node: T) => { - if (node.type === "TSAsExpression" || node.type === "TSSatisfiesExpression") { + if ( + node.type === "TSAsExpression" + || node.type === "TSSatisfiesExpression" + || node.type === "TSNonNullExpression" + || node.type === "TSTypeAssertion" + || node.type === "TSInstantiationExpression" + ) { return node.expression; } return node; @@ -319,19 +380,3 @@ const constantExportExpressions = new Set< "TemplateLiteral", // `Some ${template}` "BinaryExpression", // 24 * 60 ]); -const notReactComponentExpression = new Set< - ToString ->([ - "ArrayExpression", - "AwaitExpression", - "BinaryExpression", - "ChainExpression", - "ConditionalExpression", - "Literal", - "LogicalExpression", - "ObjectExpression", - "TemplateLiteral", - "ThisExpression", - "UnaryExpression", - "UpdateExpression", -]); diff --git a/src/types.d.ts b/src/types.d.ts index 29d520f..295ad6f 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -1,10 +1,20 @@ -type Config = { - plugins: { "react-refresh": { rules: Record } }; - rules: Record; +type Rules = { "only-export-components": any }; + +export type OnlyExportComponentsOptions = { + customHOCs?: string[]; + allowExportNames?: string[]; + allowConstantExport?: boolean; + checkJS?: boolean; +}; + +type Config = (options?: OnlyExportComponentsOptions) => { + name: string; + plugins: { "react-refresh": { rules: Rules } }; + rules: Rules; }; declare const _default: { - rules: Record; + rules: Rules; configs: { recommended: Config; vite: Config; @@ -12,4 +22,4 @@ declare const _default: { }; }; -export = _default; +export default _default;