Skip to content

Commit daa2efb

Browse files
authored
Revamp logic to catch more cases [publish] (#97)
1 parent 95c02ba commit daa2efb

9 files changed

Lines changed: 473 additions & 241 deletions

File tree

CHANGELOG.md

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,76 @@
11
# Changelog
22

3+
## 0.5.0
4+
5+
### Breaking changes
6+
7+
- The package now ships as ESM and requires ESLint 9 + node 20. Because legacy config doesn't support ESM, this requires to use [flat config](https://eslint.org/docs/latest/use/configure/migration-guide)
8+
- A new `reactRefresh` export is available and prefered over the default export. It's an object with two properties:
9+
- `plugin`: The plugin object with the rules
10+
- `configs`: An object containing configuration presets, each exposed as a function. These functions accept your custom options, merge them with sensible defaults for that config, and return the final config object.
11+
- `customHOCs` option was renamed to `extraHOCs`
12+
- Validation of HOCs calls is now more strict, you may need to add some HOCs to the `extraHOCs` option
13+
14+
Config example:
15+
16+
```js
17+
import { defineConfig } from "eslint/config";
18+
import { reactRefresh } from "eslint-plugin-react-refresh";
19+
20+
export default defineConfig(
21+
/* Main config */
22+
reactRefresh.configs.vite({ extraHOCs: ["someLibHOC"] }),
23+
);
24+
```
25+
26+
Config example without config:
27+
28+
```js
29+
import { defineConfig } from "eslint/config";
30+
import { reactRefresh } from "eslint-plugin-react-refresh";
31+
32+
export default defineConfig({
33+
files: ["**/*.ts", "**/*.tsx"],
34+
plugins: {
35+
// other plugins
36+
"react-refresh": reactRefresh.plugin,
37+
},
38+
rules: {
39+
// other rules
40+
"react-refresh/only-export-components": [
41+
"warn",
42+
{ extraHOCs: ["someLibHOC"] },
43+
],
44+
},
45+
});
46+
```
47+
48+
### Why
49+
50+
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))
51+
52+
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:
53+
54+
```json
55+
{
56+
"react-refresh/only-export-components": [
57+
"warn",
58+
{ "extraHOCs": ["createRootRouteWithContext"] }
59+
]
60+
}
61+
```
62+
63+
> [!NOTE]
64+
> 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 `extraHOCs` it you want to preserve good HMR in the long term. [Bluesky thread](https://bsky.app/profile/arnaud-barre.bsky.social/post/3ma5h5tf2sk2e).
65+
66+
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 `extraHOCs` like this:
67+
68+
```json
69+
{
70+
"react-refresh/only-export-components": ["warn", { "extraHOCs": ["connect"] }]
71+
}
72+
```
73+
374
## 0.4.26
475

576
- 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))
@@ -73,7 +144,7 @@ export default observer(Foo);
73144
{
74145
"react-refresh/only-export-components": [
75146
"error",
76-
{ "customHOCs": ["observer"] }
147+
{ "extraHOCs": ["observer"] }
77148
]
78149
}
79150
```

README.md

Lines changed: 24 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,11 @@ This plugin provides a single rule, `react-refresh/only-export-components`. Ther
3434

3535
```js
3636
import { defineConfig } from "eslint/config";
37-
import reactRefresh from "eslint-plugin-react-refresh";
37+
import { reactRefresh } from "eslint-plugin-react-refresh";
3838

3939
export default defineConfig(
4040
/* Main config */
41-
reactRefresh.configs.recommended,
41+
reactRefresh.configs.recommended(),
4242
);
4343
```
4444

@@ -48,56 +48,45 @@ This enables the `allowConstantExport` option which is supported by Vite React p
4848

4949
```js
5050
import { defineConfig } from "eslint/config";
51-
import reactRefresh from "eslint-plugin-react-refresh";
51+
import { reactRefresh } from "eslint-plugin-react-refresh";
5252

5353
export default defineConfig(
5454
/* Main config */
55-
reactRefresh.configs.vite,
55+
reactRefresh.configs.vite(),
5656
);
5757
```
5858

59-
### Next config <small>(v0.4.21)</small>
59+
### Next config
6060

6161
This allows exports like `fetchCache` and `revalidate` which are used in Page or Layout components and don't trigger a full page reload.
6262

6363
```js
6464
import { defineConfig } from "eslint/config";
65-
import reactRefresh from "eslint-plugin-react-refresh";
65+
import { reactRefresh } from "eslint-plugin-react-refresh";
6666

6767
export default defineConfig(
6868
/* Main config */
69-
reactRefresh.configs.next,
69+
reactRefresh.configs.next(),
7070
);
7171
```
7272

7373
### Without config
7474

7575
```js
7676
import { defineConfig } from "eslint/config";
77-
import reactRefresh from "eslint-plugin-react-refresh";
77+
import { reactRefresh } from "eslint-plugin-react-refresh";
7878

7979
export default defineConfig({
8080
// in main config for TSX/JSX source files
8181
plugins: {
82-
"react-refresh": reactRefresh,
82+
"react-refresh": reactRefresh.plugin,
8383
},
8484
rules: {
8585
"react-refresh/only-export-components": "error",
8686
},
8787
});
8888
```
8989

90-
### Legacy config
91-
92-
```jsonc
93-
{
94-
"plugins": ["react-refresh"],
95-
"rules": {
96-
"react-refresh/only-export-components": "error",
97-
},
98-
}
99-
```
100-
10190
## Examples
10291

10392
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`.
152141

153142
```ts
154143
interface Options {
144+
extraHOCs?: string[];
155145
allowExportNames?: string[];
156146
allowConstantExport?: boolean;
157-
customHOCs?: string[];
158147
checkJS?: boolean;
159148
}
160149

161150
const defaultOptions: Options = {
151+
extraHOCs: [],
162152
allowExportNames: [],
163153
allowConstantExport: false,
164-
customHOCs: [],
165154
checkJS: false,
166155
};
167156
```
168157

158+
### extraHOCs <small>(v0.5.0)</small>
159+
160+
If you're exporting components wrapped in non built-in React HOC (memo, forwardRef, lazy), you can use this option to avoid false positives.
161+
162+
```json
163+
{
164+
"react-refresh/only-export-components": [
165+
"error",
166+
{ "extraHOCs": ["observer", "withAuth"] }
167+
]
168+
}
169+
```
170+
169171
### allowExportNames <small>(v0.4.4)</small>
170172

171173
> Default: `[]`
@@ -218,16 +220,3 @@ If you're using JSX inside `.js` files (which I don't recommend because it force
218220
"react-refresh/only-export-components": ["error", { "checkJS": true }]
219221
}
220222
```
221-
222-
### customHOCs <small>(v0.4.15)</small>
223-
224-
If you're exporting a component wrapped in a custom HOC, you can use this option to avoid false positives.
225-
226-
```json
227-
{
228-
"react-refresh/only-export-components": [
229-
"error",
230-
{ "customHOCs": ["observer", "withAuth"] }
231-
]
232-
}
233-
```

bun.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "eslint-plugin-react-refresh",
3-
"version": "0.4.26",
3+
"version": "0.5.0",
44
"type": "module",
55
"license": "MIT",
66
"scripts": {
@@ -15,7 +15,7 @@
1515
"experimentalOperatorPosition": "start"
1616
},
1717
"peerDependencies": {
18-
"eslint": ">=8.40"
18+
"eslint": ">=9"
1919
},
2020
"devDependencies": {
2121
"@arnaud-barre/eslint-config": "^6.1.2",

scripts/bundle.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ await build({
1212
entryPoints: ["src/index.ts"],
1313
outdir: "dist",
1414
platform: "node",
15-
target: "node14",
15+
format: "esm",
16+
target: "node20",
1617
external: Object.keys(packageJSON.peerDependencies),
1718
});
1819

@@ -27,12 +28,16 @@ writeFileSync(
2728
description:
2829
"Validate that your components can safely be updated with Fast Refresh",
2930
version: packageJSON.version,
30-
type: "commonjs",
31+
type: "module",
3132
author: "Arnaud Barré (https://github.com/ArnaudBarre)",
3233
license: packageJSON.license,
3334
repository: "github:ArnaudBarre/eslint-plugin-react-refresh",
34-
main: "index.js",
35-
types: "index.d.ts",
35+
exports: {
36+
".": {
37+
types: "./index.d.ts",
38+
default: "./index.js",
39+
},
40+
},
3641
keywords: [
3742
"eslint",
3843
"eslint-plugin",

src/index.ts

Lines changed: 58 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,62 +1,74 @@
11
import { onlyExportComponents } from "./only-export-components.ts";
2+
import type { OnlyExportComponentsOptions } from "./types.d.ts";
23

3-
export const rules = {
4+
const rules = {
45
"only-export-components": onlyExportComponents,
56
};
6-
77
const plugin = { rules };
88

9-
export const configs = {
10-
recommended: {
11-
name: "react-refresh/recommended",
12-
plugins: { "react-refresh": plugin },
13-
rules: { "react-refresh/only-export-components": "error" },
14-
},
15-
vite: {
16-
name: "react-refresh/vite",
9+
const buildConfig =
10+
({
11+
name,
12+
baseOptions,
13+
}: {
14+
name: string;
15+
baseOptions: OnlyExportComponentsOptions;
16+
}) =>
17+
(options?: OnlyExportComponentsOptions) => ({
18+
name: `react-refresh/${name}`,
1719
plugins: { "react-refresh": plugin },
1820
rules: {
1921
"react-refresh/only-export-components": [
2022
"error",
21-
{ allowConstantExport: true },
23+
{ ...baseOptions, ...options },
2224
],
2325
},
24-
},
25-
next: {
26-
name: "react-refresh/next",
27-
plugins: { "react-refresh": plugin },
28-
rules: {
29-
"react-refresh/only-export-components": [
30-
"error",
31-
{
32-
allowExportNames: [
33-
// https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config
34-
"experimental_ppr",
35-
"dynamic",
36-
"dynamicParams",
37-
"revalidate",
38-
"fetchCache",
39-
"runtime",
40-
"preferredRegion",
41-
"maxDuration",
42-
// https://nextjs.org/docs/app/api-reference/functions/generate-metadata
43-
"metadata",
44-
"generateMetadata",
45-
// https://nextjs.org/docs/app/api-reference/functions/generate-viewport
46-
"viewport",
47-
"generateViewport",
48-
// https://nextjs.org/docs/app/api-reference/functions/generate-image-metadata
49-
"generateImageMetadata",
50-
// https://nextjs.org/docs/app/api-reference/functions/generate-sitemaps
51-
"generateSitemaps",
52-
// https://nextjs.org/docs/app/api-reference/functions/generate-static-params
53-
"generateStaticParams",
54-
],
55-
},
26+
});
27+
28+
const configs = {
29+
recommended: buildConfig({ name: "recommended", baseOptions: {} }),
30+
vite: buildConfig({
31+
name: "vite",
32+
baseOptions: { allowConstantExport: true },
33+
}),
34+
next: buildConfig({
35+
name: "next",
36+
baseOptions: {
37+
allowExportNames: [
38+
// https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config
39+
"experimental_ppr",
40+
"dynamic",
41+
"dynamicParams",
42+
"revalidate",
43+
"fetchCache",
44+
"runtime",
45+
"preferredRegion",
46+
"maxDuration",
47+
// https://nextjs.org/docs/app/api-reference/functions/generate-metadata
48+
"metadata",
49+
"generateMetadata",
50+
// https://nextjs.org/docs/app/api-reference/functions/generate-viewport
51+
"viewport",
52+
"generateViewport",
53+
// https://nextjs.org/docs/app/api-reference/functions/generate-image-metadata
54+
"generateImageMetadata",
55+
// https://nextjs.org/docs/app/api-reference/functions/generate-sitemaps
56+
"generateSitemaps",
57+
// https://nextjs.org/docs/app/api-reference/functions/generate-static-params
58+
"generateStaticParams",
5659
],
5760
},
58-
},
61+
}),
5962
};
6063

61-
// Probably not needed, but keep for backwards compatibility
62-
export default { rules, configs };
64+
export const reactRefresh = { plugin, configs };
65+
66+
/** Prefer reactRefresh export which exposed configs as functions */
67+
export default {
68+
rules,
69+
configs: {
70+
recommended: configs.recommended(),
71+
vite: configs.vite(),
72+
next: configs.next(),
73+
},
74+
};

0 commit comments

Comments
 (0)