Skip to content

feat(react-dom): add 'no-hydrate' rule to replace ReactDom.hydrate with hydrateRoot(), closes #973 #995

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Mar 17, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/website/content/docs/rules/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions apps/website/content/docs/rules/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. | |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions packages/plugins/eslint-plugin-react-dom/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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,
Expand Down
70 changes: 70 additions & 0 deletions packages/plugins/eslint-plugin-react-dom/src/rules/no-hydrate.md
Original file line number Diff line number Diff line change
@@ -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(<Component />, document.getElementById("app"));
```

### Passing

```tsx
import { hydrateRoot } from "react-dom/client";
import ReactDom from "react-dom";
import Component from "Component";

hydrateRoot(document.getElementById("app"), <Component />);
```

## 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()`.
Original file line number Diff line number Diff line change
@@ -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(<Component />, 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"), <Component />);
`,
},
{
code: tsx`
import React from "react";
import ReactDom from "react-dom";
import Component from "Component";

ReactDom.hydrate(<Component />, 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")!, <Component />);
`,
},
{
code: tsx`
import React from "react";
import ReactDom from "react-dom";
import Component from "Component";

const rootEl = document.getElementById("app")!;
ReactDom.hydrate(<Component />, 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, <Component />);
`,
},
],
valid: [
tsx`
import React from "react";
import { hydrateRoot } from "react-dom/client";
import Component from "Component";

hydrateRoot(document.getElementById("app"), <Component />);
`,
tsx`
import React from "react";
import { hydrateRoot } from "react-dom/client";
import Component from "Component";

hydrateRoot(document.getElementById("app")!, <Component />);
`,
],
});
100 changes: 100 additions & 0 deletions packages/plugins/eslint-plugin-react-dom/src/rules/no-hydrate.ts
Original file line number Diff line number Diff line change
@@ -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<typeof RULE_NAME>;

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<string>();
const hydrateNames = new Set<string>();

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)})`),
];
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -70,5 +70,5 @@ ReactDOM.render(<div id="app" ref={doSomethingWithInst} />, document.body);

## See Also

- [no-render](./no-render.md)\
- [no-render](./dom-no-render.md)\
Replaces usages of `ReactDom.render()` with `createRoot(node).render()`.
Original file line number Diff line number Diff line change
Expand Up @@ -65,5 +65,7 @@ createRoot(document.getElementById("app")).render(<Component />);

## 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()`.
4 changes: 3 additions & 1 deletion packages/plugins/eslint-plugin/src/configs/all.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down