Skip to content
Merged
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
244fc02
chores(lint): fixed merge conflicts
dibashthapa Nov 4, 2025
6d5d7c4
Merge branch 'main' of https://github.com/dibashthapa/biome into no-l…
dibashthapa Nov 10, 2025
d1689a6
feat(lint): ported `no-leaked-conditional-rendering` rule from `eslin…
dibashthapa Nov 18, 2025
b327267
Merge branch 'main' of https://github.com/dibashthapa/biome into no-l…
dibashthapa Nov 18, 2025
3e3606b
chores: renamed no-leaked-conditional-rendering to no-leaked-render
dibashthapa Nov 19, 2025
a244fc4
chores: generated new configs and changeset
dibashthapa Nov 19, 2025
b4706d8
fix(noLeakedRender): fixed the issue with diagnostics for some compon…
dibashthapa Nov 19, 2025
1a16a25
[autofix.ci] apply automated fixes
autofix-ci[bot] Nov 19, 2025
35683b8
chores(noLeakedRender): fixed the docstrings
dibashthapa Nov 19, 2025
ab839e1
Merge branch 'main' of https://github.com/dibashthapa/biome into no-l…
dibashthapa Nov 20, 2025
e32eaad
fix(noLeakedRender): added diagnostics for identifiers in ternary exp…
dibashthapa Nov 20, 2025
a965d09
fix(noLeakedRender): replaced with `and_then` chain
dibashthapa Nov 20, 2025
6e87da0
fix(rules): fixed merge conflicts
dibashthapa Nov 20, 2025
1fd1508
fix(noLeakedRender):fixed clippy errors
dibashthapa Nov 20, 2025
38b6290
fix(noLeakedRender): removed validStrategies and added more tests
dibashthapa Nov 20, 2025
3cdb6ae
[autofix.ci] apply automated fixes
autofix-ci[bot] Nov 20, 2025
c56b18e
fix(noLeakedRender): fixed changeset and invalid snap tests
dibashthapa Nov 20, 2025
b3fde28
fix(noLeakedRender): replaced same with inspired
dibashthapa Nov 20, 2025
6e7dd69
chores: fixed changeset
dibashthapa Nov 21, 2025
75b38bf
fix(noLeakedRender): worked on suggestions
dibashthapa Nov 21, 2025
dcc9fbb
fix(noLeakedRender): moved declare_node_union below impl
dibashthapa Nov 21, 2025
af0ce0b
chores: added `should generate diagnostics` in invalid.jsx
dibashthapa Nov 21, 2025
bab33ee
fix(lint): worked on suggestions
dibashthapa Nov 21, 2025
bccc452
Merge branch 'main' of https://github.com/dibashthapa/biome into no-l…
dibashthapa Nov 21, 2025
b8b0dd1
fix(lint): fixed merge conflicts
dibashthapa Nov 21, 2025
1185b3b
Update crates/biome_js_analyze/src/lint/nursery/no_leaked_render.rs
ematipico Nov 21, 2025
96d313d
Update crates/biome_js_analyze/src/lint/nursery/no_leaked_render.rs
ematipico Nov 21, 2025
ce85fe2
fix wording
ematipico Nov 21, 2025
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
14 changes: 14 additions & 0 deletions .changeset/calm-shrimps-study.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@biomejs/biome': patch
---

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.

For example, the following code triggers the rule because the component would render `0`:

```jsx
const Component = () => {
const count = 0;
return <div>{count && <span>Count: {count}</span>}</div>;
}
```
16 changes: 16 additions & 0 deletions crates/biome_cli/src/execute/migrate/eslint_any_rule_to_biome.rs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

185 changes: 103 additions & 82 deletions crates/biome_configuration/src/analyzer/linter/rules.rs

Large diffs are not rendered by default.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/biome_diagnostics_categories/src/categories.rs
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,7 @@ define_categories! {
"lint/nursery/noImportCycles": "https://biomejs.dev/linter/rules/no-import-cycles",
"lint/nursery/noIncrementDecrement": "https://biomejs.dev/linter/rules/no-increment-decrement",
"lint/nursery/noJsxLiterals": "https://biomejs.dev/linter/rules/no-jsx-literals",
"lint/nursery/noLeakedRender": "https://biomejs.dev/linter/rules/no-leaked-render",
"lint/nursery/noMissingGenericFamilyKeyword": "https://biomejs.dev/linter/rules/no-missing-generic-family-keyword",
"lint/nursery/noMisusedPromises": "https://biomejs.dev/linter/rules/no-misused-promises",
"lint/nursery/noNextAsyncClientComponent": "https://biomejs.dev/linter/rules/no-next-async-client-component",
Expand Down
3 changes: 2 additions & 1 deletion crates/biome_js_analyze/src/lint/nursery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ pub mod no_for_in;
pub mod no_import_cycles;
pub mod no_increment_decrement;
pub mod no_jsx_literals;
pub mod no_leaked_render;
pub mod no_misused_promises;
pub mod no_next_async_client_component;
pub mod no_parameters_only_used_in_recursion;
Expand Down Expand Up @@ -39,4 +40,4 @@ pub mod use_sorted_classes;
pub mod use_spread;
pub mod use_vue_define_macros_order;
pub mod use_vue_multi_word_component_names;
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_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 ,] } }
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_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 ,] } }
276 changes: 276 additions & 0 deletions crates/biome_js_analyze/src/lint/nursery/no_leaked_render.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,276 @@
use biome_analyze::{
Rule, RuleDiagnostic, RuleDomain, RuleSource, context::RuleContext, declare_lint_rule,
};
use biome_console::markup;
use biome_js_syntax::{
AnyJsExpression, JsConditionalExpression, JsLogicalExpression, JsLogicalOperator, JsSyntaxNode,
JsxExpressionAttributeValue, JsxExpressionChild, JsxTagExpression,
binding_ext::AnyJsBindingDeclaration,
};
use biome_rowan::{AstNode, declare_node_union};
use biome_rule_options::no_leaked_render::NoLeakedRenderOptions;

use crate::services::semantic::Semantic;

declare_lint_rule! {
/// Prevent problematic leaked values from being rendered.
///
/// This rule prevents values that might cause unintentionally rendered values
/// or rendering crashes in React JSX. When using conditional rendering with the
/// logical AND operator (`&&`), if the left-hand side evaluates to a falsy value like
/// `0`, `NaN`, or any empty string, these values will be rendered instead of rendering nothing.
///
///
/// ## Examples
///
/// ### Invalid
///
/// ```jsx,expect_diagnostic
/// const Component = () => {
/// const count = 0;
/// return <div>{count && <span>Count: {count}</span>}</div>;
/// }
/// ```
///
/// ```jsx,expect_diagnostic
/// const Component = () => {
/// const items = [];
/// return <div>{items.length && <List items={items} />}</div>;
/// }
/// ```
///
/// ```jsx,expect_diagnostic
/// const Component = () => {
/// const user = null;
/// return <div>{user && <Profile user={user} />}</div>;
/// }
/// ```
///
///
/// ### Valid
///
/// ```jsx
/// const Component = () => {
/// const count = 0;
/// return <div>{count > 0 && <span>Count: {count}</span>}</div>;
/// }
/// ```
///
/// ```jsx
/// const Component = () => {
/// const items = [];
/// return <div>{!!items.length && <List items={items} />}</div>;
/// }
/// ```
///
/// ```jsx
/// const Component = () => {
/// const user = null;
/// return <div>{user ? <Profile user={user} /> : null}</div>;
/// }
/// ```
///
/// ```jsx
/// const Component = () => {
/// const condition = false;
/// return <div>{condition ? <Content /> : <Fallback />}</div>;
/// }
/// ```
///
/// ```jsx
/// const Component = () => {
/// const isReady = true;
/// return <div>{isReady && <Content />}</div>;
/// }
/// ```
pub NoLeakedRender{
version: "next",
name: "noLeakedRender",
language: "jsx",
domains: &[RuleDomain::React],
sources: &[
RuleSource::EslintReact("no-leaked-render").inspired(),
],
recommended: false,
}
}

impl Rule for NoLeakedRender {
type Query = Semantic<NoLeakedRenderQuery>;
type State = bool;
type Signals = Option<Self::State>;
type Options = NoLeakedRenderOptions;

fn run(ctx: &RuleContext<Self>) -> Self::Signals {
let query = ctx.query();
let model = ctx.model();

if !is_inside_jsx_expression(query.syntax()) {
return None;
}

match query {
NoLeakedRenderQuery::JsLogicalExpression(exp) => {
let op = exp.operator().ok()?;

if op != JsLogicalOperator::LogicalAnd {
return None;
}
let left = exp.left().ok()?;

let is_left_hand_side_safe = matches!(
left,
AnyJsExpression::JsUnaryExpression(_)
| AnyJsExpression::JsCallExpression(_)
| AnyJsExpression::JsBinaryExpression(_)
);

if is_left_hand_side_safe {
return None;
}

let mut is_nested_left_hand_side_safe = false;

let mut stack = vec![left.clone()];

// Traverse the expression tree iteratively using a stack
// This allows us to check nested expressions without recursion
while let Some(current) = stack.pop() {
match current {
AnyJsExpression::JsLogicalExpression(expr) => {
let left = expr.left().ok()?;
let right = expr.right().ok()?;
stack.push(left);
stack.push(right);
}
AnyJsExpression::JsParenthesizedExpression(expr) => {
stack.push(expr.expression().ok()?);
}
Comment on lines 141 to 149
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One last thing. If left, right and expression() are AnyJsExpression, I suggest you go call omit_parentheses(), so we remove the parentheses.

In fact, we have a few tests with binary expressions that contain parentheses. Can we add more of them? Let's get creative and add absurd cases, e.g.:

const Component1 = ({ count, title }) => {
	return <div>{(((((count))))) && ((title))}</div>;
};

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried adding the code you suggested, but parentheses were automatically omitted by the formatter, haha

// If we find expressions that coerce to boolean (unary, call, binary),
// then the entire expression is considered safe
AnyJsExpression::JsUnaryExpression(_)
| AnyJsExpression::JsCallExpression(_)
| AnyJsExpression::JsBinaryExpression(_) => {
is_nested_left_hand_side_safe = true;
break;
}
_ => {}
}
}

if is_nested_left_hand_side_safe {
return None;
}

if let AnyJsExpression::JsIdentifierExpression(ident) = &left {
let name = ident.name().ok()?;

// Use the semantic model to resolve the variable binding and check
// if it's initialized with a boolean literal. This allows us to
// handle cases like:
// let isOpen = false; // This is safe
// return <div>{isOpen && <Content />}</div>; // This should pass
if let Some(binding) = model.binding(&name)
&& binding
.tree()
.declaration()
.and_then(|declaration| {
if let AnyJsBindingDeclaration::JsVariableDeclarator(declarator) =
declaration
{
Some(declarator)
} else {
None
}
})
.and_then(|declarator| declarator.initializer())
.and_then(|initializer| initializer.expression().ok())
.and_then(|expr| {
if let AnyJsExpression::AnyJsLiteralExpression(literal) = expr {
Some(literal)
} else {
None
}
})
.and_then(|literal| literal.value_token().ok())
.is_some_and(|token| matches!(token.text_trimmed(), "true" | "false"))
{
return None;
}
}

let is_literal = matches!(left, AnyJsExpression::AnyJsLiteralExpression(_));
if is_literal && left.to_trimmed_text().is_empty() {
return None;
}

Some(true)
}
NoLeakedRenderQuery::JsConditionalExpression(expr) => {
let alternate = expr.alternate().ok()?;
let is_alternate_identifier =
matches!(alternate, AnyJsExpression::JsIdentifierExpression(_));
let is_jsx_element_alt = matches!(alternate, AnyJsExpression::JsxTagExpression(_));
if !is_alternate_identifier || is_jsx_element_alt {
return None;
}

Some(true)
}
}
}

fn diagnostic(ctx: &RuleContext<Self>, _state: &Self::State) -> Option<RuleDiagnostic> {
let node = ctx.query();

match node {
NoLeakedRenderQuery::JsLogicalExpression(_) => {
Some(
RuleDiagnostic::new(
rule_category!(),
node.range(),
markup! {
"Potential leaked value that might cause unintended rendering."
},
)
.note(markup! {
"JavaScript's && operator returns the left value when it's falsy (e.g., 0, NaN, ''). React will render that value, causing unexpected UI output."
})
.note(markup! {
"Make sure the condition is explicitly boolean.Use !!value, value > 0, or a ternary expression."
})
)
}
NoLeakedRenderQuery::JsConditionalExpression(_) => {
Some(
RuleDiagnostic::new(
rule_category!(),
node.range(),
markup! {
"Potential leaked value that might cause unintended rendering."
},
)
.note(markup! {
"This happens when you use ternary operators in JSX with alternate values that could be variables"
})
.note(markup! {
"Replace with a safe alternate value like an empty string , null or another JSX element"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Fix spacing and punctuation in diagnostic note.

Line 262 has two minor issues:

  • Extra space before comma: "string , null" should be "string, null"
  • Missing terminal punctuation at the end

Apply this diff:

-                        "Replace with a safe alternate value like an empty string , null or another JSX element"
+                        "Replace with a safe alternate value like an empty string, null, or another JSX element."
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
"Replace with a safe alternate value like an empty string , null or another JSX element"
"Replace with a safe alternate value like an empty string, null, or another JSX element."
🤖 Prompt for AI Agents
In crates/biome_js_analyze/src/lint/nursery/no_leaked_render.rs around line 262,
fix the diagnostic note string by removing the extra space before the comma and
adding terminal punctuation: change `"Replace with a safe alternate value like
an empty string , null or another JSX element"` to `"Replace with a safe
alternate value like an empty string, null, or another JSX element."` so spacing
and punctuation are correct.

})
)
}
}
}
}

declare_node_union! {
pub NoLeakedRenderQuery = JsLogicalExpression | JsConditionalExpression
}

fn is_inside_jsx_expression(node: &JsSyntaxNode) -> bool {
node.ancestors().any(|ancestor| {
JsxExpressionChild::can_cast(ancestor.kind())
|| JsxExpressionAttributeValue::can_cast(ancestor.kind())
|| JsxTagExpression::can_cast(ancestor.kind())
})
}
Loading