Skip to content

Commit dea65cd

Browse files
Moktoclaude
andcommitted
feat(js/nursery): add noSvelteNoUnnecessaryStateWrap rule
Adds RuleDomain::Svelte and RuleSource::EslintSvelte infrastructure (from upstream #10503), plus a new JS nursery rule that reports $state() wrapping of classes from svelte/reactivity (SvelteMap, SvelteSet, SvelteDate, etc.) that are already deeply reactive without the rune. Includes an unsafe fix to strip the wrapper, and two options: - additionalReactiveClasses: extend the built-in list with custom classes - allowReassign: permit $state for variables that are reassigned later Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 99bc7df commit dea65cd

31 files changed

Lines changed: 990 additions & 0 deletions
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
"@biomejs/biome": patch
3+
---
4+
5+
Added the new nursery rule [`noSvelteNoUnnecessaryStateWrap`](https://biomejs.dev/linter/rules/no-svelte-unnecessary-state-wrap/), which reports unnecessary `$state()` wrapping of classes from `svelte/reactivity` that are already reactive.
6+
7+
```svelte
8+
<script>
9+
import { SvelteMap } from "svelte/reactivity";
10+
const map = $state(new SvelteMap()); // redundant
11+
</script>
12+
```

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

Lines changed: 12 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: 4 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/generated/domain_selector.rs

Lines changed: 4 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/generated/linter_options_check.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
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
use biome_analyze::{
2+
FixKind, Rule, RuleDiagnostic, RuleDomain, RuleSource, context::RuleContext,
3+
declare_lint_rule,
4+
};
5+
use biome_console::markup;
6+
use biome_js_semantic::ReferencesExtensions;
7+
use biome_js_syntax::{
8+
AnyJsBinding, AnyJsBindingPattern, AnyJsCallArgument, AnyJsExpression, JsCallExpression,
9+
JsFileSource, JsImport, JsVariableDeclarator,
10+
};
11+
use biome_rowan::{AstNode, AstSeparatedList, BatchMutationExt, TokenText};
12+
use biome_rule_options::no_svelte_unnecessary_state_wrap::NoSvelteUnnecessaryStateWrapOptions;
13+
14+
use crate::JsRuleAction;
15+
use crate::services::semantic::Semantic;
16+
17+
/// Classes from `svelte/reactivity` that are already reactive without `$state`.
18+
const REACTIVE_CLASSES: &[&str] = &[
19+
"SvelteSet",
20+
"SvelteMap",
21+
"SvelteURL",
22+
"SvelteURLSearchParams",
23+
"SvelteDate",
24+
"MediaQuery",
25+
];
26+
27+
const SVELTE_REACTIVITY_MODULE: &str = "svelte/reactivity";
28+
29+
declare_lint_rule! {
30+
/// Disallow unnecessary `$state` wrapping of reactive classes.
31+
///
32+
/// Several classes exported from `svelte/reactivity` — such as `SvelteMap`, `SvelteSet`, and
33+
/// `SvelteDate` — are already deeply reactive without the `$state` rune. Wrapping them in
34+
/// `$state(...)` is redundant and may mislead readers into thinking the reactivity comes from
35+
/// the rune rather than the class itself.
36+
///
37+
/// Use the `additionalReactiveClasses` option to extend this list with custom reactive classes
38+
/// from your own codebase.
39+
///
40+
/// Use `allowReassign: true` if you need to reassign the variable itself after declaration,
41+
/// which requires `$state` to track the reference change.
42+
///
43+
/// ## Examples
44+
///
45+
/// ### Invalid
46+
///
47+
/// ```svelte,expect_diagnostic
48+
/// <script>
49+
/// import { SvelteMap } from "svelte/reactivity";
50+
/// const map = $state(new SvelteMap());
51+
/// </script>
52+
/// ```
53+
///
54+
/// ### Valid
55+
///
56+
/// ```svelte
57+
/// <script>
58+
/// import { SvelteMap } from "svelte/reactivity";
59+
/// const map = new SvelteMap();
60+
/// </script>
61+
/// ```
62+
///
63+
pub NoSvelteUnnecessaryStateWrap {
64+
version: "next",
65+
name: "noSvelteUnnecessaryStateWrap",
66+
language: "js",
67+
domains: &[RuleDomain::Svelte],
68+
sources: &[RuleSource::EslintSvelte("no-unnecessary-state-wrap").same()],
69+
recommended: false,
70+
fix_kind: FixKind::Unsafe,
71+
}
72+
}
73+
74+
pub struct RuleState {
75+
/// Name of the reactive class being unnecessarily wrapped.
76+
class_name: TokenText,
77+
/// The inner expression (the `new X()` / `X()` argument) to replace the call with.
78+
inner_expr: AnyJsExpression,
79+
}
80+
81+
impl Rule for NoSvelteUnnecessaryStateWrap {
82+
type Query = Semantic<JsCallExpression>;
83+
type State = RuleState;
84+
type Signals = Option<Self::State>;
85+
type Options = NoSvelteUnnecessaryStateWrapOptions;
86+
87+
fn run(ctx: &RuleContext<Self>) -> Self::Signals {
88+
let call = ctx.query();
89+
let model = ctx.model();
90+
91+
if !ctx
92+
.source_type::<JsFileSource>()
93+
.as_embedding_kind()
94+
.is_svelte()
95+
{
96+
return None;
97+
}
98+
99+
// Callee must be the `$state` rune.
100+
let AnyJsExpression::JsIdentifierExpression(callee_ident) = call.callee().ok()? else {
101+
return None;
102+
};
103+
if callee_ident
104+
.name()
105+
.ok()?
106+
.value_token()
107+
.ok()?
108+
.text_trimmed()
109+
!= "$state"
110+
{
111+
return None;
112+
}
113+
114+
// Must be called with exactly one argument.
115+
let args = call.arguments().ok()?.args();
116+
if args.len() != 1 {
117+
return None;
118+
}
119+
let AnyJsCallArgument::AnyJsExpression(arg_expr) = args.first()?.ok()? else {
120+
return None;
121+
};
122+
123+
// Argument must be `new X(...)` or `X(...)`.
124+
let class_callee = match &arg_expr {
125+
AnyJsExpression::JsNewExpression(new_expr) => new_expr.callee().ok()?,
126+
AnyJsExpression::JsCallExpression(inner_call) => inner_call.callee().ok()?,
127+
_ => return None,
128+
};
129+
let AnyJsExpression::JsIdentifierExpression(class_ident) = class_callee else {
130+
return None;
131+
};
132+
let class_ref = class_ident.name().ok()?;
133+
let class_name_text = class_ref.value_token().ok()?.token_text_trimmed();
134+
135+
let options = ctx.options();
136+
137+
let is_builtin = REACTIVE_CLASSES.contains(&class_name_text.text());
138+
let is_additional = options
139+
.additional_reactive_classes
140+
.as_deref()
141+
.unwrap_or(&[])
142+
.iter()
143+
.any(|c| c.as_ref() == class_name_text.text());
144+
145+
if !is_builtin && !is_additional {
146+
return None;
147+
}
148+
149+
// For built-in classes, verify the class is imported from `svelte/reactivity`.
150+
if is_builtin {
151+
let binding = model.binding(&class_ref)?;
152+
let imported_from = binding
153+
.syntax()
154+
.ancestors()
155+
.find_map(JsImport::cast)
156+
.and_then(|import| import.source_text().ok());
157+
match imported_from {
158+
Some(source) if source.text() == SVELTE_REACTIVITY_MODULE => {}
159+
_ => return None,
160+
}
161+
}
162+
163+
// If `allowReassign` is enabled, skip variables that are reassigned after declaration.
164+
if options.allow_reassign.unwrap_or(false) {
165+
if let Some(declarator) =
166+
call.syntax().ancestors().find_map(JsVariableDeclarator::cast)
167+
{
168+
if let Ok(AnyJsBindingPattern::AnyJsBinding(AnyJsBinding::JsIdentifierBinding(
169+
id_binding,
170+
))) = declarator.id()
171+
{
172+
if id_binding.all_writes(model).next().is_some() {
173+
return None;
174+
}
175+
}
176+
}
177+
}
178+
179+
Some(RuleState {
180+
class_name: class_name_text,
181+
inner_expr: arg_expr,
182+
})
183+
}
184+
185+
fn diagnostic(ctx: &RuleContext<Self>, state: &Self::State) -> Option<RuleDiagnostic> {
186+
let call = ctx.query();
187+
Some(
188+
RuleDiagnostic::new(
189+
rule_category!(),
190+
call.range(),
191+
markup! {
192+
<Emphasis>{state.class_name.text()}</Emphasis>" is already reactive, wrapping it in "<Emphasis>"$state()"</Emphasis>" is unnecessary."
193+
},
194+
)
195+
.note(markup! {
196+
"Classes from "<Emphasis>"svelte/reactivity"</Emphasis>" track their own mutations without needing a "<Emphasis>"$state"</Emphasis>" wrapper."
197+
})
198+
.note(markup! {
199+
"Remove the "<Emphasis>"$state()"</Emphasis>" wrapper. If you need to reassign the variable itself, enable the "<Emphasis>"allowReassign"</Emphasis>" option."
200+
}),
201+
)
202+
}
203+
204+
fn action(ctx: &RuleContext<Self>, state: &Self::State) -> Option<JsRuleAction> {
205+
let call = ctx.query();
206+
let mut mutation = ctx.root().begin();
207+
mutation.replace_node(
208+
AnyJsExpression::JsCallExpression(call.clone()),
209+
state.inner_expr.clone(),
210+
);
211+
Some(JsRuleAction::new(
212+
ctx.metadata().action_category(ctx.category(), ctx.group()),
213+
ctx.metadata().applicability(),
214+
markup! { "Remove the unnecessary "<Emphasis>"$state()"</Emphasis>" wrapper." }
215+
.to_owned(),
216+
mutation,
217+
))
218+
}
219+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<!-- should generate diagnostics -->
2+
<script>
3+
import { SvelteMap, SvelteSet, SvelteDate, SvelteURL, SvelteURLSearchParams } from "svelte/reactivity";
4+
import { MediaQuery } from "svelte/reactivity";
5+
6+
// All built-in reactive classes wrapped unnecessarily.
7+
const map = $state(new SvelteMap());
8+
const set = $state(new SvelteSet());
9+
const date = $state(new SvelteDate());
10+
const url = $state(new SvelteURL("https://example.com"));
11+
const params = $state(new SvelteURLSearchParams());
12+
const mq = $state(new MediaQuery("(min-width: 768px)"));
13+
</script>

0 commit comments

Comments
 (0)