diff --git a/apps/website/content/docs/rules/meta.json b/apps/website/content/docs/rules/meta.json
index e3236957a..b8788b1b7 100644
--- a/apps/website/content/docs/rules/meta.json
+++ b/apps/website/content/docs/rules/meta.json
@@ -59,6 +59,7 @@
"dom-no-dangerously-set-innerhtml-with-children",
"dom-no-find-dom-node",
"dom-no-flush-sync",
+ "dom-no-hydrate",
"dom-no-missing-button-type",
"dom-no-missing-iframe-sandbox",
"dom-no-namespace",
diff --git a/apps/website/content/docs/rules/overview.md b/apps/website/content/docs/rules/overview.md
index 8f41d1cc0..f83d668d9 100644
--- a/apps/website/content/docs/rules/overview.md
+++ b/apps/website/content/docs/rules/overview.md
@@ -79,6 +79,7 @@ full: true
| [`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. | |
| [`no-find-dom-node`](./dom-no-find-dom-node) | 2️⃣ | `🔍` | Prevents using `findDOMNode`. | |
| [`no-flush-sync`](./dom-no-flush-sync) | 2️⃣ | `🔍` | Prevents using `flushSync`. | |
+| [`no-hydrate`](./dom-no-hydrate) | 1️⃣ | `🔍` `🔄` | Replaces usages of `ReactDom.hydrate()` with `hydrateRoot()`. | >=18.0.0 |
| [`no-missing-button-type`](./dom-no-missing-button-type) | 1️⃣ | `🔍` | Enforces explicit `type` attribute for `button` elements. | |
| [`no-missing-iframe-sandbox`](./dom-no-missing-iframe-sandbox) | 1️⃣ | `🔍` | Enforces explicit `sandbox` attribute for `iframe` elements. | |
| [`no-namespace`](./dom-no-namespace) | 2️⃣ | `🔍` | Enforces the absence of a `namespace` in React elements. | |
diff --git a/packages/plugins/eslint-plugin-react-dom/src/configs/recommended.ts b/packages/plugins/eslint-plugin-react-dom/src/configs/recommended.ts
index e2a83d614..e6b6d055f 100644
--- a/packages/plugins/eslint-plugin-react-dom/src/configs/recommended.ts
+++ b/packages/plugins/eslint-plugin-react-dom/src/configs/recommended.ts
@@ -8,6 +8,7 @@ export const rules = {
"react-dom/no-dangerously-set-innerhtml-with-children": "error",
"react-dom/no-find-dom-node": "error",
"react-dom/no-flush-sync": "error",
+ "react-dom/no-hydrate": "error",
"react-dom/no-missing-button-type": "warn",
"react-dom/no-missing-iframe-sandbox": "warn",
"react-dom/no-namespace": "error",
diff --git a/packages/plugins/eslint-plugin-react-dom/src/plugin.ts b/packages/plugins/eslint-plugin-react-dom/src/plugin.ts
index 291a69e62..bb552c45f 100644
--- a/packages/plugins/eslint-plugin-react-dom/src/plugin.ts
+++ b/packages/plugins/eslint-plugin-react-dom/src/plugin.ts
@@ -3,6 +3,7 @@ import noDangerouslySetInnerHTML from "./rules/no-dangerously-set-innerhtml";
import noDangerouslySetInnerHTMLWithChildren from "./rules/no-dangerously-set-innerhtml-with-children";
import noFindDomNode from "./rules/no-find-dom-node";
import noFlushSync from "./rules/no-flush-sync";
+import noHydrate from "./rules/no-hydrate";
import noMissingButtonType from "./rules/no-missing-button-type";
import noMissingIframeSandbox from "./rules/no-missing-iframe-sandbox";
import noNamespace from "./rules/no-namespace";
@@ -25,6 +26,7 @@ export const plugin = {
"no-dangerously-set-innerhtml-with-children": noDangerouslySetInnerHTMLWithChildren,
"no-find-dom-node": noFindDomNode,
"no-flush-sync": noFlushSync,
+ "no-hydrate": noHydrate,
"no-missing-button-type": noMissingButtonType,
"no-missing-iframe-sandbox": noMissingIframeSandbox,
"no-namespace": noNamespace,
diff --git a/packages/plugins/eslint-plugin-react-dom/src/rules/no-hydrate.md b/packages/plugins/eslint-plugin-react-dom/src/rules/no-hydrate.md
new file mode 100644
index 000000000..87c1f238c
--- /dev/null
+++ b/packages/plugins/eslint-plugin-react-dom/src/rules/no-hydrate.md
@@ -0,0 +1,70 @@
+---
+title: no-hydrate
+---
+
+**Full Name in `eslint-plugin-react-dom`**
+
+```plain copy
+react-dom/no-hydrate
+```
+
+**Full Name in `@eslint-react/eslint-plugin`**
+
+```plain copy
+@eslint-react/dom/no-hydrate
+```
+
+**Features**
+
+`🔍` `🔄`
+
+**Presets**
+
+- `dom`
+- `recommended`
+- `recommended-typescript`
+- `recommended-type-checked`
+
+## What it does
+
+Replaces usages of `ReactDom.hydrate()` with `hydrateRoot()`.
+
+An **unsafe** codemod is available for this rule.
+
+## Examples
+
+### Failing
+
+```tsx
+import ReactDom from "react-dom";
+import Component from "Component";
+
+ReactDom.hydrate(, document.getElementById("app"));
+```
+
+### Passing
+
+```tsx
+import { hydrateRoot } from "react-dom/client";
+import ReactDom from "react-dom";
+import Component from "Component";
+
+hydrateRoot(document.getElementById("app"), );
+```
+
+## Implementation
+
+- [Rule source](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom/src/rules/no-hydrate.ts)
+- [Test source](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom/src/rules/no-hydrate.spec.ts)
+
+## Further Reading
+
+- [React: react-dom/hydrate](https://18.react.dev/reference/react-dom/hydrate)
+- [React: react-dom/createRoot](https://react.dev/reference/react-dom/client/hydrateRoot)
+
+---
+
+## See Also
+
+- [no-return](./dom-no-hydrate)\
+ Replaces usages of `ReactDom.hydrate()` with `createRoot(node).hydrate()`.
diff --git a/packages/plugins/eslint-plugin-react-dom/src/rules/no-hydrate.spec.ts b/packages/plugins/eslint-plugin-react-dom/src/rules/no-hydrate.spec.ts
new file mode 100644
index 000000000..daa77ff3e
--- /dev/null
+++ b/packages/plugins/eslint-plugin-react-dom/src/rules/no-hydrate.spec.ts
@@ -0,0 +1,79 @@
+import tsx from "dedent";
+
+import { ruleTester } from "../../../../../test";
+import rule, { RULE_NAME } from "./no-hydrate";
+
+ruleTester.run(RULE_NAME, rule, {
+ invalid: [
+ {
+ code: tsx`
+ import ReactDom from "react-dom";
+ import Component from "Component";
+
+ ReactDom.hydrate(, document.getElementById("app"));
+ `,
+ errors: [{ messageId: "noHydrate" }],
+ output: tsx`
+ import { hydrateRoot } from "react-dom/client";
+ import ReactDom from "react-dom";
+ import Component from "Component";
+
+ hydrateRoot(document.getElementById("app"), );
+ `,
+ },
+ {
+ code: tsx`
+ import React from "react";
+ import ReactDom from "react-dom";
+ import Component from "Component";
+
+ ReactDom.hydrate(, document.getElementById("app")!);
+ `,
+ errors: [{ messageId: "noHydrate" }],
+ output: tsx`
+ import { hydrateRoot } from "react-dom/client";
+ import React from "react";
+ import ReactDom from "react-dom";
+ import Component from "Component";
+
+ hydrateRoot(document.getElementById("app")!, );
+ `,
+ },
+ {
+ code: tsx`
+ import React from "react";
+ import ReactDom from "react-dom";
+ import Component from "Component";
+
+ const rootEl = document.getElementById("app")!;
+ ReactDom.hydrate(, rootEl);
+ `,
+ errors: [{ messageId: "noHydrate" }],
+ output: tsx`
+ import { hydrateRoot } from "react-dom/client";
+ import React from "react";
+ import ReactDom from "react-dom";
+ import Component from "Component";
+
+ const rootEl = document.getElementById("app")!;
+ hydrateRoot(rootEl, );
+ `,
+ },
+ ],
+ valid: [
+ tsx`
+ import React from "react";
+ import { hydrateRoot } from "react-dom/client";
+ import Component from "Component";
+
+ hydrateRoot(document.getElementById("app"), );
+ `,
+ tsx`
+ import React from "react";
+ import { hydrateRoot } from "react-dom/client";
+ import Component from "Component";
+
+ hydrateRoot(document.getElementById("app")!, );
+ `,
+ ],
+});
diff --git a/packages/plugins/eslint-plugin-react-dom/src/rules/no-hydrate.ts b/packages/plugins/eslint-plugin-react-dom/src/rules/no-hydrate.ts
new file mode 100644
index 000000000..f6ee7a4fb
--- /dev/null
+++ b/packages/plugins/eslint-plugin-react-dom/src/rules/no-hydrate.ts
@@ -0,0 +1,100 @@
+import type { RuleFeature } from "@eslint-react/shared";
+import { getSettingsFromContext } from "@eslint-react/shared";
+import type { TSESTree } from "@typescript-eslint/types";
+import { AST_NODE_TYPES as T } from "@typescript-eslint/types";
+import type { RuleFixer } from "@typescript-eslint/utils/ts-eslint";
+import { compare } from "compare-versions";
+import type { CamelCase } from "string-ts";
+
+import type { RuleContext } from "../../../../shared/src/types";
+import { createRule } from "../utils";
+
+export const RULE_NAME = "no-hydrate";
+
+export const RULE_FEATURES = [
+ "CHK",
+ "MOD",
+] as const satisfies RuleFeature[];
+
+export type MessageID = CamelCase;
+
+export default createRule<[], MessageID>({
+ meta: {
+ type: "problem",
+ docs: {
+ description: "replaces usages of 'ReactDom.hydrate()' with 'hydrateRoot()'",
+ [Symbol.for("rule_features")]: RULE_FEATURES,
+ },
+ fixable: "code",
+ messages: {
+ noHydrate: "[Deprecated] Use 'hydrateRoot()' instead.",
+ },
+ schema: [],
+ },
+ name: RULE_NAME,
+ create(context) {
+ if (!context.sourceCode.text.includes("hydrate")) return {};
+ const settings = getSettingsFromContext(context);
+ if (compare(settings.version, "18.0.0", "<")) return {};
+
+ const reactDomNames = new Set();
+ const hydrateNames = new Set();
+
+ return {
+ CallExpression(node) {
+ switch (true) {
+ case node.callee.type === T.Identifier
+ && hydrateNames.has(node.callee.name):
+ context.report({
+ messageId: "noHydrate",
+ node,
+ fix: getFix(context, node),
+ });
+ return;
+ case node.callee.type === T.MemberExpression
+ && node.callee.object.type === T.Identifier
+ && node.callee.property.type === T.Identifier
+ && node.callee.property.name === "hydrate"
+ && reactDomNames.has(node.callee.object.name):
+ context.report({
+ messageId: "noHydrate",
+ node,
+ fix: getFix(context, node),
+ });
+ return;
+ }
+ },
+ ImportDeclaration(node) {
+ const [baseSource] = node.source.value.split("/");
+ if (baseSource !== "react-dom") return;
+ for (const specifier of node.specifiers) {
+ switch (specifier.type) {
+ case T.ImportSpecifier:
+ if (specifier.imported.type !== T.Identifier) continue;
+ if (specifier.imported.name === "hydrate") {
+ hydrateNames.add(specifier.local.name);
+ }
+ continue;
+ case T.ImportDefaultSpecifier:
+ case T.ImportNamespaceSpecifier:
+ reactDomNames.add(specifier.local.name);
+ continue;
+ }
+ }
+ },
+ };
+ },
+ defaultOptions: [],
+});
+
+function getFix(context: RuleContext, node: TSESTree.CallExpression) {
+ const getText = (n: TSESTree.Node) => context.sourceCode.getText(n);
+ return (fixer: RuleFixer) => {
+ const [arg0, arg1] = node.arguments;
+ if (arg0 == null || arg1 == null) return null;
+ return [
+ fixer.insertTextBefore(context.sourceCode.ast, 'import { hydrateRoot } from "react-dom/client";\n'),
+ fixer.replaceText(node, `hydrateRoot(${getText(arg1)}, ${getText(arg0)})`),
+ ];
+ };
+}
diff --git a/packages/plugins/eslint-plugin-react-dom/src/rules/no-render-return-value.md b/packages/plugins/eslint-plugin-react-dom/src/rules/no-render-return-value.md
index 28bfbf4b9..eec882aec 100644
--- a/packages/plugins/eslint-plugin-react-dom/src/rules/no-render-return-value.md
+++ b/packages/plugins/eslint-plugin-react-dom/src/rules/no-render-return-value.md
@@ -70,5 +70,5 @@ ReactDOM.render(, document.body);
## See Also
-- [no-render](./no-render.md)\
+- [no-render](./dom-no-render.md)\
Replaces usages of `ReactDom.render()` with `createRoot(node).render()`.
diff --git a/packages/plugins/eslint-plugin-react-dom/src/rules/no-render.md b/packages/plugins/eslint-plugin-react-dom/src/rules/no-render.md
index c7eb89202..484b44ff3 100644
--- a/packages/plugins/eslint-plugin-react-dom/src/rules/no-render.md
+++ b/packages/plugins/eslint-plugin-react-dom/src/rules/no-render.md
@@ -65,5 +65,7 @@ createRoot(document.getElementById("app")).render();
## See Also
-- [no-render-return-value](./no-render-return-value.md)\
+- [no-render-return-value](./dom-no-render-return-value)\
Prevents usage of the return value of `ReactDOM.render`.
+- [no-hydrate](./dom-no-hydrate)\
+ Replaces usages of `ReactDom.hydrate()` with `createRoot(node).hydrate()`.
diff --git a/packages/plugins/eslint-plugin/src/configs/all.ts b/packages/plugins/eslint-plugin/src/configs/all.ts
index 93cef7f3e..275aeaac5 100644
--- a/packages/plugins/eslint-plugin/src/configs/all.ts
+++ b/packages/plugins/eslint-plugin/src/configs/all.ts
@@ -63,6 +63,8 @@ export const rules = {
"@eslint-react/dom/no-dangerously-set-innerhtml": "warn",
"@eslint-react/dom/no-dangerously-set-innerhtml-with-children": "error",
"@eslint-react/dom/no-find-dom-node": "error",
+ "@eslint-react/dom/no-flush-sync": "error",
+ "@eslint-react/dom/no-hydrate": "error",
"@eslint-react/dom/no-missing-button-type": "warn",
"@eslint-react/dom/no-missing-iframe-sandbox": "warn",
"@eslint-react/dom/no-namespace": "error",
@@ -73,7 +75,7 @@ export const rules = {
"@eslint-react/dom/no-unsafe-iframe-sandbox": "warn",
"@eslint-react/dom/no-unsafe-target-blank": "warn",
"@eslint-react/dom/no-use-form-state": "error",
- "@eslint-react/dom/no-void-elements-with-children": "warn",
+ "@eslint-react/dom/no-void-elements-with-children": "error",
"@eslint-react/web-api/no-leaked-event-listener": "warn",
"@eslint-react/web-api/no-leaked-interval": "warn",