Skip to content

Commit c434f01

Browse files
sterliakovematipicodyc3
authored
feat(linter): add excludedComponents option to useUniqueElementIds (#6723)
Co-authored-by: Emanuele Stoppa <[email protected]> Co-authored-by: ematipico <[email protected]> Co-authored-by: dyc3 <[email protected]>
1 parent 51bf430 commit c434f01

File tree

12 files changed

+331
-11
lines changed

12 files changed

+331
-11
lines changed

.changeset/vast-tables-dance.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@biomejs/biome": patch
3+
---
4+
5+
`useUniqueElementIds` now has an `excludedComponents` option to support elements using `id` prop for reasons not related to DOM element id. Fixed [#6722](https://github.com/biomejs/biome/issues/6722).

crates/biome_js_analyze/src/lint/nursery/use_unique_element_ids.rs

Lines changed: 67 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ use biome_js_syntax::{
66
AnyJsExpression, AnyJsxAttributeValue, JsCallExpression, JsPropertyObjectMember, JsxAttribute,
77
jsx_ext::AnyJsxElement,
88
};
9-
use biome_rowan::{AstNode, declare_node_union};
9+
use biome_rowan::{AstNode, TokenText, declare_node_union};
1010
use biome_rule_options::use_unique_element_ids::UseUniqueElementIdsOptions;
1111

1212
use crate::react::{ReactApiCall, ReactCreateElementCall};
@@ -44,6 +44,37 @@ declare_lint_rule! {
4444
/// React.createElement("div", { id });
4545
/// ```
4646
///
47+
/// ## Options
48+
///
49+
/// The following option is available
50+
///
51+
/// ### `excludedComponents`
52+
///
53+
/// List of unqualified component names to ignore.
54+
/// Use it to list components expecting an `id` attribute that does not represent
55+
/// a DOM element ID.
56+
///
57+
/// **Default**: empty list.
58+
///
59+
/// ```json,options
60+
/// {
61+
/// "options": {
62+
/// "excludedComponents": [
63+
/// "FormattedMessage"
64+
/// ]
65+
/// }
66+
/// }
67+
/// ```
68+
///
69+
/// ```jsx,use_options
70+
/// <FormattedMessage id="static" />
71+
/// ```
72+
///
73+
/// ```jsx,use_options
74+
/// <Library.FormattedMessage id="static" />
75+
/// ```
76+
///
77+
///
4778
pub UseUniqueElementIds {
4879
version: "2.0.0",
4980
name: "useUniqueElementIds",
@@ -63,16 +94,37 @@ declare_node_union! {
6394
}
6495

6596
impl UseUniqueElementIdsQuery {
66-
fn find_id_attribute(&self, model: &SemanticModel) -> Option<IdProp> {
97+
fn create_element_call(&self, model: &SemanticModel) -> Option<ReactCreateElementCall> {
6798
match self {
68-
Self::AnyJsxElement(jsx) => jsx.find_attribute_by_name("id").map(IdProp::from),
6999
Self::JsCallExpression(expression) => {
70-
let react_create_element =
71-
ReactCreateElementCall::from_call_expression(expression, model)?;
72-
react_create_element
73-
.find_prop_by_name("id")
74-
.map(IdProp::from)
100+
ReactCreateElementCall::from_call_expression(expression, model)
75101
}
102+
&Self::AnyJsxElement(_) => None,
103+
}
104+
}
105+
106+
fn element_name(&self, model: &SemanticModel) -> Option<TokenText> {
107+
match self {
108+
Self::AnyJsxElement(jsx) => jsx
109+
.name_value_token()
110+
.ok()
111+
.map(|tok| tok.token_text_trimmed()),
112+
Self::JsCallExpression(_) => self
113+
.create_element_call(model)?
114+
.element_type
115+
.as_any_js_expression()?
116+
.get_callee_member_name()
117+
.map(|tok| tok.token_text_trimmed()),
118+
}
119+
}
120+
121+
fn find_id_attribute(&self, model: &SemanticModel) -> Option<IdProp> {
122+
match self {
123+
Self::AnyJsxElement(jsx) => jsx.find_attribute_by_name("id").map(IdProp::from),
124+
Self::JsCallExpression(_) => self
125+
.create_element_call(model)?
126+
.find_prop_by_name("id")
127+
.map(IdProp::from),
76128
}
77129
}
78130
}
@@ -86,6 +138,13 @@ impl Rule for UseUniqueElementIds {
86138
fn run(ctx: &RuleContext<Self>) -> Self::Signals {
87139
let node = ctx.query();
88140
let model = ctx.model();
141+
let options = ctx.options();
142+
if node
143+
.element_name(model)
144+
.is_some_and(|name| options.excluded_components.contains(name.text()))
145+
{
146+
return None;
147+
}
89148
let id_attribute = node.find_id_attribute(model)?;
90149

91150
match id_attribute {

crates/biome_js_analyze/src/react/components.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,7 @@ impl ReactComponentInfo {
198198

199199
/// Creates a `ReactComponentInfo` from an expression.
200200
/// It is not guaranteed that the expression is a React component,
201-
/// but if any reqiuirements are not met, it will return `None`.
201+
/// but if any requirements are not met, it will return `None`.
202202
/// Never returns a name, can only return a name hint.
203203
pub(crate) fn from_expression(syntax: &SyntaxNode<JsLanguage>) -> Option<Self> {
204204
let any_expression = AnyJsExpression::cast_ref(syntax)?;
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
// allowed
2+
function WithJsx() {
3+
return <FormattedMessage id="abc"></FormattedMessage>
4+
}
5+
6+
function WithJsxSelfClosing() {
7+
return <FormattedMessage id="abc"/>
8+
}
9+
10+
function WithJsxNamespaced() {
11+
return <Library.FormattedMessage id="abc"/>
12+
}
13+
14+
function WithCreateElement() {
15+
return React.createElement(FormattedMessage, {id: "abc"})
16+
}
17+
18+
function WithCreateElement2() {
19+
return React.createElement(Library.FormattedMessage, {id: "abc"})
20+
}
21+
22+
// denied
23+
function WithJsxOther() {
24+
return <OtherFormattedMessage id="abc"></OtherFormattedMessage>
25+
}
26+
27+
function WithCreateElementOther() {
28+
return React.createElement(OtherFormattedMessage, {id: "abc"})
29+
}
30+
31+
function WithCreateElementWronglyQuoted() {
32+
return React.createElement("FormattedMessage", {id: "abc"})
33+
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
---
2+
source: crates/biome_js_analyze/tests/spec_tests.rs
3+
expression: allowlist.jsx
4+
---
5+
# Input
6+
```jsx
7+
// allowed
8+
function WithJsx() {
9+
return <FormattedMessage id="abc"></FormattedMessage>
10+
}
11+
12+
function WithJsxSelfClosing() {
13+
return <FormattedMessage id="abc"/>
14+
}
15+
16+
function WithJsxNamespaced() {
17+
return <Library.FormattedMessage id="abc"/>
18+
}
19+
20+
function WithCreateElement() {
21+
return React.createElement(FormattedMessage, {id: "abc"})
22+
}
23+
24+
function WithCreateElement2() {
25+
return React.createElement(Library.FormattedMessage, {id: "abc"})
26+
}
27+
28+
// denied
29+
function WithJsxOther() {
30+
return <OtherFormattedMessage id="abc"></OtherFormattedMessage>
31+
}
32+
33+
function WithCreateElementOther() {
34+
return React.createElement(OtherFormattedMessage, {id: "abc"})
35+
}
36+
37+
function WithCreateElementWronglyQuoted() {
38+
return React.createElement("FormattedMessage", {id: "abc"})
39+
}
40+
41+
```
42+
43+
# Diagnostics
44+
```
45+
allowlist.jsx:24:9 lint/nursery/useUniqueElementIds ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
46+
47+
× id attribute should not be a static string literal. Generate unique IDs using useId().
48+
49+
22 │ // denied
50+
23 │ function WithJsxOther() {
51+
> 24return <OtherFormattedMessage id="abc"></OtherFormattedMessage>
52+
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
53+
25}
54+
26 │
55+
56+
i In React, if you hardcode IDs and use the component multiple times, it can lead to duplicate IDs in the DOM. Instead, generate unique IDs using useId().
57+
58+
59+
```
60+
61+
```
62+
allowlist.jsx:28:9 lint/nursery/useUniqueElementIds ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
63+
64+
× id attribute should not be a static string literal. Generate unique IDs using useId().
65+
66+
27 │ function WithCreateElementOther() {
67+
> 28return React.createElement(OtherFormattedMessage, {id: "abc"})
68+
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
69+
29}
70+
30 │
71+
72+
i In React, if you hardcode IDs and use the component multiple times, it can lead to duplicate IDs in the DOM. Instead, generate unique IDs using useId().
73+
74+
75+
```
76+
77+
```
78+
allowlist.jsx:32:9 lint/nursery/useUniqueElementIds ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
79+
80+
× id attribute should not be a static string literal. Generate unique IDs using useId().
81+
82+
31 │ function WithCreateElementWronglyQuoted() {
83+
> 32return React.createElement("FormattedMessage", {id: "abc"})
84+
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
85+
33}
86+
34 │
87+
88+
i In React, if you hardcode IDs and use the component multiple times, it can lead to duplicate IDs in the DOM. Instead, generate unique IDs using useId().
89+
90+
91+
```
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"$schema": "../../../../../../packages/@biomejs/biome/configuration_schema.json",
3+
"linter": {
4+
"rules": {
5+
"nursery": {
6+
"useUniqueElementIds": {
7+
"level": "error",
8+
"options": {
9+
"excludedComponents": ["FormattedMessage"]
10+
}
11+
}
12+
}
13+
}
14+
}
15+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
function WithJsxNamespaced() {
2+
return <Library.FormattedMessage id="abc"/>
3+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
---
2+
source: crates/biome_js_analyze/tests/spec_tests.rs
3+
expression: badAllowlist.jsx
4+
---
5+
# Input
6+
```jsx
7+
function WithJsxNamespaced() {
8+
return <Library.FormattedMessage id="abc"/>
9+
}
10+
11+
```
12+
13+
# Diagnostics
14+
```
15+
badAllowlist.options:8:32 deserialize ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
16+
17+
× 'excludedComponents' does not accept values with dots.
18+
19+
6 │ "useUniqueElementIds": {
20+
7"level": "error",
21+
> 8"options": {
22+
│ ^
23+
> 9 │ "excludedComponents": ["Library.FormattedMessage"]
24+
> 10 │ }
25+
^
26+
11}
27+
12 │ }
28+
29+
30+
```
31+
32+
```
33+
badAllowlist.jsx:2:9 lint/nursery/useUniqueElementIds ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
34+
35+
× id attribute should not be a static string literal. Generate unique IDs using useId().
36+
37+
1 │ function WithJsxNamespaced() {
38+
> 2return <Library.FormattedMessage id="abc"/>
39+
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
40+
3}
41+
4 │
42+
43+
i In React, if you hardcode IDs and use the component multiple times, it can lead to duplicate IDs in the DOM. Instead, generate unique IDs using useId().
44+
45+
46+
```
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"$schema": "../../../../../../packages/@biomejs/biome/configuration_schema.json",
3+
"linter": {
4+
"rules": {
5+
"nursery": {
6+
"useUniqueElementIds": {
7+
"level": "error",
8+
"options": {
9+
"excludedComponents": ["Library.FormattedMessage"]
10+
}
11+
}
12+
}
13+
}
14+
}
15+
}
Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,45 @@
1+
use biome_console::markup;
2+
use biome_deserialize::{
3+
DeserializableValidator, DeserializationContext, DeserializationDiagnostic, TextRange,
4+
};
15
use biome_deserialize_macros::Deserializable;
6+
use rustc_hash::FxHashSet;
27
use serde::{Deserialize, Serialize};
8+
39
#[derive(Default, Clone, Debug, Deserialize, Deserializable, Eq, PartialEq, Serialize)]
410
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
511
#[serde(rename_all = "camelCase", deny_unknown_fields, default)]
6-
pub struct UseUniqueElementIdsOptions {}
12+
#[deserializable(with_validator)]
13+
pub struct UseUniqueElementIdsOptions {
14+
/// Component names that accept an `id` prop that does not translate
15+
/// to a DOM element id.
16+
pub excluded_components: FxHashSet<Box<str>>,
17+
}
18+
19+
impl DeserializableValidator for UseUniqueElementIdsOptions {
20+
fn validate(
21+
&mut self,
22+
ctx: &mut impl DeserializationContext,
23+
_name: &str,
24+
range: TextRange,
25+
) -> bool {
26+
for name in &self.excluded_components {
27+
let msg = if name.is_empty() {
28+
"empty values"
29+
} else if name.contains('.') {
30+
"values with dots"
31+
} else {
32+
continue;
33+
};
34+
ctx.report(
35+
DeserializationDiagnostic::new(markup!(
36+
<Emphasis>"'excludedComponents'"</Emphasis>" does not accept "{msg}"."
37+
))
38+
.with_range(range),
39+
);
40+
return false;
41+
}
42+
43+
true
44+
}
45+
}

0 commit comments

Comments
 (0)