Skip to content

Commit 7acf1e0

Browse files
authored
feat(lint/js): add noReactStringRefs (#9922)
1 parent 8386dd0 commit 7acf1e0

18 files changed

Lines changed: 548 additions & 0 deletions

File tree

.changeset/fifty-chicken-like.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@biomejs/biome": patch
3+
---
4+
5+
Added the new nursery rule [`noReactStringRefs`](https://biomejs.dev/linter/rules/no-react-string-refs/), which disallows legacy React string refs such as `ref="hello"` and `this.refs.hello`.
6+
7+
Biome also reports template-literal refs such as ``ref={`hello`}``, so React code can consistently migrate to callback refs, `createRef()`, or `useRef()`.

crates/biome_cli/src/execute/migrate/eslint_any_rule_to_biome.rs

Lines changed: 12 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/biome_configuration/src/analyzer/linter/rules.rs

Lines changed: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/biome_configuration/src/generated/domain_selector.rs

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/biome_configuration/src/generated/linter_options_check.rs

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/biome_diagnostics_categories/src/categories.rs

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
use std::sync::Arc;
2+
3+
use camino::Utf8PathBuf;
4+
5+
use crate::react::components::{AnyPotentialReactComponentDeclaration, ReactComponentInfo};
6+
use crate::services::semantic::Semantic;
7+
use biome_analyze::{
8+
Rule, RuleDiagnostic, RuleDomain, RuleSource, context::RuleContext, declare_lint_rule,
9+
};
10+
use biome_console::markup;
11+
use biome_diagnostics::Severity;
12+
use biome_js_syntax::{
13+
AnyJsExpression, AnyJsMemberExpression, AnyJsTemplateElement, AnyJsxAttributeValue,
14+
JsxAttribute,
15+
};
16+
use biome_package::PackageJson;
17+
use biome_rowan::{AstNode, TextRange, declare_node_union};
18+
use biome_rule_options::no_react_string_refs::NoReactStringRefsOptions;
19+
20+
declare_lint_rule! {
21+
/// Disallow string refs in React components.
22+
///
23+
/// String refs are a legacy React feature. Modern React code should use callback refs,
24+
/// `createRef()`, or `useRef()` instead.
25+
///
26+
/// Biome also flags template literal refs, even though upstream only does so through an option.
27+
///
28+
/// ## Examples
29+
///
30+
/// ### Invalid
31+
///
32+
/// ```jsx,expect_diagnostic
33+
/// function Hello() {
34+
/// return <div ref="hello">Hello</div>;
35+
/// }
36+
/// ```
37+
///
38+
/// ```jsx,expect_diagnostic
39+
/// function Hello({ id }) {
40+
/// return <div ref={`hello-${id}`}>Hello</div>;
41+
/// }
42+
/// ```
43+
///
44+
/// ```jsx,expect_diagnostic
45+
/// class Hello extends React.Component {
46+
/// componentDidMount() {
47+
/// this.refs.hello.focus();
48+
/// }
49+
/// }
50+
/// ```
51+
///
52+
/// ### Valid
53+
///
54+
/// ```jsx
55+
/// function Hello() {
56+
/// const helloRef = useRef(null);
57+
/// return <div ref={helloRef}>Hello</div>;
58+
/// }
59+
/// ```
60+
///
61+
pub NoReactStringRefs {
62+
version: "next",
63+
name: "noReactStringRefs",
64+
language: "js",
65+
sources: &[RuleSource::EslintReact("no-string-refs").same()],
66+
domains: &[RuleDomain::React],
67+
recommended: true,
68+
severity: Severity::Warning,
69+
}
70+
}
71+
72+
declare_node_union! {
73+
pub AnyNoReactStringRefsQuery = JsxAttribute | AnyJsMemberExpression
74+
}
75+
76+
pub enum NoReactStringRefsState {
77+
RefAttribute(TextRange),
78+
ThisRefs(TextRange),
79+
}
80+
81+
impl Rule for NoReactStringRefs {
82+
type Query = Semantic<AnyNoReactStringRefsQuery>;
83+
type State = NoReactStringRefsState;
84+
type Signals = Option<Self::State>;
85+
type Options = NoReactStringRefsOptions;
86+
87+
fn run(ctx: &RuleContext<Self>) -> Self::Signals {
88+
match ctx.query() {
89+
AnyNoReactStringRefsQuery::JsxAttribute(attribute) => {
90+
let name = attribute.name().ok()?;
91+
let name = name.as_jsx_name()?;
92+
if name.value_token().ok()?.text_trimmed() != "ref" {
93+
return None;
94+
}
95+
96+
let value = attribute.initializer()?.value().ok()?;
97+
is_string_ref_value(&value)
98+
.then_some(NoReactStringRefsState::RefAttribute(value.range()))
99+
}
100+
AnyNoReactStringRefsQuery::AnyJsMemberExpression(member_expression) => {
101+
if is_react_18_3_or_higher(ctx) {
102+
return None;
103+
}
104+
105+
let refs_range = this_refs_range(member_expression)?;
106+
107+
member_expression
108+
.syntax()
109+
.ancestors()
110+
.filter_map(AnyPotentialReactComponentDeclaration::cast)
111+
.find_map(|declaration| {
112+
ReactComponentInfo::from_declaration(declaration.syntax())
113+
})
114+
.map(|_| NoReactStringRefsState::ThisRefs(refs_range))
115+
}
116+
}
117+
}
118+
119+
fn diagnostic(_ctx: &RuleContext<Self>, state: &Self::State) -> Option<RuleDiagnostic> {
120+
let (range, message, note, help) = match state {
121+
NoReactStringRefsState::RefAttribute(range) => (
122+
*range,
123+
markup! { "String refs are deprecated." },
124+
markup! {
125+
"String refs are a legacy React feature that can make ref usage harder to follow."
126+
},
127+
markup! {
128+
"Use a callback ref or an object ref created with "<Emphasis>"createRef()"</Emphasis>" or "<Emphasis>"useRef()"</Emphasis>" instead."
129+
},
130+
),
131+
NoReactStringRefsState::ThisRefs(range) => (
132+
*range,
133+
markup! { "Using "<Emphasis>"this.refs"</Emphasis>" is deprecated." },
134+
markup! {
135+
"Accessing refs through "<Emphasis>"this.refs"</Emphasis>" relies on React's legacy string ref behavior."
136+
},
137+
markup! {
138+
"Store the ref on an instance field with a callback ref, or switch to "<Emphasis>"createRef()"</Emphasis>" or "<Emphasis>"useRef()"</Emphasis>"."
139+
},
140+
),
141+
};
142+
143+
Some(
144+
RuleDiagnostic::new(rule_category!(), range, message)
145+
.note(note)
146+
.note(help),
147+
)
148+
}
149+
}
150+
151+
/// Check if the attribute value is a string ref, which can be either a string literal or a template literal with only string chunks.
152+
fn is_string_ref_value(value: &AnyJsxAttributeValue) -> bool {
153+
match value {
154+
AnyJsxAttributeValue::AnyJsxTag(_) => false,
155+
AnyJsxAttributeValue::JsxString(_) => true,
156+
AnyJsxAttributeValue::JsxExpressionAttributeValue(expression) => {
157+
match expression.expression().ok() {
158+
Some(AnyJsExpression::AnyJsLiteralExpression(literal)) => {
159+
literal.as_js_string_literal_expression().is_some()
160+
}
161+
Some(AnyJsExpression::JsTemplateExpression(template)) => {
162+
template.elements().into_iter().any(|element| {
163+
matches!(
164+
element,
165+
AnyJsTemplateElement::JsTemplateChunkElement(_)
166+
| AnyJsTemplateElement::JsTemplateElement(_)
167+
)
168+
})
169+
}
170+
_ => false,
171+
}
172+
}
173+
}
174+
}
175+
176+
fn is_react_18_3_or_higher(ctx: &RuleContext<NoReactStringRefs>) -> bool {
177+
ctx.get_service::<Option<(Utf8PathBuf, Arc<PackageJson>)>>()
178+
.and_then(|manifest| {
179+
manifest
180+
.as_ref()
181+
.map(|(_, package_json)| package_json.matches_dependency("react", ">=18.3.0"))
182+
})
183+
== Some(true)
184+
}
185+
186+
/// Returns `Some` if the member expression is `this.refs`, otherwise `None`.
187+
fn this_refs_range(member_expression: &AnyJsMemberExpression) -> Option<TextRange> {
188+
if member_expression
189+
.object()
190+
.ok()?
191+
.as_js_this_expression()
192+
.is_some()
193+
&& member_expression.member_name()?.text() == "refs"
194+
{
195+
return Some(member_expression.range());
196+
}
197+
198+
None
199+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
/* should generate diagnostics */
2+
import React from "react";
3+
4+
class StringRefs extends React.Component {
5+
componentDidMount() {
6+
this.refs.hello.focus();
7+
this.refs["world"]?.focus();
8+
}
9+
10+
render() {
11+
return (
12+
<section>
13+
<div ref="hello">Hello</div>
14+
<div ref={"world"}>World</div>
15+
<div ref={`template`}>Template</div>
16+
<div ref={`template-${id}`}>Template Expression</div>
17+
</section>
18+
);
19+
}
20+
}

0 commit comments

Comments
 (0)