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",