Skip to content

feat(react-dom): add 'no-render' rule, closes #972 #993

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 14 commits 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 @@ -62,6 +62,7 @@
"dom-no-missing-button-type",
"dom-no-missing-iframe-sandbox",
"dom-no-namespace",
"dom-no-render",
"dom-no-render-return-value",
"dom-no-script-url",
"dom-no-unknown-property",
Expand Down
31 changes: 16 additions & 15 deletions apps/website/content/docs/rules/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,21 +73,22 @@ full: true

## DOM Rules

| Rule | ✅ | Features | Description |
| :----------------------------------------------------------------------------------------------- | :- | :------------ | :------------------------------------------------------------------------------------- |
| [`no-dangerously-set-innerhtml`](./dom-no-dangerously-set-innerhtml) | 1️⃣ | `🔍` | Prevents DOM elements using `dangerouslySetInnerHTML`. |
| [`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-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. |
| [`no-render-return-value`](./dom-no-render-return-value) | 2️⃣ | `🔍` | Prevents using the return value of `ReactDOM.render`. |
| [`no-script-url`](./dom-no-script-url) | 1️⃣ | `🔍` | Prevents using `javascript:` URLs as the value of attributes. |
| [`no-unknown-property`](./dom-no-unknown-property) | 1️⃣ | `🔍` `🔧` `⚙️` | Prevents using unknown `DOM` property |
| [`no-unsafe-iframe-sandbox`](./dom-no-unsafe-iframe-sandbox) | 1️⃣ | `🔍` | Enforces `sandbox` attribute for `iframe` elements is not set to unsafe combinations. |
| [`no-unsafe-target-blank`](./dom-no-unsafe-target-blank) | 1️⃣ | `🔍` | Prevents using `target="_blank"` without `rel="noreferrer noopener"`. |
| [`no-void-elements-with-children`](./dom-no-void-elements-with-children) | 2️⃣ | `🔍` | Prevents using `children` in void DOM elements. |
| Rule | ✅ | Features | Description | React DOM |
| :----------------------------------------------------------------------------------------------- | :- | :------------ | :------------------------------------------------------------------------------------- | :-------: |
| [`no-dangerously-set-innerhtml`](./dom-no-dangerously-set-innerhtml) | 1️⃣ | `🔍` | Prevents DOM elements using `dangerouslySetInnerHTML`. | |
| [`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-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. | |
| [`no-render`](./dom-no-render) | 2️⃣ | `🔍` `🔄` | Replaces usages of `ReactDom.render()` with `createRoot(node).render()`. | >=18.0.0 |
| [`no-render-return-value`](./dom-no-render-return-value) | 2️⃣ | `🔍` | Prevents using the return value of `ReactDOM.render`. | |
| [`no-script-url`](./dom-no-script-url) | 1️⃣ | `🔍` | Prevents using `javascript:` URLs as the value of attributes. | |
| [`no-unknown-property`](./dom-no-unknown-property) | 1️⃣ | `🔍` `🔧` `⚙️` | Prevents using unknown `DOM` property | |
| [`no-unsafe-iframe-sandbox`](./dom-no-unsafe-iframe-sandbox) | 1️⃣ | `🔍` | Enforces `sandbox` attribute for `iframe` elements is not set to unsafe combinations. | |
| [`no-unsafe-target-blank`](./dom-no-unsafe-target-blank) | 1️⃣ | `🔍` | Prevents using `target="_blank"` without `rel="noreferrer noopener"`. | |
| [`no-void-elements-with-children`](./dom-no-void-elements-with-children) | 2️⃣ | `🔍` | Prevents using `children` in void DOM elements. | |

## Web API Rules

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export const rules = {
"react-dom/no-missing-button-type": "warn",
"react-dom/no-missing-iframe-sandbox": "warn",
"react-dom/no-namespace": "error",
"react-dom/no-render": "error",
"react-dom/no-render-return-value": "error",
"react-dom/no-script-url": "warn",
"react-dom/no-unknown-property": "warn",
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 @@ -6,6 +6,7 @@ import noFlushSync from "./rules/no-flush-sync";
import noMissingButtonType from "./rules/no-missing-button-type";
import noMissingIframeSandbox from "./rules/no-missing-iframe-sandbox";
import noNamespace from "./rules/no-namespace";
import noRender from "./rules/no-render";
import noRenderReturnValue from "./rules/no-render-return-value";
import noScriptUrl from "./rules/no-script-url";
import noUnknownProperty from "./rules/no-unknown-property";
Expand All @@ -26,6 +27,7 @@ export const plugin = {
"no-missing-button-type": noMissingButtonType,
"no-missing-iframe-sandbox": noMissingIframeSandbox,
"no-namespace": noNamespace,
"no-render": noRender,
"no-render-return-value": noRenderReturnValue,
"no-script-url": noScriptUrl,
"no-unknown-property": noUnknownProperty,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,4 +64,11 @@ ReactDOM.render(<div id="app" ref={doSomethingWithInst} />, document.body);

## Further Reading

- [Legacy React: react-dom/render](https://legacy.reactjs.org/docs/react-dom.html#render)
- [React: react-dom/render](https://18.react.dev/reference/react-dom/render)

---

## See Also

- [no-render](./no-render.md)\
Replaces usages of `ReactDom.render()` with `createRoot(node).render()`.
69 changes: 69 additions & 0 deletions packages/plugins/eslint-plugin-react-dom/src/rules/no-render.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
---
title: no-render
---

**Full Name in `eslint-plugin-react-dom`**

```plain copy
react-dom/no-render
```

**Full Name in `@eslint-react/eslint-plugin`**

```plain copy
@eslint-react/dom/no-render
```

**Features**

`🔍` `🔄`

**Presets**

- `dom`
- `recommended`
- `recommended-typescript`
- `recommended-type-checked`

## What it does

Replaces usages of `ReactDom.render()` with `createRoot(node).render()`.

An **unsafe** codemod is available for this rule.

## Examples

### Failing

```tsx
import ReactDom from "react-dom";
import Component from "Component";

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

### Passing

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

createRoot(document.getElementById("app")).render(<Component />);
```

## Implementation

- [Rule source](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom/src/rules/no-render.ts)
- [Test source](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom/src/rules/no-render.spec.ts)

## Further Reading

- [React: react-dom/render](https://18.react.dev/reference/react-dom/render)
- [React: react-dom/createRoot](https://react.dev/reference/react-dom/client/createRoot)

---

## See Also

- [no-render-return-value](./no-render-return-value.md)\
Prevents usage of the return value of `ReactDOM.render`.
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import tsx from "dedent";

import { ruleTester } from "../../../../../test";
import rule, { RULE_NAME } from "./no-render";

ruleTester.run(RULE_NAME, rule, {
invalid: [
{
code: tsx`
import React from "react";
import { render } from "react-dom/client";
import Component from "Component";

render(<Component />, document.getElementById("app"));
`,
errors: [{ messageId: "noRender" }],
output: tsx`
import { createRoot } from "react-dom/client";
import React from "react";
import { render } from "react-dom/client";
import Component from "Component";

createRoot(document.getElementById("app")).render(<Component />);
`,
},
{
code: tsx`
import React from "react";
import ReactDom from "react-dom";
import Component from "Component";

ReactDom.render(<Component />, document.getElementById("app"));
`,
errors: [{ messageId: "noRender" }],
output: tsx`
import { createRoot } from "react-dom/client";
import React from "react";
import ReactDom from "react-dom";
import Component from "Component";

createRoot(document.getElementById("app")).render(<Component />);
`,
},
{
code: tsx`
import React from "react";
import ReactDom from "react-dom";
import Component from "Component";

ReactDom.render(<Component />, document.getElementById("app")!);
`,
errors: [{ messageId: "noRender" }],
output: tsx`
import { createRoot } from "react-dom/client";
import React from "react";
import ReactDom from "react-dom";
import Component from "Component";

createRoot(document.getElementById("app")!).render(<Component />);
`,
},
{
code: tsx`
import React from "react";
import ReactDom from "react-dom";
import Component from "Component";

const rootEl = document.getElementById("app")!;
ReactDom.render(<Component />, rootEl);
`,
errors: [{ messageId: "noRender" }],
output: tsx`
import { createRoot } from "react-dom/client";
import React from "react";
import ReactDom from "react-dom";
import Component from "Component";

const rootEl = document.getElementById("app")!;
createRoot(rootEl).render(<Component />);
`,
},
],
valid: [
tsx`
import React from "react";
import { render } from "react-dom/client";
import Component from "Component";

createRoot(document.getElementById("app")).render(<Component />);
`,
tsx`
import React from "react";
import ReactDom from "react-dom";
import Component from "Component";

createRoot(document.getElementById("app")).render(<Component />);
`,
],
});
100 changes: 100 additions & 0 deletions packages/plugins/eslint-plugin-react-dom/src/rules/no-render.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-render";

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: "replace usages of 'ReactDom.render()' with 'createRoot(node).render()'",
[Symbol.for("rule_features")]: RULE_FEATURES,
},
fixable: "code",
messages: {
noRender: "[Deprecated] Use 'createRoot(node).render()' instead.",
},
schema: [],
},
name: RULE_NAME,
create(context) {
if (!context.sourceCode.text.includes("render")) return {};
const settings = getSettingsFromContext(context);
if (compare(settings.version, "18.0.0", "<")) return {};

const reactDomNames = new Set<string>();
const renderNames = new Set<string>();

return {
CallExpression(node) {
switch (true) {
case node.callee.type === T.Identifier
&& renderNames.has(node.callee.name):
context.report({
messageId: "noRender",
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 === "render"
&& reactDomNames.has(node.callee.object.name):
context.report({
messageId: "noRender",
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 === "render") {
renderNames.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 { createRoot } from "react-dom/client";\n'),
fixer.replaceText(node, `createRoot(${getText(arg1)}).render(${getText(arg0)})`),
];
};
}
1 change: 1 addition & 0 deletions packages/plugins/eslint-plugin/src/configs/all.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ export const rules = {
"@eslint-react/dom/no-missing-button-type": "warn",
"@eslint-react/dom/no-missing-iframe-sandbox": "warn",
"@eslint-react/dom/no-namespace": "error",
"@eslint-react/dom/no-render": "error",
"@eslint-react/dom/no-render-return-value": "error",
"@eslint-react/dom/no-script-url": "warn",
"@eslint-react/dom/no-unknown-property": "warn",
Expand Down
1 change: 1 addition & 0 deletions packages/plugins/eslint-plugin/src/configs/dom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export const rules = {
"@eslint-react/dom/no-missing-button-type": "warn",
"@eslint-react/dom/no-missing-iframe-sandbox": "warn",
"@eslint-react/dom/no-namespace": "error",
"@eslint-react/dom/no-render": "error",
"@eslint-react/dom/no-render-return-value": "error",
"@eslint-react/dom/no-script-url": "warn",
"@eslint-react/dom/no-unknown-property": "warn",
Expand Down