Skip to content

Commit 8cf51a5

Browse files
authored
feat(react-dom): add 'no-hydrate' rule to replace ReactDom.hydrate with createRoot().hydrate, closes #973 (#995)
1 parent d07c076 commit 8cf51a5

File tree

10 files changed

+261
-3
lines changed

10 files changed

+261
-3
lines changed

apps/website/content/docs/rules/meta.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@
5959
"dom-no-dangerously-set-innerhtml-with-children",
6060
"dom-no-find-dom-node",
6161
"dom-no-flush-sync",
62+
"dom-no-hydrate",
6263
"dom-no-missing-button-type",
6364
"dom-no-missing-iframe-sandbox",
6465
"dom-no-namespace",

apps/website/content/docs/rules/overview.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ full: true
7979
| [`no-dangerously-set-innerhtml-with-children`](./dom-no-dangerously-set-innerhtml-with-children) | 2️⃣ | `🔍` | Prevents DOM elements using `dangerouslySetInnerHTML` and `children` at the same time. | |
8080
| [`no-find-dom-node`](./dom-no-find-dom-node) | 2️⃣ | `🔍` | Prevents using `findDOMNode`. | |
8181
| [`no-flush-sync`](./dom-no-flush-sync) | 2️⃣ | `🔍` | Prevents using `flushSync`. | |
82+
| [`no-hydrate`](./dom-no-hydrate) | 1️⃣ | `🔍` `🔄` | Replaces usages of `ReactDom.hydrate()` with `hydrateRoot()`. | >=18.0.0 |
8283
| [`no-missing-button-type`](./dom-no-missing-button-type) | 1️⃣ | `🔍` | Enforces explicit `type` attribute for `button` elements. | |
8384
| [`no-missing-iframe-sandbox`](./dom-no-missing-iframe-sandbox) | 1️⃣ | `🔍` | Enforces explicit `sandbox` attribute for `iframe` elements. | |
8485
| [`no-namespace`](./dom-no-namespace) | 2️⃣ | `🔍` | Enforces the absence of a `namespace` in React elements. | |

packages/plugins/eslint-plugin-react-dom/src/configs/recommended.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export const rules = {
88
"react-dom/no-dangerously-set-innerhtml-with-children": "error",
99
"react-dom/no-find-dom-node": "error",
1010
"react-dom/no-flush-sync": "error",
11+
"react-dom/no-hydrate": "error",
1112
"react-dom/no-missing-button-type": "warn",
1213
"react-dom/no-missing-iframe-sandbox": "warn",
1314
"react-dom/no-namespace": "error",

packages/plugins/eslint-plugin-react-dom/src/plugin.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import noDangerouslySetInnerHTML from "./rules/no-dangerously-set-innerhtml";
33
import noDangerouslySetInnerHTMLWithChildren from "./rules/no-dangerously-set-innerhtml-with-children";
44
import noFindDomNode from "./rules/no-find-dom-node";
55
import noFlushSync from "./rules/no-flush-sync";
6+
import noHydrate from "./rules/no-hydrate";
67
import noMissingButtonType from "./rules/no-missing-button-type";
78
import noMissingIframeSandbox from "./rules/no-missing-iframe-sandbox";
89
import noNamespace from "./rules/no-namespace";
@@ -25,6 +26,7 @@ export const plugin = {
2526
"no-dangerously-set-innerhtml-with-children": noDangerouslySetInnerHTMLWithChildren,
2627
"no-find-dom-node": noFindDomNode,
2728
"no-flush-sync": noFlushSync,
29+
"no-hydrate": noHydrate,
2830
"no-missing-button-type": noMissingButtonType,
2931
"no-missing-iframe-sandbox": noMissingIframeSandbox,
3032
"no-namespace": noNamespace,
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
---
2+
title: no-hydrate
3+
---
4+
5+
**Full Name in `eslint-plugin-react-dom`**
6+
7+
```plain copy
8+
react-dom/no-hydrate
9+
```
10+
11+
**Full Name in `@eslint-react/eslint-plugin`**
12+
13+
```plain copy
14+
@eslint-react/dom/no-hydrate
15+
```
16+
17+
**Features**
18+
19+
`🔍` `🔄`
20+
21+
**Presets**
22+
23+
- `dom`
24+
- `recommended`
25+
- `recommended-typescript`
26+
- `recommended-type-checked`
27+
28+
## What it does
29+
30+
Replaces usages of `ReactDom.hydrate()` with `hydrateRoot()`.
31+
32+
An **unsafe** codemod is available for this rule.
33+
34+
## Examples
35+
36+
### Failing
37+
38+
```tsx
39+
import ReactDom from "react-dom";
40+
import Component from "Component";
41+
42+
ReactDom.hydrate(<Component />, document.getElementById("app"));
43+
```
44+
45+
### Passing
46+
47+
```tsx
48+
import { hydrateRoot } from "react-dom/client";
49+
import ReactDom from "react-dom";
50+
import Component from "Component";
51+
52+
hydrateRoot(document.getElementById("app"), <Component />);
53+
```
54+
55+
## Implementation
56+
57+
- [Rule source](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom/src/rules/no-hydrate.ts)
58+
- [Test source](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom/src/rules/no-hydrate.spec.ts)
59+
60+
## Further Reading
61+
62+
- [React: react-dom/hydrate](https://18.react.dev/reference/react-dom/hydrate)
63+
- [React: react-dom/createRoot](https://react.dev/reference/react-dom/client/hydrateRoot)
64+
65+
---
66+
67+
## See Also
68+
69+
- [no-return](./dom-no-hydrate)\
70+
Replaces usages of `ReactDom.hydrate()` with `createRoot(node).hydrate()`.
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import tsx from "dedent";
2+
3+
import { ruleTester } from "../../../../../test";
4+
import rule, { RULE_NAME } from "./no-hydrate";
5+
6+
ruleTester.run(RULE_NAME, rule, {
7+
invalid: [
8+
{
9+
code: tsx`
10+
import ReactDom from "react-dom";
11+
import Component from "Component";
12+
13+
ReactDom.hydrate(<Component />, document.getElementById("app"));
14+
`,
15+
errors: [{ messageId: "noHydrate" }],
16+
output: tsx`
17+
import { hydrateRoot } from "react-dom/client";
18+
import ReactDom from "react-dom";
19+
import Component from "Component";
20+
21+
hydrateRoot(document.getElementById("app"), <Component />);
22+
`,
23+
},
24+
{
25+
code: tsx`
26+
import React from "react";
27+
import ReactDom from "react-dom";
28+
import Component from "Component";
29+
30+
ReactDom.hydrate(<Component />, document.getElementById("app")!);
31+
`,
32+
errors: [{ messageId: "noHydrate" }],
33+
output: tsx`
34+
import { hydrateRoot } from "react-dom/client";
35+
import React from "react";
36+
import ReactDom from "react-dom";
37+
import Component from "Component";
38+
39+
hydrateRoot(document.getElementById("app")!, <Component />);
40+
`,
41+
},
42+
{
43+
code: tsx`
44+
import React from "react";
45+
import ReactDom from "react-dom";
46+
import Component from "Component";
47+
48+
const rootEl = document.getElementById("app")!;
49+
ReactDom.hydrate(<Component />, rootEl);
50+
`,
51+
errors: [{ messageId: "noHydrate" }],
52+
output: tsx`
53+
import { hydrateRoot } from "react-dom/client";
54+
import React from "react";
55+
import ReactDom from "react-dom";
56+
import Component from "Component";
57+
58+
const rootEl = document.getElementById("app")!;
59+
hydrateRoot(rootEl, <Component />);
60+
`,
61+
},
62+
],
63+
valid: [
64+
tsx`
65+
import React from "react";
66+
import { hydrateRoot } from "react-dom/client";
67+
import Component from "Component";
68+
69+
hydrateRoot(document.getElementById("app"), <Component />);
70+
`,
71+
tsx`
72+
import React from "react";
73+
import { hydrateRoot } from "react-dom/client";
74+
import Component from "Component";
75+
76+
hydrateRoot(document.getElementById("app")!, <Component />);
77+
`,
78+
],
79+
});
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import type { RuleFeature } from "@eslint-react/shared";
2+
import { getSettingsFromContext } from "@eslint-react/shared";
3+
import type { TSESTree } from "@typescript-eslint/types";
4+
import { AST_NODE_TYPES as T } from "@typescript-eslint/types";
5+
import type { RuleFixer } from "@typescript-eslint/utils/ts-eslint";
6+
import { compare } from "compare-versions";
7+
import type { CamelCase } from "string-ts";
8+
9+
import type { RuleContext } from "../../../../shared/src/types";
10+
import { createRule } from "../utils";
11+
12+
export const RULE_NAME = "no-hydrate";
13+
14+
export const RULE_FEATURES = [
15+
"CHK",
16+
"MOD",
17+
] as const satisfies RuleFeature[];
18+
19+
export type MessageID = CamelCase<typeof RULE_NAME>;
20+
21+
export default createRule<[], MessageID>({
22+
meta: {
23+
type: "problem",
24+
docs: {
25+
description: "replaces usages of 'ReactDom.hydrate()' with 'hydrateRoot()'",
26+
[Symbol.for("rule_features")]: RULE_FEATURES,
27+
},
28+
fixable: "code",
29+
messages: {
30+
noHydrate: "[Deprecated] Use 'hydrateRoot()' instead.",
31+
},
32+
schema: [],
33+
},
34+
name: RULE_NAME,
35+
create(context) {
36+
if (!context.sourceCode.text.includes("hydrate")) return {};
37+
const settings = getSettingsFromContext(context);
38+
if (compare(settings.version, "18.0.0", "<")) return {};
39+
40+
const reactDomNames = new Set<string>();
41+
const hydrateNames = new Set<string>();
42+
43+
return {
44+
CallExpression(node) {
45+
switch (true) {
46+
case node.callee.type === T.Identifier
47+
&& hydrateNames.has(node.callee.name):
48+
context.report({
49+
messageId: "noHydrate",
50+
node,
51+
fix: getFix(context, node),
52+
});
53+
return;
54+
case node.callee.type === T.MemberExpression
55+
&& node.callee.object.type === T.Identifier
56+
&& node.callee.property.type === T.Identifier
57+
&& node.callee.property.name === "hydrate"
58+
&& reactDomNames.has(node.callee.object.name):
59+
context.report({
60+
messageId: "noHydrate",
61+
node,
62+
fix: getFix(context, node),
63+
});
64+
return;
65+
}
66+
},
67+
ImportDeclaration(node) {
68+
const [baseSource] = node.source.value.split("/");
69+
if (baseSource !== "react-dom") return;
70+
for (const specifier of node.specifiers) {
71+
switch (specifier.type) {
72+
case T.ImportSpecifier:
73+
if (specifier.imported.type !== T.Identifier) continue;
74+
if (specifier.imported.name === "hydrate") {
75+
hydrateNames.add(specifier.local.name);
76+
}
77+
continue;
78+
case T.ImportDefaultSpecifier:
79+
case T.ImportNamespaceSpecifier:
80+
reactDomNames.add(specifier.local.name);
81+
continue;
82+
}
83+
}
84+
},
85+
};
86+
},
87+
defaultOptions: [],
88+
});
89+
90+
function getFix(context: RuleContext, node: TSESTree.CallExpression) {
91+
const getText = (n: TSESTree.Node) => context.sourceCode.getText(n);
92+
return (fixer: RuleFixer) => {
93+
const [arg0, arg1] = node.arguments;
94+
if (arg0 == null || arg1 == null) return null;
95+
return [
96+
fixer.insertTextBefore(context.sourceCode.ast, 'import { hydrateRoot } from "react-dom/client";\n'),
97+
fixer.replaceText(node, `hydrateRoot(${getText(arg1)}, ${getText(arg0)})`),
98+
];
99+
};
100+
}

packages/plugins/eslint-plugin-react-dom/src/rules/no-render-return-value.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,5 +70,5 @@ ReactDOM.render(<div id="app" ref={doSomethingWithInst} />, document.body);
7070

7171
## See Also
7272

73-
- [no-render](./no-render.md)\
73+
- [no-render](./dom-no-render.md)\
7474
Replaces usages of `ReactDom.render()` with `createRoot(node).render()`.

packages/plugins/eslint-plugin-react-dom/src/rules/no-render.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,5 +65,7 @@ createRoot(document.getElementById("app")).render(<Component />);
6565

6666
## See Also
6767

68-
- [no-render-return-value](./no-render-return-value.md)\
68+
- [no-render-return-value](./dom-no-render-return-value)\
6969
Prevents usage of the return value of `ReactDOM.render`.
70+
- [no-hydrate](./dom-no-hydrate)\
71+
Replaces usages of `ReactDom.hydrate()` with `createRoot(node).hydrate()`.

packages/plugins/eslint-plugin/src/configs/all.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,8 @@ export const rules = {
6363
"@eslint-react/dom/no-dangerously-set-innerhtml": "warn",
6464
"@eslint-react/dom/no-dangerously-set-innerhtml-with-children": "error",
6565
"@eslint-react/dom/no-find-dom-node": "error",
66+
"@eslint-react/dom/no-flush-sync": "error",
67+
"@eslint-react/dom/no-hydrate": "error",
6668
"@eslint-react/dom/no-missing-button-type": "warn",
6769
"@eslint-react/dom/no-missing-iframe-sandbox": "warn",
6870
"@eslint-react/dom/no-namespace": "error",
@@ -73,7 +75,7 @@ export const rules = {
7375
"@eslint-react/dom/no-unsafe-iframe-sandbox": "warn",
7476
"@eslint-react/dom/no-unsafe-target-blank": "warn",
7577
"@eslint-react/dom/no-use-form-state": "error",
76-
"@eslint-react/dom/no-void-elements-with-children": "warn",
78+
"@eslint-react/dom/no-void-elements-with-children": "error",
7779

7880
"@eslint-react/web-api/no-leaked-event-listener": "warn",
7981
"@eslint-react/web-api/no-leaked-interval": "warn",

0 commit comments

Comments
 (0)