Skip to content

Commit 2ccc3fd

Browse files
committed
Catch components in entrypoints or with non-component exports [publish]
1 parent 628926f commit 2ccc3fd

File tree

5 files changed

+101
-16
lines changed

5 files changed

+101
-16
lines changed

CHANGELOG.md

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

3+
## 0.3.0
4+
5+
Report an error when a file that contains components that can't be fast-refreshed because:
6+
7+
- There are no export (entrypoint)
8+
- Exports are not components
9+
310
## 0.2.1
411

512
Doc only: Update limitations section README.md

README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,16 @@ export default function () {}
5757
export * from "./foo";
5858
```
5959

60+
```jsx
61+
const Tab = () => {};
62+
export const tabs = [<Tab />, <Tab />];
63+
```
64+
65+
```jsx
66+
const App = () => {};
67+
createRoot(document.getElementById("root")).render(<App />);
68+
```
69+
6070
## Pass
6171

6272
```jsx
@@ -69,3 +79,8 @@ export default function Foo() {
6979
const foo = () => {};
7080
export const Bar = () => <></>;
7181
```
82+
83+
```jsx
84+
import { App } from "./App";
85+
createRoot(document.getElementById("root")).render(<App />);
86+
```

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "eslint-plugin-react-refresh",
3-
"version": "0.2.1",
3+
"version": "0.3.0",
44
"license": "MIT",
55
"scripts": {
66
"build": "scripts/bundle.ts",

src/only-export-components.ts

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

4+
const possibleReactExportRE = /^[A-Z][a-zA-Z]*$/;
5+
// Only letters, starts with uppercase and at least one lowercase
6+
// This can lead to some false positive (ex: `const CMS = () => <></>`)
7+
// But allow to catch `export const CONSTANT = 3`
8+
const strictReactExportRE = /^[A-Z][a-zA-Z]*[a-z]+[a-zA-Z]*$/;
9+
410
export const onlyExportComponents: TSESLint.RuleModule<
5-
"exportAll" | "namedExport" | "anonymousExport"
11+
| "exportAll"
12+
| "namedExport"
13+
| "anonymousExport"
14+
| "noExport"
15+
| "localComponents"
616
> = {
717
meta: {
818
messages: {
@@ -12,6 +22,10 @@ export const onlyExportComponents: TSESLint.RuleModule<
1222
"Fast refresh only works when a file only export components. Use a new file to share constant or functions between components.",
1323
anonymousExport:
1424
"Fast refresh can't handle anonymous component. Add a name to your export.",
25+
localComponents:
26+
"Fast refresh only works when a file only export components. Move your component(s) to a separate file.",
27+
noExport:
28+
"Fast refresh only works when a file has exports. Move your component(s) to a separate file.",
1529
},
1630
type: "problem",
1731
schema: [],
@@ -22,67 +36,102 @@ export const onlyExportComponents: TSESLint.RuleModule<
2236

2337
return {
2438
Program(program) {
39+
let hasExports = false;
2540
let mayHaveReactExport = false;
41+
const localComponents: TSESTree.Identifier[] = [];
2642
const nonComponentExport: TSESTree.BindingName[] = [];
2743

28-
const handleIdentifier = (identifierNode: TSESTree.BindingName) => {
44+
const handleLocalIdentifier = (
45+
identifierNode: TSESTree.BindingName,
46+
) => {
47+
if (identifierNode.type !== "Identifier") return;
48+
if (possibleReactExportRE.test(identifierNode.name)) {
49+
localComponents.push(identifierNode);
50+
}
51+
};
52+
53+
const handleExportIdentifier = (
54+
identifierNode: TSESTree.BindingName,
55+
) => {
2956
if (identifierNode.type !== "Identifier") {
3057
nonComponentExport.push(identifierNode);
3158
return;
3259
}
33-
if (/^[A-Z][a-zA-Z]*$/.test(identifierNode.name)) {
60+
if (
61+
!mayHaveReactExport &&
62+
possibleReactExportRE.test(identifierNode.name)
63+
) {
3464
mayHaveReactExport = true;
3565
}
36-
// Only letters, starts with uppercase and at least one lowercase
37-
// This can lead to some false positive (ex: `const CMS = () => <></>`)
38-
// But allow to catch `export const CONSTANT = 3`
39-
if (!/^[A-Z][a-zA-Z]*[a-z]+[a-zA-Z]*$/.test(identifierNode.name)) {
66+
if (!strictReactExportRE.test(identifierNode.name)) {
4067
nonComponentExport.push(identifierNode);
4168
}
4269
};
4370

4471
const handleExportDeclaration = (node: TSESTree.ExportDeclaration) => {
4572
if (node.type === "VariableDeclaration") {
4673
for (const variable of node.declarations) {
47-
handleIdentifier(variable.id);
74+
handleExportIdentifier(variable.id);
4875
}
4976
} else if (node.type === "FunctionDeclaration") {
5077
if (node.id === null) {
5178
context.report({ messageId: "anonymousExport", node });
5279
} else {
53-
handleIdentifier(node.id);
80+
handleExportIdentifier(node.id);
5481
}
5582
}
5683
};
5784

5885
for (const node of program.body) {
5986
if (node.type === "ExportAllDeclaration") {
87+
hasExports = true;
6088
context.report({ messageId: "exportAll", node });
6189
} else if (node.type === "ExportDefaultDeclaration") {
90+
hasExports = true;
6291
if (
6392
node.declaration.type === "VariableDeclaration" ||
6493
node.declaration.type === "FunctionDeclaration"
6594
) {
6695
handleExportDeclaration(node.declaration);
6796
}
97+
if (node.declaration.type === "Identifier") {
98+
handleExportIdentifier(node.declaration);
99+
}
68100
if (
69101
node.declaration.type === "ArrowFunctionExpression" &&
70102
!node.declaration.id
71103
) {
72104
context.report({ messageId: "anonymousExport", node });
73105
}
74106
} else if (node.type === "ExportNamedDeclaration") {
107+
hasExports = true;
75108
if (node.declaration) handleExportDeclaration(node.declaration);
76109
for (const specifier of node.specifiers) {
77-
handleIdentifier(specifier.exported);
110+
handleExportIdentifier(specifier.exported);
111+
}
112+
} else if (node.type === "VariableDeclaration") {
113+
for (const variable of node.declarations) {
114+
handleLocalIdentifier(variable.id);
78115
}
116+
} else if (node.type === "FunctionDeclaration") {
117+
handleLocalIdentifier(node.id);
79118
}
80119
}
81120

82-
if (!mayHaveReactExport) return;
83-
84-
for (const node of nonComponentExport) {
85-
context.report({ messageId: "namedExport", node });
121+
if (hasExports) {
122+
if (mayHaveReactExport) {
123+
for (const node of nonComponentExport) {
124+
context.report({ messageId: "namedExport", node });
125+
}
126+
} else if (localComponents.length) {
127+
for (const node of localComponents) {
128+
context.report({ messageId: "localComponents", node });
129+
}
130+
}
131+
} else if (localComponents.length) {
132+
for (const node of localComponents) {
133+
context.report({ messageId: "noExport", node });
134+
}
86135
}
87136
},
88137
};

src/tests.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@ import { RuleTester } from "eslint";
33
import { onlyExportComponents } from "./only-export-components";
44

55
const ruleTester = new RuleTester({
6-
parserOptions: { sourceType: "module", ecmaVersion: 2018 },
6+
parserOptions: {
7+
sourceType: "module",
8+
ecmaVersion: 2018,
9+
ecmaFeatures: { jsx: true },
10+
},
711
});
812

913
const valid = [
@@ -97,6 +101,16 @@ const invalid = [
97101
code: "export const CONSTANT = 3; export const Foo = () => {};",
98102
errorId: "namedExport",
99103
},
104+
{
105+
name: "Unexported component and export",
106+
code: "const Tab = () => {}; export const tabs = [<Tab />, <Tab />];",
107+
errorId: "localComponents",
108+
},
109+
{
110+
name: "Unexported component and no export",
111+
code: "const App = () => {}; createRoot(document.getElementById('root')).render(<App />);",
112+
errorId: "noExport",
113+
},
100114
];
101115

102116
let failedTests = 0;

0 commit comments

Comments
 (0)