Skip to content

Commit

Permalink
Add changelog entry & simplify legacy name based only check
Browse files Browse the repository at this point in the history
  • Loading branch information
ArnaudBarre committed Nov 28, 2024
1 parent 478e778 commit 86bddb3
Show file tree
Hide file tree
Showing 6 changed files with 52 additions and 33 deletions.
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- uses: oven-sh/setup-bun@v2
- run: bun install
- run: bun ci
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,27 @@
# Changelog

## Unreleased

### Add support for custom HOCs (#60)

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:

```tsx
const Foo = () => <></>;
export default observer(Foo);
```

```json
{
"react-refresh/only-export-components": [
"error",
{ "customHOCs": ["observer"] }
]
}
```

Thanks @HorusGoul!

## 0.4.14

- Warn if a context is exported alongside a component (fixes #53). Thanks @IgorAufricht!
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,14 +111,17 @@ These options are all present on `react-refresh/only-exports-components`.

```ts
interface Options {
allowExportNames?: string[];
allowExportNames?: string[];
allowConstantExport?: boolean;
customHOCs?: string[];
checkJS?: boolean;
}

const defaultOptions: Options = {
allowExportNames: [],
allowConstantExport: false,
customHOCs: [],
checkJS: false,
};
```
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"scripts": {
"build": "scripts/bundle.ts",
"test": "bun test",
"lint": "bun --bun eslint . --max-warnings 0",
"lint": "bun eslint . --max-warnings 0",
"prettier": "bun prettier-ci --write",
"prettier-ci": "prettier --ignore-path=.gitignore --check '**/*.{js,ts,json,md,yml}'",
"ci": "tsc && bun lint && bun prettier-ci && bun test && bun run build"
Expand Down
2 changes: 1 addition & 1 deletion scripts/bundle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ writeFileSync(
{
name: packageJSON.name,
description:
"Validate that your components can safely be updated with fast refresh",
"Validate that your components can safely be updated with Fast Refresh",
version: packageJSON.version,
author: "Arnaud Barré (https://github.com/ArnaudBarre)",
license: packageJSON.license,
Expand Down
53 changes: 22 additions & 31 deletions src/only-export-components.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,7 @@
import type { TSESLint } from "@typescript-eslint/utils";
import type { TSESTree } from "@typescript-eslint/types";

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

export const onlyExportComponents: TSESLint.RuleModule<
| "exportAll"
Expand All @@ -18,10 +13,10 @@ export const onlyExportComponents: TSESLint.RuleModule<
| []
| [
{
allowConstantExport?: boolean;
checkJS?: boolean;
allowExportNames?: string[];
allowConstantExport?: boolean;
customHOCs?: string[];
checkJS?: boolean;
},
]
> = {
Expand All @@ -45,10 +40,10 @@ export const onlyExportComponents: TSESLint.RuleModule<
{
type: "object",
properties: {
allowConstantExport: { type: "boolean" },
checkJS: { type: "boolean" },
allowExportNames: { type: "array", items: { type: "string" } },
allowConstantExport: { type: "boolean" },
customHOCs: { type: "array", items: { type: "string" } },
checkJS: { type: "boolean" },
},
additionalProperties: false,
},
Expand All @@ -57,10 +52,10 @@ export const onlyExportComponents: TSESLint.RuleModule<
defaultOptions: [],
create: (context) => {
const {
allowConstantExport = false,
checkJS = false,
allowExportNames,
allowConstantExport = false,
customHOCs = [],
checkJS = false,
} = context.options[0] ?? {};
const filename = context.filename;
// Skip tests & stories files
Expand All @@ -82,20 +77,20 @@ export const onlyExportComponents: TSESLint.RuleModule<
? new Set(allowExportNames)
: undefined;

const reactHOCs = new Set(["memo", "forwardRef", ...customHOCs]);
const reactHOCs = ["memo", "forwardRef", ...customHOCs];
const canBeReactFunctionComponent = (init: TSESTree.Expression | null) => {
if (!init) return false;
if (init.type === "ArrowFunctionExpression") return true;
if (init.type === "CallExpression" && init.callee.type === "Identifier") {
return reactHOCs.has(init.callee.name);
return reactHOCs.includes(init.callee.name);
}
return false;
};

return {
Program(program) {
let hasExports = false;
let mayHaveReactExport = false;
let hasReactExport = false;
let reactIsInScope = false;
const localComponents: TSESTree.Identifier[] = [];
const nonComponentExports: (
Expand All @@ -108,7 +103,7 @@ export const onlyExportComponents: TSESLint.RuleModule<
identifierNode: TSESTree.BindingName,
) => {
if (identifierNode.type !== "Identifier") return;
if (possibleReactExportRE.test(identifierNode.name)) {
if (reactComponentNameRE.test(identifierNode.name)) {
localComponents.push(identifierNode);
}
};
Expand All @@ -135,8 +130,8 @@ export const onlyExportComponents: TSESLint.RuleModule<
}

if (isFunction) {
if (possibleReactExportRE.test(identifierNode.name)) {
mayHaveReactExport = true;
if (reactComponentNameRE.test(identifierNode.name)) {
hasReactExport = true;
} else {
nonComponentExports.push(identifierNode);
}
Expand All @@ -162,13 +157,9 @@ export const onlyExportComponents: TSESLint.RuleModule<
nonComponentExports.push(identifierNode);
return;
}
if (
!mayHaveReactExport &&
possibleReactExportRE.test(identifierNode.name)
) {
mayHaveReactExport = true;
}
if (!strictReactExportRE.test(identifierNode.name)) {
if (reactComponentNameRE.test(identifierNode.name)) {
hasReactExport = true;
} else {
nonComponentExports.push(identifierNode);
}
}
Expand Down Expand Up @@ -197,21 +188,21 @@ export const onlyExportComponents: TSESLint.RuleModule<
) {
// support for react-redux
// export default connect(mapStateToProps, mapDispatchToProps)(Comp)
mayHaveReactExport = true;
hasReactExport = true;
} else if (node.callee.type !== "Identifier") {
// we rule out non HoC first
// export default React.memo(function Foo() {})
// export default Preact.memo(function Foo() {})
if (
node.callee.type === "MemberExpression" &&
node.callee.property.type === "Identifier" &&
reactHOCs.has(node.callee.property.name)
reactHOCs.includes(node.callee.property.name)
) {
mayHaveReactExport = true;
hasReactExport = true;
} else {
context.report({ messageId: "anonymousExport", node });
}
} else if (!reactHOCs.has(node.callee.name)) {
} else if (!reactHOCs.includes(node.callee.name)) {
// we rule out non HoC first
context.report({ messageId: "anonymousExport", node });
} else if (
Expand All @@ -225,7 +216,7 @@ export const onlyExportComponents: TSESLint.RuleModule<
// No need to check further, the identifier has necessarily a named,
// and it would throw at runtime if it's not a React component.
// We have React exports since we are exporting return value of HoC
mayHaveReactExport = true;
hasReactExport = true;
} else {
context.report({ messageId: "anonymousExport", node });
}
Expand Down Expand Up @@ -289,7 +280,7 @@ export const onlyExportComponents: TSESLint.RuleModule<

if (hasExports) {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (mayHaveReactExport) {
if (hasReactExport) {
for (const node of nonComponentExports) {
context.report({ messageId: "namedExport", node });
}
Expand Down

0 comments on commit 86bddb3

Please sign in to comment.