Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
13 changes: 13 additions & 0 deletions .changeset/rude-crabs-mix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"@biomejs/biome": patch
---

Added the nursery rule [`noJsxLeakedDollar`](https://biomejs.dev/linter/rules/no-jsx-leaked-dollar), which flags text nodes with a trailing `$` if the next sibling node is a JSX expression. This could be an unintentional mistake, resulting in a '$' being rendered as text in the output.

**Invalid**:

```jsx
function MyComponent({ user }) {
return <div>Hello ${user.name}</div>;
}
```
24 changes: 24 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.

4 changes: 4 additions & 0 deletions crates/biome_configuration/src/analyzer/linter/rules.rs

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

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

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

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

151 changes: 151 additions & 0 deletions crates/biome_js_analyze/src/lint/nursery/no_jsx_leaked_dollar.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
use crate::JsRuleAction;
use biome_analyze::{
Ast, FixKind, Rule, RuleDiagnostic, RuleDomain, RuleSource, context::RuleContext,
declare_lint_rule,
};
use biome_console::markup;
use biome_diagnostics::Severity;
use biome_js_factory::make;
use biome_js_syntax::{AnyJsxChild, JsSyntaxKind, JsSyntaxToken, JsxChildList, JsxText};
use biome_rowan::{AstNode, AstNodeList, BatchMutationExt, TextRange, TextSize};
use biome_rule_options::no_jsx_leaked_dollar::NoJsxLeakedDollarOptions;

declare_lint_rule! {
/// Flags text nodes with a trailing `$` before a JSX expression.
///
/// This can happen when refactoring from a template literal to JSX and forgetting
/// to remove the dollar sign. This results in an unintentional `$` being rendered
/// as text in the output.
///
/// ```jsx
/// function MyComponent({ user }) {
/// return `Hello ${user.name}`;
/// }
/// ```
///
/// When refactored to JSX, it might look like this:
///
/// ```jsx,ignore
/// function MyComponent({ user }) {
/// return <>Hello ${user.name}</>;
/// }
/// ```
///
/// However, the `$` before `{user.name}` is unnecessary and will be rendered as text in the output.
///
/// ## Examples
///
/// ### Invalid
///
/// ```jsx,expect_diagnostic
/// function MyComponent({ user }) {
/// return <div>Hello ${user.name}</div>;
/// }
/// ```
///
/// ```jsx,expect_diagnostic
/// function MyComponent({ user }) {
/// return <div>${user.name} is your name</div>;
/// }
/// ```
///
/// ### Valid
///
/// ```jsx
/// function MyComponent({ user }) {
/// return <div>Hello {user.name}</div>;
/// }
/// ```
///
/// ```jsx
/// // A lone `$` before a single expression is treated as intentional (e.g. a price).
/// function MyComponent({ price }) {
/// return <div>${price}</div>;
/// }
/// ```
///
pub NoJsxLeakedDollar {
version: "next",
name: "noJsxLeakedDollar",
language: "jsx",
recommended: false,
fix_kind: FixKind::Unsafe,
severity: Severity::Warning,
domains: &[RuleDomain::React],
sources: &[RuleSource::EslintReactJsx("no-leaked-dollar").same(), RuleSource::EslintReactXyz("jsx-no-leaked-dollar").same()],
Comment thread
Netail marked this conversation as resolved.
}
}

impl Rule for NoJsxLeakedDollar {
type Query = Ast<JsxText>;
type State = TextRange;
type Signals = Option<Self::State>;
type Options = NoJsxLeakedDollarOptions;

fn run(ctx: &RuleContext<Self>) -> Self::Signals {
let node = ctx.query();
let value_token = node.value_token().ok()?;
let text = value_token.text();

// Check if the text ends with `$`
if !text.ends_with('$') {
return None;
}

// Check if the next sibling is a JsxExpressionChild
let next_sibling = node.syntax().next_sibling()?;
if next_sibling.kind() != JsSyntaxKind::JSX_EXPRESSION_CHILD {
return None;
}

// Exception: if the text is exactly "$" and the parent has only 2 children,
// it looks like an intentional dollar sign (e.g. `<div>${price}</div>`).
if text == "$"
&& let Some(parent) = node.syntax().parent()
&& let Some(parent) = JsxChildList::cast(parent)
&& parent.len() == 2
{
return None;
}

// Return the range of the trailing `$` character
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Did you mean leading? Trailing means "the one at the end"

Copy link
Copy Markdown
Member Author

@Netail Netail Apr 11, 2026

Choose a reason for hiding this comment

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

No, the rule triggers on a dollar sign at the end of a text node. Trailing in the text node, but leading in front of an expression

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Ok, maybe reword the comment then, that wasn't clear

let end = value_token.text_range().end();
let start = end - TextSize::from(1u32);
Some(TextRange::new(start, end))
}

fn diagnostic(_: &RuleContext<Self>, state: &Self::State) -> Option<RuleDiagnostic> {
Some(
RuleDiagnostic::new(
rule_category!(),
state,
markup! {
"Possible unintentional "<Emphasis>"'$'"</Emphasis>" before a JSX expression."
},
)
.note(markup! {
"This "<Emphasis>"'$'"</Emphasis>" will be rendered as text. Remove the "<Emphasis>"'$'"</Emphasis>" from the text node or add a suppression if it is intentional."
}),
Comment on lines +126 to +128
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

wouldn't moving it inside cause the syntax to be invalid?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Yeah, idk why I left that there. Copied and forgot to modify I am afraid

)
}

fn action(ctx: &RuleContext<Self>, _state: &Self::State) -> Option<JsRuleAction> {
let node = ctx.query();
let value_token = node.value_token().ok()?;
let text = value_token.text();

// Remove the trailing `$`
let new_text = text[..text.len() - 1].to_string();

let new_token = JsSyntaxToken::new_detached(JsSyntaxKind::JSX_TEXT, &new_text, [], []);
let new_jsx_text = AnyJsxChild::JsxText(make::jsx_text(new_token));
let mut mutation = ctx.root().begin();
mutation.replace_node(AnyJsxChild::from(node.clone()), new_jsx_text);
Some(JsRuleAction::new(
ctx.metadata().action_category(ctx.category(), ctx.group()),
ctx.metadata().applicability(),
markup! { "Remove dollar sign." }.to_owned(),
mutation,
))
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/* should generate diagnostics */
const Invalid1 = () => <>Hello ${user.name}</>
const Invalid2 = () => <>Hello $${user.name}</>
const Invalid3 = (props) => {
return <div>Hello ${props.name}</div>;
};

const Invalid4 = (props) => {
return <div>${props.name} is your name</div>;
};

const Invalid5 = (props) => {
return <div>Hello ${props.name} is your name</div>;
};

function Invalid6({ count, total }) {
return <div>Progress: ${count} / ${total}</div>;
}
Loading
Loading