Skip to content

feat: add codemod feature to react-x/no-string-refs, closes #1044 #1045

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
Apr 9, 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
2 changes: 1 addition & 1 deletion apps/website/content/docs/rules/overview.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ Linter rules can have false positives, false negatives, and some rules are depen
| [`no-set-state-in-component-did-mount`](./no-set-state-in-component-did-mount) | 1️⃣ | | Disallow calling `this.setState` in `componentDidMount` outside of functions, such as callbacks | |
| [`no-set-state-in-component-did-update`](./no-set-state-in-component-did-update) | 1️⃣ | | Disallow calling `this.setState` in `componentDidUpdate` outside of functions, such as callbacks | |
| [`no-set-state-in-component-will-update`](./no-set-state-in-component-will-update) | 1️⃣ | | Disallow calling `this.setState` in `componentWillUpdate` outside of functions, such as callbacks | |
| [`no-string-refs`](./no-string-refs) | 2️⃣ | | Disallow deprecated string `refs` | |
| [`no-string-refs`](./no-string-refs) | 2️⃣ | `🔄` | Replaces string refs with callback refs | >=16.3.0 |
| [`no-unsafe-component-will-mount`](./no-unsafe-component-will-mount) | 1️⃣ | | Warns the usage of `UNSAFE_componentWillMount` in class components | |
| [`no-unsafe-component-will-receive-props`](./no-unsafe-component-will-receive-props) | 1️⃣ | | Warns the usage of `UNSAFE_componentWillReceiveProps` in class components | |
| [`no-unsafe-component-will-update`](./no-unsafe-component-will-update) | 1️⃣ | | Warns the usage of `UNSAFE_componentWillUpdate` in class components | |
Expand Down
33 changes: 22 additions & 11 deletions packages/plugins/eslint-plugin-react-x/src/rules/no-string-refs.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ react-x/no-string-refs
@eslint-react/no-string-refs
```

**Features**

`🔄`

**Presets**

- `x`
Expand All @@ -23,32 +27,39 @@ react-x/no-string-refs

## Description

Disallow deprecated string `refs`.
Replaces string refs with callback refs.

String refs are deprecated in React. Use callback refs instead.

## Examples

### Failing
### Before

```tsx
import React from "react";

function MyComponent() {
return <div ref="ref" />;
// ^^^^^
// - [Deprecated] Use callback refs instead.
class Input extends React.Component {
focus = () => {
this.refs.input.focus();
};
render() {
return <input ref="input" />;
}
}
```

### Passing
### After

```tsx
import React, { useRef } from "react";
import React from "react";

function MyComponent() {
const ref = useRef<HTMLDivElement>(null);
return <div ref={ref} />;
class Input extends React.Component {
focus = () => {
this.refs.input.focus();
};
render() {
return <input ref={ref => this.refs.input = ref} />;
}
}
```

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,92 @@ ruleTester.run(RULE_NAME, rule, {
}
`,
errors: [{ messageId: "noStringRefs" }],
output: tsx`
class Input extends React.Component {
focus = () => {
this.refs.input.focus();
}

render() {
return <input ref={(ref) => { this.refs["input"] = ref; }} />;
}
}
`,
},
{
code: tsx`
class Input extends React.Component {
focus = () => {
this.refs.input.focus();
}

render() {
return <input ref={"input"} />;
}
}
`,
errors: [{ messageId: "noStringRefs" }],
output: tsx`
class Input extends React.Component {
focus = () => {
this.refs.input.focus();
}

render() {
return <input ref={(ref) => { this.refs["input"] = ref; }} />;
}
}
`,
},
{
code: tsx`
class Input extends React.Component {
focus = () => {
this.refs.input.focus();
}

render() {
return <input ref={\`input\`} />;
}
}
`,
errors: [{ messageId: "noStringRefs" }],
output: tsx`
class Input extends React.Component {
focus = () => {
this.refs.input.focus();
}

render() {
return <input ref={(ref) => { this.refs[\`input\`] = ref; }} />;
}
}
`,
},
{
code: tsx`
class Input extends React.Component {
focus = () => {
this.refs.input.focus();
}

render() {
return <input ref={\`inp\${"ut"}\`} />;
}
}
`,
errors: [{ messageId: "noStringRefs" }],
output: tsx`
class Input extends React.Component {
focus = () => {
this.refs.input.focus();
}

render() {
return <input ref={(ref) => { this.refs[\`inp\${"ut"}\`] = ref; }} />;
}
}
`,
},
],
valid: [
Expand Down
78 changes: 52 additions & 26 deletions packages/plugins/eslint-plugin-react-x/src/rules/no-string-refs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,38 +2,27 @@ import type { RuleContext, RuleFeature } from "@eslint-react/kit";
import type { TSESTree } from "@typescript-eslint/types";
import type { RuleListener } from "@typescript-eslint/utils/ts-eslint";
import type { CamelCase } from "string-ts";
import * as ER from "@eslint-react/core";
import { AST_NODE_TYPES as T } from "@typescript-eslint/types";

import { createRule } from "../utils";

export const RULE_NAME = "no-string-refs";

export const RULE_FEATURES = [] as const satisfies RuleFeature[];
export const RULE_FEATURES = [
"MOD",
] as const satisfies RuleFeature[];

export type MessageID = CamelCase<typeof RULE_NAME>;

function containsStringLiteral({ value }: TSESTree.JSXAttribute) {
return value?.type === T.Literal && typeof value.value === "string";
}

function containsStringExpressionContainer({ value }: TSESTree.JSXAttribute) {
if (value?.type !== T.JSXExpressionContainer) {
return false;
}
if (value.expression.type === T.Literal) {
return typeof value.expression.value === "string";
}

return value.expression.type === T.TemplateLiteral;
}

export default createRule<[], MessageID>({
meta: {
type: "problem",
docs: {
description: "Disallow deprecated string `refs`.",
description: "Replaces string refs with callback refs.",
[Symbol.for("rule_features")]: RULE_FEATURES,
},
fixable: "code",
messages: {
noStringRefs: "[Deprecated] Use callback refs instead.",
},
Expand All @@ -45,17 +34,54 @@ export default createRule<[], MessageID>({
});

export function create(context: RuleContext<MessageID, []>): RuleListener {
const state = {
isWithinClassComponent: false,
};

function onClassBodyEnter(node: TSESTree.ClassBody) {
if (ER.isClassComponent(node.parent)) {
state.isWithinClassComponent = true;
}
}

function onClassBodyExit() {
state.isWithinClassComponent = false;
}

return {
ClassBody: onClassBodyEnter,
"ClassBody:exit": onClassBodyExit,
JSXAttribute(node) {
if (node.name.name !== "ref") {
return;
}
if (containsStringLiteral(node) || containsStringExpressionContainer(node)) {
context.report({
messageId: "noStringRefs",
node,
});
}
if (node.name.name !== "ref") return;
const refNameText = getAttributeValueText(context, node.value);
if (refNameText == null) return;
context.report({
messageId: "noStringRefs",
node,
fix(fixer) {
if (node.value == null) return null;
if (!state.isWithinClassComponent) return null;
return fixer.replaceText(node.value, `{(ref) => { this.refs[${refNameText}] = ref; }}`);
},
});
},
};
}

function getAttributeValueText(context: RuleContext, node: TSESTree.JSXAttribute["value"]) {
if (node == null) return null;
switch (true) {
case node.type === T.Literal
&& typeof node.value === "string":
return context.sourceCode.getText(node);
case node.type === T.JSXExpressionContainer
&& node.expression.type === T.Literal
&& typeof node.expression.value === "string":
return context.sourceCode.getText(node.expression);
case node.type === T.JSXExpressionContainer
&& node.expression.type === T.TemplateLiteral:
return context.sourceCode.getText(node.expression);
default:
return null;
}
}
Loading