Skip to content

Commit 79adaea

Browse files
dibashthapaautofix-ci[bot]ematipico
authored
feat(lint): added new rule no-leaked-render from eslint-react (#8171)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Emanuele Stoppa <[email protected]>
1 parent 6f49d95 commit 79adaea

File tree

15 files changed

+1146
-85
lines changed

15 files changed

+1146
-85
lines changed

.changeset/calm-shrimps-study.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@biomejs/biome': patch
3+
---
4+
5+
Added the new rule [`noLeakedRender`](https://biomejs.dev/linter/rules/no-leaked-render). This rule helps prevent potential leaks when rendering components that use binary expressions or ternaries.
6+
7+
For example, the following code triggers the rule because the component would render `0`:
8+
9+
```jsx
10+
const Component = () => {
11+
const count = 0;
12+
return <div>{count && <span>Count: {count}</span>}</div>;
13+
}
14+
```

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

Lines changed: 16 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: 105 additions & 84 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_diagnostics_categories/src/categories.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,7 @@ define_categories! {
175175
"lint/nursery/noImportCycles": "https://biomejs.dev/linter/rules/no-import-cycles",
176176
"lint/nursery/noIncrementDecrement": "https://biomejs.dev/linter/rules/no-increment-decrement",
177177
"lint/nursery/noJsxLiterals": "https://biomejs.dev/linter/rules/no-jsx-literals",
178+
"lint/nursery/noLeakedRender": "https://biomejs.dev/linter/rules/no-leaked-render",
178179
"lint/nursery/noMissingGenericFamilyKeyword": "https://biomejs.dev/linter/rules/no-missing-generic-family-keyword",
179180
"lint/nursery/noMisusedPromises": "https://biomejs.dev/linter/rules/no-misused-promises",
180181
"lint/nursery/noNextAsyncClientComponent": "https://biomejs.dev/linter/rules/no-next-async-client-component",

crates/biome_js_analyze/src/lint/nursery.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ pub mod no_for_in;
1111
pub mod no_import_cycles;
1212
pub mod no_increment_decrement;
1313
pub mod no_jsx_literals;
14+
pub mod no_leaked_render;
1415
pub mod no_misused_promises;
1516
pub mod no_next_async_client_component;
1617
pub mod no_parameters_only_used_in_recursion;
@@ -40,4 +41,4 @@ pub mod use_sorted_classes;
4041
pub mod use_spread;
4142
pub mod use_vue_define_macros_order;
4243
pub mod use_vue_multi_word_component_names;
43-
declare_lint_group! { pub Nursery { name : "nursery" , rules : [self :: no_continue :: NoContinue , self :: no_deprecated_imports :: NoDeprecatedImports , self :: no_empty_source :: NoEmptySource , self :: no_floating_promises :: NoFloatingPromises , self :: no_for_in :: NoForIn , self :: no_import_cycles :: NoImportCycles , self :: no_increment_decrement :: NoIncrementDecrement , self :: no_jsx_literals :: NoJsxLiterals , self :: no_misused_promises :: NoMisusedPromises , self :: no_next_async_client_component :: NoNextAsyncClientComponent , self :: no_parameters_only_used_in_recursion :: NoParametersOnlyUsedInRecursion , self :: no_react_forward_ref :: NoReactForwardRef , self :: no_shadow :: NoShadow , self :: no_sync_scripts :: NoSyncScripts , self :: no_ternary :: NoTernary , self :: no_unknown_attribute :: NoUnknownAttribute , self :: no_unnecessary_conditions :: NoUnnecessaryConditions , self :: no_unresolved_imports :: NoUnresolvedImports , self :: no_unused_expressions :: NoUnusedExpressions , self :: no_useless_catch_binding :: NoUselessCatchBinding , self :: no_useless_undefined :: NoUselessUndefined , self :: no_vue_data_object_declaration :: NoVueDataObjectDeclaration , self :: no_vue_duplicate_keys :: NoVueDuplicateKeys , self :: no_vue_reserved_keys :: NoVueReservedKeys , self :: no_vue_reserved_props :: NoVueReservedProps , self :: use_array_sort_compare :: UseArraySortCompare , self :: use_consistent_arrow_return :: UseConsistentArrowReturn , self :: use_exhaustive_switch_cases :: UseExhaustiveSwitchCases , self :: use_explicit_type :: UseExplicitType , self :: use_find :: UseFind , self :: use_max_params :: UseMaxParams , self :: use_qwik_method_usage :: UseQwikMethodUsage , self :: use_qwik_valid_lexical_scope :: UseQwikValidLexicalScope , self :: use_sorted_classes :: UseSortedClasses , self :: use_spread :: UseSpread , self :: use_vue_define_macros_order :: UseVueDefineMacrosOrder , self :: use_vue_multi_word_component_names :: UseVueMultiWordComponentNames ,] } }
44+
declare_lint_group! { pub Nursery { name : "nursery" , rules : [self :: no_continue :: NoContinue , self :: no_deprecated_imports :: NoDeprecatedImports , self :: no_empty_source :: NoEmptySource , self :: no_floating_promises :: NoFloatingPromises , self :: no_for_in :: NoForIn , self :: no_import_cycles :: NoImportCycles , self :: no_increment_decrement :: NoIncrementDecrement , self :: no_jsx_literals :: NoJsxLiterals , self :: no_leaked_render :: NoLeakedRender , self :: no_misused_promises :: NoMisusedPromises , self :: no_next_async_client_component :: NoNextAsyncClientComponent , self :: no_parameters_only_used_in_recursion :: NoParametersOnlyUsedInRecursion , self :: no_react_forward_ref :: NoReactForwardRef , self :: no_shadow :: NoShadow , self :: no_sync_scripts :: NoSyncScripts , self :: no_ternary :: NoTernary , self :: no_unknown_attribute :: NoUnknownAttribute , self :: no_unnecessary_conditions :: NoUnnecessaryConditions , self :: no_unresolved_imports :: NoUnresolvedImports , self :: no_unused_expressions :: NoUnusedExpressions , self :: no_useless_catch_binding :: NoUselessCatchBinding , self :: no_useless_undefined :: NoUselessUndefined , self :: no_vue_data_object_declaration :: NoVueDataObjectDeclaration , self :: no_vue_duplicate_keys :: NoVueDuplicateKeys , self :: no_vue_reserved_keys :: NoVueReservedKeys , self :: no_vue_reserved_props :: NoVueReservedProps , self :: use_array_sort_compare :: UseArraySortCompare , self :: use_consistent_arrow_return :: UseConsistentArrowReturn , self :: use_exhaustive_switch_cases :: UseExhaustiveSwitchCases , self :: use_explicit_type :: UseExplicitType , self :: use_find :: UseFind , self :: use_max_params :: UseMaxParams , self :: use_qwik_method_usage :: UseQwikMethodUsage , self :: use_qwik_valid_lexical_scope :: UseQwikValidLexicalScope , self :: use_sorted_classes :: UseSortedClasses , self :: use_spread :: UseSpread , self :: use_vue_define_macros_order :: UseVueDefineMacrosOrder , self :: use_vue_multi_word_component_names :: UseVueMultiWordComponentNames ,] } }
Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,276 @@
1+
use biome_analyze::{
2+
Rule, RuleDiagnostic, RuleDomain, RuleSource, context::RuleContext, declare_lint_rule,
3+
};
4+
use biome_console::markup;
5+
use biome_js_syntax::{
6+
AnyJsExpression, JsConditionalExpression, JsLogicalExpression, JsLogicalOperator, JsSyntaxNode,
7+
JsxExpressionAttributeValue, JsxExpressionChild, JsxTagExpression,
8+
binding_ext::AnyJsBindingDeclaration,
9+
};
10+
use biome_rowan::{AstNode, declare_node_union};
11+
use biome_rule_options::no_leaked_render::NoLeakedRenderOptions;
12+
13+
use crate::services::semantic::Semantic;
14+
15+
declare_lint_rule! {
16+
/// Prevent problematic leaked values from being rendered.
17+
///
18+
/// This rule prevents values that might cause unintentionally rendered values
19+
/// or rendering crashes in React JSX. When using conditional rendering with the
20+
/// logical AND operator (`&&`), if the left-hand side evaluates to a falsy value like
21+
/// `0`, `NaN`, or any empty string, these values will be rendered instead of rendering nothing.
22+
///
23+
///
24+
/// ## Examples
25+
///
26+
/// ### Invalid
27+
///
28+
/// ```jsx,expect_diagnostic
29+
/// const Component = () => {
30+
/// const count = 0;
31+
/// return <div>{count && <span>Count: {count}</span>}</div>;
32+
/// }
33+
/// ```
34+
///
35+
/// ```jsx,expect_diagnostic
36+
/// const Component = () => {
37+
/// const items = [];
38+
/// return <div>{items.length && <List items={items} />}</div>;
39+
/// }
40+
/// ```
41+
///
42+
/// ```jsx,expect_diagnostic
43+
/// const Component = () => {
44+
/// const user = null;
45+
/// return <div>{user && <Profile user={user} />}</div>;
46+
/// }
47+
/// ```
48+
///
49+
///
50+
/// ### Valid
51+
///
52+
/// ```jsx
53+
/// const Component = () => {
54+
/// const count = 0;
55+
/// return <div>{count > 0 && <span>Count: {count}</span>}</div>;
56+
/// }
57+
/// ```
58+
///
59+
/// ```jsx
60+
/// const Component = () => {
61+
/// const items = [];
62+
/// return <div>{!!items.length && <List items={items} />}</div>;
63+
/// }
64+
/// ```
65+
///
66+
/// ```jsx
67+
/// const Component = () => {
68+
/// const user = null;
69+
/// return <div>{user ? <Profile user={user} /> : null}</div>;
70+
/// }
71+
/// ```
72+
///
73+
/// ```jsx
74+
/// const Component = () => {
75+
/// const condition = false;
76+
/// return <div>{condition ? <Content /> : <Fallback />}</div>;
77+
/// }
78+
/// ```
79+
///
80+
/// ```jsx
81+
/// const Component = () => {
82+
/// const isReady = true;
83+
/// return <div>{isReady && <Content />}</div>;
84+
/// }
85+
/// ```
86+
87+
pub NoLeakedRender{
88+
version: "next",
89+
name: "noLeakedRender",
90+
language: "jsx",
91+
domains: &[RuleDomain::React],
92+
sources: &[
93+
RuleSource::EslintReact("no-leaked-render").inspired(),
94+
],
95+
recommended: false,
96+
}
97+
}
98+
99+
impl Rule for NoLeakedRender {
100+
type Query = Semantic<NoLeakedRenderQuery>;
101+
type State = bool;
102+
type Signals = Option<Self::State>;
103+
type Options = NoLeakedRenderOptions;
104+
105+
fn run(ctx: &RuleContext<Self>) -> Self::Signals {
106+
let query = ctx.query();
107+
let model = ctx.model();
108+
109+
if !is_inside_jsx_expression(query.syntax()) {
110+
return None;
111+
}
112+
113+
match query {
114+
NoLeakedRenderQuery::JsLogicalExpression(exp) => {
115+
let op = exp.operator().ok()?;
116+
117+
if op != JsLogicalOperator::LogicalAnd {
118+
return None;
119+
}
120+
let left = exp.left().ok()?;
121+
122+
let is_left_hand_side_safe = matches!(
123+
left,
124+
AnyJsExpression::JsUnaryExpression(_)
125+
| AnyJsExpression::JsCallExpression(_)
126+
| AnyJsExpression::JsBinaryExpression(_)
127+
);
128+
129+
if is_left_hand_side_safe {
130+
return None;
131+
}
132+
133+
let mut is_nested_left_hand_side_safe = false;
134+
135+
let mut stack = vec![left.clone()];
136+
137+
// Traverse the expression tree iteratively using a stack
138+
// This allows us to check nested expressions without recursion
139+
while let Some(current) = stack.pop() {
140+
match current {
141+
AnyJsExpression::JsLogicalExpression(expr) => {
142+
let left = expr.left().ok()?.omit_parentheses();
143+
let right = expr.right().ok()?.omit_parentheses();
144+
stack.push(left);
145+
stack.push(right);
146+
}
147+
AnyJsExpression::JsParenthesizedExpression(expr) => {
148+
stack.push(expr.expression().ok()?.omit_parentheses());
149+
}
150+
// If we find expressions that coerce to boolean (unary, call, binary),
151+
// then the entire expression is considered safe
152+
AnyJsExpression::JsUnaryExpression(_)
153+
| AnyJsExpression::JsCallExpression(_)
154+
| AnyJsExpression::JsBinaryExpression(_) => {
155+
is_nested_left_hand_side_safe = true;
156+
break;
157+
}
158+
_ => {}
159+
}
160+
}
161+
162+
if is_nested_left_hand_side_safe {
163+
return None;
164+
}
165+
166+
if let AnyJsExpression::JsIdentifierExpression(ident) = &left {
167+
let name = ident.name().ok()?;
168+
169+
// Use the semantic model to resolve the variable binding and check
170+
// if it's initialized with a boolean literal. This allows us to
171+
// handle cases like:
172+
// let isOpen = false; // This is safe
173+
// return <div>{isOpen && <Content />}</div>; // This should pass
174+
if let Some(binding) = model.binding(&name)
175+
&& binding
176+
.tree()
177+
.declaration()
178+
.and_then(|declaration| {
179+
if let AnyJsBindingDeclaration::JsVariableDeclarator(declarator) =
180+
declaration
181+
{
182+
Some(declarator)
183+
} else {
184+
None
185+
}
186+
})
187+
.and_then(|declarator| declarator.initializer())
188+
.and_then(|initializer| initializer.expression().ok())
189+
.and_then(|expr| {
190+
if let AnyJsExpression::AnyJsLiteralExpression(literal) = expr {
191+
Some(literal)
192+
} else {
193+
None
194+
}
195+
})
196+
.and_then(|literal| literal.value_token().ok())
197+
.is_some_and(|token| matches!(token.text_trimmed(), "true" | "false"))
198+
{
199+
return None;
200+
}
201+
}
202+
203+
let is_literal = matches!(left, AnyJsExpression::AnyJsLiteralExpression(_));
204+
if is_literal && left.to_trimmed_text().is_empty() {
205+
return None;
206+
}
207+
208+
Some(true)
209+
}
210+
NoLeakedRenderQuery::JsConditionalExpression(expr) => {
211+
let alternate = expr.alternate().ok()?;
212+
let is_alternate_identifier =
213+
matches!(alternate, AnyJsExpression::JsIdentifierExpression(_));
214+
let is_jsx_element_alt = matches!(alternate, AnyJsExpression::JsxTagExpression(_));
215+
if !is_alternate_identifier || is_jsx_element_alt {
216+
return None;
217+
}
218+
219+
Some(true)
220+
}
221+
}
222+
}
223+
224+
fn diagnostic(ctx: &RuleContext<Self>, _state: &Self::State) -> Option<RuleDiagnostic> {
225+
let node = ctx.query();
226+
227+
match node {
228+
NoLeakedRenderQuery::JsLogicalExpression(_) => {
229+
Some(
230+
RuleDiagnostic::new(
231+
rule_category!(),
232+
node.range(),
233+
markup! {
234+
"Potential leaked value that might cause unintended rendering."
235+
},
236+
)
237+
.note(markup! {
238+
"JavaScript's && operator returns the left value when it's falsy (e.g., 0, NaN, ''). React will render that value, causing unexpected UI output."
239+
})
240+
.note(markup! {
241+
"Make sure the condition is explicitly boolean.Use !!value, value > 0, or a ternary expression."
242+
})
243+
)
244+
}
245+
NoLeakedRenderQuery::JsConditionalExpression(_) => {
246+
Some(
247+
RuleDiagnostic::new(
248+
rule_category!(),
249+
node.range(),
250+
markup! {
251+
"Potential leaked value that might cause unintended rendering."
252+
},
253+
)
254+
.note(markup! {
255+
"This happens when you use ternary operators in JSX with alternate values that could be variables."
256+
})
257+
.note(markup! {
258+
"Replace with a safe alternate value like an empty string , null or another JSX element."
259+
})
260+
)
261+
}
262+
}
263+
}
264+
}
265+
266+
declare_node_union! {
267+
pub NoLeakedRenderQuery = JsLogicalExpression | JsConditionalExpression
268+
}
269+
270+
fn is_inside_jsx_expression(node: &JsSyntaxNode) -> bool {
271+
node.ancestors().any(|ancestor| {
272+
JsxExpressionChild::can_cast(ancestor.kind())
273+
|| JsxExpressionAttributeValue::can_cast(ancestor.kind())
274+
|| JsxTagExpression::can_cast(ancestor.kind())
275+
})
276+
}

0 commit comments

Comments
 (0)