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;