Skip to content

Commit 86bddb3

Browse files
committed
Add changelog entry & simplify legacy name based only check
1 parent 478e778 commit 86bddb3

File tree

6 files changed

+52
-33
lines changed

6 files changed

+52
-33
lines changed

.github/workflows/ci.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ jobs:
88
runs-on: ubuntu-latest
99
steps:
1010
- uses: actions/checkout@v4
11+
- uses: actions/setup-node@v4
12+
with:
13+
node-version: 22
1114
- uses: oven-sh/setup-bun@v2
1215
- run: bun install
1316
- run: bun ci

CHANGELOG.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,27 @@
11
# Changelog
22

3+
## Unreleased
4+
5+
### Add support for custom HOCs (#60)
6+
7+
By default, the rule only knows that `memo` & `forwardRef` function calls with return a React component. With this option, you can also allow extra function names like Mobx observer to make this code valid:
8+
9+
```tsx
10+
const Foo = () => <></>;
11+
export default observer(Foo);
12+
```
13+
14+
```json
15+
{
16+
"react-refresh/only-export-components": [
17+
"error",
18+
{ "customHOCs": ["observer"] }
19+
]
20+
}
21+
```
22+
23+
Thanks @HorusGoul!
24+
325
## 0.4.14
426

527
- Warn if a context is exported alongside a component (fixes #53). Thanks @IgorAufricht!

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,14 +111,17 @@ These options are all present on `react-refresh/only-exports-components`.
111111

112112
```ts
113113
interface Options {
114+
allowExportNames?: string[];
114115
allowExportNames?: string[];
115116
allowConstantExport?: boolean;
117+
customHOCs?: string[];
116118
checkJS?: boolean;
117119
}
118120

119121
const defaultOptions: Options = {
120122
allowExportNames: [],
121123
allowConstantExport: false,
124+
customHOCs: [],
122125
checkJS: false,
123126
};
124127
```

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
"scripts": {
77
"build": "scripts/bundle.ts",
88
"test": "bun test",
9-
"lint": "bun --bun eslint . --max-warnings 0",
9+
"lint": "bun eslint . --max-warnings 0",
1010
"prettier": "bun prettier-ci --write",
1111
"prettier-ci": "prettier --ignore-path=.gitignore --check '**/*.{js,ts,json,md,yml}'",
1212
"ci": "tsc && bun lint && bun prettier-ci && bun test && bun run build"

scripts/bundle.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ writeFileSync(
2424
{
2525
name: packageJSON.name,
2626
description:
27-
"Validate that your components can safely be updated with fast refresh",
27+
"Validate that your components can safely be updated with Fast Refresh",
2828
version: packageJSON.version,
2929
author: "Arnaud Barré (https://github.com/ArnaudBarre)",
3030
license: packageJSON.license,

src/only-export-components.ts

Lines changed: 22 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,7 @@
11
import type { TSESLint } from "@typescript-eslint/utils";
22
import type { TSESTree } from "@typescript-eslint/types";
33

4-
const possibleReactExportRE = /^[A-Z][a-zA-Z0-9]*$/u;
5-
// Starts with uppercase and at least one lowercase
6-
// This can lead to some false positive (ex: `const CMS = () => <></>; export default CMS`)
7-
// But allow to catch `export const CONSTANT = 3`
8-
// and the false positive can be avoided with direct name export
9-
const strictReactExportRE = /^[A-Z][a-zA-Z0-9]*[a-z]+[a-zA-Z0-9]*$/u;
4+
const reactComponentNameRE = /^[A-Z][a-zA-Z0-9]*$/u;
105

116
export const onlyExportComponents: TSESLint.RuleModule<
127
| "exportAll"
@@ -18,10 +13,10 @@ export const onlyExportComponents: TSESLint.RuleModule<
1813
| []
1914
| [
2015
{
21-
allowConstantExport?: boolean;
22-
checkJS?: boolean;
2316
allowExportNames?: string[];
17+
allowConstantExport?: boolean;
2418
customHOCs?: string[];
19+
checkJS?: boolean;
2520
},
2621
]
2722
> = {
@@ -45,10 +40,10 @@ export const onlyExportComponents: TSESLint.RuleModule<
4540
{
4641
type: "object",
4742
properties: {
48-
allowConstantExport: { type: "boolean" },
49-
checkJS: { type: "boolean" },
5043
allowExportNames: { type: "array", items: { type: "string" } },
44+
allowConstantExport: { type: "boolean" },
5145
customHOCs: { type: "array", items: { type: "string" } },
46+
checkJS: { type: "boolean" },
5247
},
5348
additionalProperties: false,
5449
},
@@ -57,10 +52,10 @@ export const onlyExportComponents: TSESLint.RuleModule<
5752
defaultOptions: [],
5853
create: (context) => {
5954
const {
60-
allowConstantExport = false,
61-
checkJS = false,
6255
allowExportNames,
56+
allowConstantExport = false,
6357
customHOCs = [],
58+
checkJS = false,
6459
} = context.options[0] ?? {};
6560
const filename = context.filename;
6661
// Skip tests & stories files
@@ -82,20 +77,20 @@ export const onlyExportComponents: TSESLint.RuleModule<
8277
? new Set(allowExportNames)
8378
: undefined;
8479

85-
const reactHOCs = new Set(["memo", "forwardRef", ...customHOCs]);
80+
const reactHOCs = ["memo", "forwardRef", ...customHOCs];
8681
const canBeReactFunctionComponent = (init: TSESTree.Expression | null) => {
8782
if (!init) return false;
8883
if (init.type === "ArrowFunctionExpression") return true;
8984
if (init.type === "CallExpression" && init.callee.type === "Identifier") {
90-
return reactHOCs.has(init.callee.name);
85+
return reactHOCs.includes(init.callee.name);
9186
}
9287
return false;
9388
};
9489

9590
return {
9691
Program(program) {
9792
let hasExports = false;
98-
let mayHaveReactExport = false;
93+
let hasReactExport = false;
9994
let reactIsInScope = false;
10095
const localComponents: TSESTree.Identifier[] = [];
10196
const nonComponentExports: (
@@ -108,7 +103,7 @@ export const onlyExportComponents: TSESLint.RuleModule<
108103
identifierNode: TSESTree.BindingName,
109104
) => {
110105
if (identifierNode.type !== "Identifier") return;
111-
if (possibleReactExportRE.test(identifierNode.name)) {
106+
if (reactComponentNameRE.test(identifierNode.name)) {
112107
localComponents.push(identifierNode);
113108
}
114109
};
@@ -135,8 +130,8 @@ export const onlyExportComponents: TSESLint.RuleModule<
135130
}
136131

137132
if (isFunction) {
138-
if (possibleReactExportRE.test(identifierNode.name)) {
139-
mayHaveReactExport = true;
133+
if (reactComponentNameRE.test(identifierNode.name)) {
134+
hasReactExport = true;
140135
} else {
141136
nonComponentExports.push(identifierNode);
142137
}
@@ -162,13 +157,9 @@ export const onlyExportComponents: TSESLint.RuleModule<
162157
nonComponentExports.push(identifierNode);
163158
return;
164159
}
165-
if (
166-
!mayHaveReactExport &&
167-
possibleReactExportRE.test(identifierNode.name)
168-
) {
169-
mayHaveReactExport = true;
170-
}
171-
if (!strictReactExportRE.test(identifierNode.name)) {
160+
if (reactComponentNameRE.test(identifierNode.name)) {
161+
hasReactExport = true;
162+
} else {
172163
nonComponentExports.push(identifierNode);
173164
}
174165
}
@@ -197,21 +188,21 @@ export const onlyExportComponents: TSESLint.RuleModule<
197188
) {
198189
// support for react-redux
199190
// export default connect(mapStateToProps, mapDispatchToProps)(Comp)
200-
mayHaveReactExport = true;
191+
hasReactExport = true;
201192
} else if (node.callee.type !== "Identifier") {
202193
// we rule out non HoC first
203194
// export default React.memo(function Foo() {})
204195
// export default Preact.memo(function Foo() {})
205196
if (
206197
node.callee.type === "MemberExpression" &&
207198
node.callee.property.type === "Identifier" &&
208-
reactHOCs.has(node.callee.property.name)
199+
reactHOCs.includes(node.callee.property.name)
209200
) {
210-
mayHaveReactExport = true;
201+
hasReactExport = true;
211202
} else {
212203
context.report({ messageId: "anonymousExport", node });
213204
}
214-
} else if (!reactHOCs.has(node.callee.name)) {
205+
} else if (!reactHOCs.includes(node.callee.name)) {
215206
// we rule out non HoC first
216207
context.report({ messageId: "anonymousExport", node });
217208
} else if (
@@ -225,7 +216,7 @@ export const onlyExportComponents: TSESLint.RuleModule<
225216
// No need to check further, the identifier has necessarily a named,
226217
// and it would throw at runtime if it's not a React component.
227218
// We have React exports since we are exporting return value of HoC
228-
mayHaveReactExport = true;
219+
hasReactExport = true;
229220
} else {
230221
context.report({ messageId: "anonymousExport", node });
231222
}
@@ -289,7 +280,7 @@ export const onlyExportComponents: TSESLint.RuleModule<
289280

290281
if (hasExports) {
291282
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
292-
if (mayHaveReactExport) {
283+
if (hasReactExport) {
293284
for (const node of nonComponentExports) {
294285
context.report({ messageId: "namedExport", node });
295286
}

0 commit comments

Comments
 (0)