-
-
Notifications
You must be signed in to change notification settings - Fork 864
feat(lint): added new rule no-leaked-render from eslint-react
#8171
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
ematipico
merged 28 commits into
biomejs:main
from
dibashthapa:no-leaked-conditional-rendering
Nov 21, 2025
Merged
Changes from all commits
Commits
Show all changes
28 commits
Select commit
Hold shift + click to select a range
244fc02
chores(lint): fixed merge conflicts
dibashthapa 6d5d7c4
Merge branch 'main' of https://github.com/dibashthapa/biome into no-l…
dibashthapa d1689a6
feat(lint): ported `no-leaked-conditional-rendering` rule from `eslin…
dibashthapa b327267
Merge branch 'main' of https://github.com/dibashthapa/biome into no-l…
dibashthapa 3e3606b
chores: renamed no-leaked-conditional-rendering to no-leaked-render
dibashthapa a244fc4
chores: generated new configs and changeset
dibashthapa b4706d8
fix(noLeakedRender): fixed the issue with diagnostics for some compon…
dibashthapa 1a16a25
[autofix.ci] apply automated fixes
autofix-ci[bot] 35683b8
chores(noLeakedRender): fixed the docstrings
dibashthapa ab839e1
Merge branch 'main' of https://github.com/dibashthapa/biome into no-l…
dibashthapa e32eaad
fix(noLeakedRender): added diagnostics for identifiers in ternary exp…
dibashthapa a965d09
fix(noLeakedRender): replaced with `and_then` chain
dibashthapa 6e87da0
fix(rules): fixed merge conflicts
dibashthapa 1fd1508
fix(noLeakedRender):fixed clippy errors
dibashthapa 38b6290
fix(noLeakedRender): removed validStrategies and added more tests
dibashthapa 3cdb6ae
[autofix.ci] apply automated fixes
autofix-ci[bot] c56b18e
fix(noLeakedRender): fixed changeset and invalid snap tests
dibashthapa b3fde28
fix(noLeakedRender): replaced same with inspired
dibashthapa 6e7dd69
chores: fixed changeset
dibashthapa 75b38bf
fix(noLeakedRender): worked on suggestions
dibashthapa dcc9fbb
fix(noLeakedRender): moved declare_node_union below impl
dibashthapa af0ce0b
chores: added `should generate diagnostics` in invalid.jsx
dibashthapa bab33ee
fix(lint): worked on suggestions
dibashthapa bccc452
Merge branch 'main' of https://github.com/dibashthapa/biome into no-l…
dibashthapa b8b0dd1
fix(lint): fixed merge conflicts
dibashthapa 1185b3b
Update crates/biome_js_analyze/src/lint/nursery/no_leaked_render.rs
ematipico 96d313d
Update crates/biome_js_analyze/src/lint/nursery/no_leaked_render.rs
ematipico ce85fe2
fix wording
ematipico File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
16
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.
Oops, something went wrong.
189 changes: 105 additions & 84 deletions
189
crates/biome_configuration/src/analyzer/linter/rules.rs
Large diffs are not rendered by default.
Oops, something went wrong.
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
276 changes: 276 additions & 0 deletions
276
crates/biome_js_analyze/src/lint/nursery/no_leaked_render.rs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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()?.omit_parentheses(); | ||
| let right = expr.right().ok()?.omit_parentheses(); | ||
| stack.push(left); | ||
| stack.push(right); | ||
| } | ||
| AnyJsExpression::JsParenthesizedExpression(expr) => { | ||
| stack.push(expr.expression().ok()?.omit_parentheses()); | ||
| } | ||
| // 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) | ||
| } | ||
dyc3 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
| } | ||
|
|
||
| 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." | ||
| }) | ||
| ) | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| 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()) | ||
| }) | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Normalise the left‑hand side with
omit_parentheses()to avoid false positivesAt the moment
leftis taken as‑is from the AST, so cases like:will be treated differently from
isOpen && <Content />even though they’re semantically identical. The same applies to parenthesised literals.You can fix this by stripping parentheses once up front:
This will also make the identifier, literal, and
is_literalchecks behave consistently for expressions wrapped in extra brackets.