Skip to content

Commit a212358

Browse files
committed
feat(lint): implement vue/noSetupPropsReactivityLoss
1 parent c9f00eb commit a212358

27 files changed

+1090
-42
lines changed

.changeset/yellow-parts-appear.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
"@biomejs/biome": minor
3+
---
4+
5+
Added the `vue/noSetupPropsReactivityLoss` rule to the nursery.
6+
7+
This new rule disallows usages that cause the reactivity of `props` passed to the `setup` function to be lost.
8+
9+
Invalid code example:
10+
11+
```jsx
12+
export default {
13+
setup({ count }) {
14+
// `count` is no longer reactive here.
15+
return () => h("div", count);
16+
},
17+
};
18+
```

crates/biome_configuration/src/analyzer/linter/rules.rs

Lines changed: 60 additions & 39 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: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,7 @@ define_categories! {
162162
"lint/correctness/useValidTypeof": "https://biomejs.dev/linter/rules/use-valid-typeof",
163163
"lint/correctness/useYield": "https://biomejs.dev/linter/rules/use-yield",
164164
"lint/nursery/noColorInvalidHex": "https://biomejs.dev/linter/rules/no-color-invalid-hex",
165+
"lint/nursery/noDuplicateDependencies": "https://biomejs.dev/linter/rules/no-duplicate-dependencies",
165166
"lint/nursery/noFloatingPromises": "https://biomejs.dev/linter/rules/no-floating-promises",
166167
"lint/nursery/noImplicitCoercion": "https://biomejs.dev/linter/rules/no-implicit-coercion",
167168
"lint/nursery/noImportCycles": "https://biomejs.dev/linter/rules/no-import-cycles",
@@ -172,6 +173,7 @@ define_categories! {
172173
"lint/nursery/noNonNullAssertedOptionalChain": "https://biomejs.dev/linter/rules/no-non-null-asserted-optional-chain",
173174
"lint/nursery/noQwikUseVisibleTask": "https://biomejs.dev/linter/rules/no-qwik-use-visible-task",
174175
"lint/nursery/noSecrets": "https://biomejs.dev/linter/rules/no-secrets",
176+
"lint/nursery/noSetupPropsReactivityLoss": "https://biomejs.dev/linter/rules/no-setup-props-reactivity-loss",
175177
"lint/nursery/noShadow": "https://biomejs.dev/linter/rules/no-shadow",
176178
"lint/nursery/noUnnecessaryConditions": "https://biomejs.dev/linter/rules/no-unnecessary-conditions",
177179
"lint/nursery/noUnresolvedImports": "https://biomejs.dev/linter/rules/no-unresolved-imports",
@@ -312,7 +314,6 @@ define_categories! {
312314
"lint/suspicious/noDuplicateCase": "https://biomejs.dev/linter/rules/no-duplicate-case",
313315
"lint/suspicious/noDuplicateClassMembers": "https://biomejs.dev/linter/rules/no-duplicate-class-members",
314316
"lint/suspicious/noDuplicateCustomProperties": "https://biomejs.dev/linter/rules/no-duplicate-custom-properties",
315-
"lint/nursery/noDuplicateDependencies": "https://biomejs.dev/linter/rules/no-duplicate-dependencies",
316317
"lint/suspicious/noDuplicateElseIf": "https://biomejs.dev/linter/rules/no-duplicate-else-if",
317318
"lint/suspicious/noDuplicateFields": "https://biomejs.dev/linter/rules/no-duplicate-fields",
318319
"lint/suspicious/noDuplicateFontNames": "https://biomejs.dev/linter/rules/no-font-family-duplicate-names",

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_next_async_client_component;
1111
pub mod no_non_null_asserted_optional_chain;
1212
pub mod no_qwik_use_visible_task;
1313
pub mod no_secrets;
14+
pub mod no_setup_props_reactivity_loss;
1415
pub mod no_shadow;
1516
pub mod no_unnecessary_conditions;
1617
pub mod no_unresolved_imports;
@@ -30,4 +31,4 @@ pub mod use_qwik_classlist;
3031
pub mod use_react_function_components;
3132
pub mod use_sorted_classes;
3233
pub mod use_vue_multi_word_component_names;
33-
declare_lint_group! { pub Nursery { name : "nursery" , rules : [self :: no_floating_promises :: NoFloatingPromises , self :: no_import_cycles :: NoImportCycles , self :: no_jsx_literals :: NoJsxLiterals , self :: no_misused_promises :: NoMisusedPromises , self :: no_next_async_client_component :: NoNextAsyncClientComponent , self :: no_non_null_asserted_optional_chain :: NoNonNullAssertedOptionalChain , self :: no_qwik_use_visible_task :: NoQwikUseVisibleTask , self :: no_secrets :: NoSecrets , self :: no_shadow :: NoShadow , self :: no_unnecessary_conditions :: NoUnnecessaryConditions , self :: no_unresolved_imports :: NoUnresolvedImports , self :: no_useless_catch_binding :: NoUselessCatchBinding , self :: no_useless_undefined :: NoUselessUndefined , self :: no_vue_data_object_declaration :: NoVueDataObjectDeclaration , self :: no_vue_reserved_keys :: NoVueReservedKeys , self :: no_vue_reserved_props :: NoVueReservedProps , self :: use_anchor_href :: UseAnchorHref , self :: use_consistent_arrow_return :: UseConsistentArrowReturn , self :: use_consistent_type_definitions :: UseConsistentTypeDefinitions , self :: use_exhaustive_switch_cases :: UseExhaustiveSwitchCases , self :: use_explicit_type :: UseExplicitType , self :: use_image_size :: UseImageSize , self :: use_max_params :: UseMaxParams , self :: use_qwik_classlist :: UseQwikClasslist , self :: use_react_function_components :: UseReactFunctionComponents , self :: use_sorted_classes :: UseSortedClasses , self :: use_vue_multi_word_component_names :: UseVueMultiWordComponentNames ,] } }
34+
declare_lint_group! { pub Nursery { name : "nursery" , rules : [self :: no_floating_promises :: NoFloatingPromises , self :: no_import_cycles :: NoImportCycles , self :: no_jsx_literals :: NoJsxLiterals , self :: no_misused_promises :: NoMisusedPromises , self :: no_next_async_client_component :: NoNextAsyncClientComponent , self :: no_non_null_asserted_optional_chain :: NoNonNullAssertedOptionalChain , self :: no_qwik_use_visible_task :: NoQwikUseVisibleTask , self :: no_secrets :: NoSecrets , self :: no_setup_props_reactivity_loss :: NoSetupPropsReactivityLoss , self :: no_shadow :: NoShadow , self :: no_unnecessary_conditions :: NoUnnecessaryConditions , self :: no_unresolved_imports :: NoUnresolvedImports , self :: no_useless_catch_binding :: NoUselessCatchBinding , self :: no_useless_undefined :: NoUselessUndefined , self :: no_vue_data_object_declaration :: NoVueDataObjectDeclaration , self :: no_vue_reserved_keys :: NoVueReservedKeys , self :: no_vue_reserved_props :: NoVueReservedProps , self :: use_anchor_href :: UseAnchorHref , self :: use_consistent_arrow_return :: UseConsistentArrowReturn , self :: use_consistent_type_definitions :: UseConsistentTypeDefinitions , self :: use_exhaustive_switch_cases :: UseExhaustiveSwitchCases , self :: use_explicit_type :: UseExplicitType , self :: use_image_size :: UseImageSize , self :: use_max_params :: UseMaxParams , self :: use_qwik_classlist :: UseQwikClasslist , self :: use_react_function_components :: UseReactFunctionComponents , self :: use_sorted_classes :: UseSortedClasses , 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 crate::services::semantic::Semantic;
2+
use biome_analyze::{Rule, RuleDiagnostic, context::RuleContext, declare_lint_rule};
3+
use biome_console::markup;
4+
use biome_js_syntax::{
5+
AnyJsExpression, AnyJsStatement, JsExportDefaultExpressionClause, JsMethodObjectMember,
6+
JsObjectExpression, JsVariableDeclaration,
7+
};
8+
use biome_rowan::{AstNode, AstNodeList, AstSeparatedList, TextRange};
9+
use biome_rule_options::no_setup_props_reactivity_loss::NoSetupPropsReactivityLossOptions;
10+
11+
declare_lint_rule! {
12+
/// Disallow usages that lose the reactivity of `props` passed to `setup`.
13+
///
14+
/// Vue's Composition API requires that props passed to the `setup` function
15+
/// maintain reactivity. Destructuring props or using member expressions on props
16+
/// in the root scope of `setup` will cause the values to lose reactivity.
17+
///
18+
/// This rule reports:
19+
/// - Direct destructuring of props in setup function parameters
20+
/// - Destructuring assignment of props in the root scope of setup
21+
/// - Member access patterns that could break reactivity
22+
///
23+
/// Note: Destructuring is allowed inside nested functions, callbacks, and
24+
/// returned render functions where the reactive context is preserved.
25+
///
26+
/// ## Examples
27+
///
28+
/// ### Invalid
29+
///
30+
/// ```js,expect_diagnostic
31+
/// // Destructuring in setup parameters
32+
/// export default {
33+
/// setup({ count }) {
34+
/// // count is no longer reactive
35+
/// return () => h('div', count)
36+
/// }
37+
/// }
38+
/// ```
39+
///
40+
/// ```js,expect_diagnostic
41+
/// // Destructuring in setup root scope
42+
/// export default {
43+
/// setup(props) {
44+
/// const { count } = props
45+
/// // count is no longer reactive
46+
/// return () => h('div', count)
47+
/// }
48+
/// }
49+
/// ```
50+
///
51+
/// ### Valid
52+
///
53+
/// ```js
54+
/// // Keep props reactive
55+
/// export default {
56+
/// setup(props) {
57+
/// watch(() => props.count, () => {
58+
/// console.log(props.count)
59+
/// })
60+
/// return () => h('div', props.count)
61+
/// }
62+
/// }
63+
/// ```
64+
///
65+
/// ```js
66+
/// // Destructuring inside callbacks is OK
67+
/// export default {
68+
/// setup(props) {
69+
/// watch(() => props.count, () => {
70+
/// const { count } = props // OK inside callback
71+
/// console.log(count)
72+
/// })
73+
/// return () => {
74+
/// const { count } = props // OK inside render function
75+
/// return h('div', count)
76+
/// }
77+
/// }
78+
/// }
79+
/// ```
80+
///
81+
pub NoSetupPropsReactivityLoss {
82+
version: "next",
83+
name: "noSetupPropsReactivityLoss",
84+
language: "js",
85+
recommended: false,
86+
}
87+
}
88+
89+
pub enum RuleState {
90+
DestructuredParameter(TextRange),
91+
DestructuredAssignment(TextRange),
92+
}
93+
94+
impl Rule for NoSetupPropsReactivityLoss {
95+
type Query = Semantic<JsExportDefaultExpressionClause>;
96+
type State = RuleState;
97+
type Signals = Vec<Self::State>;
98+
type Options = NoSetupPropsReactivityLossOptions;
99+
100+
fn run(ctx: &RuleContext<Self>) -> Self::Signals {
101+
let export_clause = ctx.query();
102+
let mut signals = Vec::new();
103+
104+
if let Ok(expression) = export_clause.expression()
105+
&& let Some(obj_expr) = expression.as_js_object_expression()
106+
&& let Some(setup_method) = find_setup_method(obj_expr)
107+
{
108+
check_setup_method(&setup_method, &mut signals);
109+
}
110+
111+
signals
112+
}
113+
114+
fn diagnostic(_ctx: &RuleContext<Self>, state: &Self::State) -> Option<RuleDiagnostic> {
115+
match state {
116+
RuleState::DestructuredParameter(range) => Some(
117+
RuleDiagnostic::new(
118+
rule_category!(),
119+
*range,
120+
markup! {
121+
"Destructuring props in setup function parameters causes reactivity loss."
122+
},
123+
)
124+
.note(markup! {
125+
"Use the props object directly to maintain reactivity: `props.propertyName`"
126+
}),
127+
),
128+
RuleState::DestructuredAssignment(range) => Some(
129+
RuleDiagnostic::new(
130+
rule_category!(),
131+
*range,
132+
markup! {
133+
"Destructuring props in the root scope of setup causes reactivity loss."
134+
},
135+
)
136+
.note(markup! {
137+
"Use the props object directly or destructure inside callbacks/render functions."
138+
}),
139+
),
140+
}
141+
}
142+
}
143+
144+
fn find_setup_method(obj_expr: &JsObjectExpression) -> Option<JsMethodObjectMember> {
145+
obj_expr.members().into_iter().flatten().find_map(|member| {
146+
let method = member.as_js_method_object_member()?;
147+
let name = method.name().ok()?;
148+
let literal_name = name.as_js_literal_member_name()?;
149+
let name_token = literal_name.name().ok()?;
150+
(name_token.text() == "setup").then(|| method.clone())
151+
})
152+
}
153+
154+
fn check_setup_method(setup_method: &JsMethodObjectMember, signals: &mut Vec<RuleState>) {
155+
let params = setup_method.parameters();
156+
157+
if let Ok(params_list) = &params
158+
&& let Some(Ok(param)) = params_list.items().first()
159+
&& let Some(formal_param) = param
160+
.as_any_js_formal_parameter()
161+
.and_then(|fp| fp.as_js_formal_parameter())
162+
&& let Ok(binding) = formal_param.binding()
163+
&& (binding.as_js_object_binding_pattern().is_some()
164+
|| binding.as_js_array_binding_pattern().is_some())
165+
{
166+
signals.push(RuleState::DestructuredParameter(binding.range()));
167+
}
168+
169+
if let Ok(body) = setup_method.body() {
170+
check_setup_body_statements(body.statements().iter(), signals, &params.ok());
171+
}
172+
}
173+
174+
fn check_setup_body_statements(
175+
statements: impl Iterator<Item = AnyJsStatement>,
176+
signals: &mut Vec<RuleState>,
177+
params: &Option<biome_js_syntax::JsParameters>,
178+
) {
179+
let props_param_name = params
180+
.as_ref()
181+
.and_then(|p| p.items().first()?.ok())
182+
.and_then(|p| {
183+
p.as_any_js_formal_parameter()?
184+
.as_js_formal_parameter()?
185+
.binding()
186+
.ok()
187+
})
188+
.and_then(|p| {
189+
p.as_any_js_binding()?
190+
.as_js_identifier_binding()?
191+
.name_token()
192+
.ok()
193+
})
194+
.map(|n| n.text().to_string());
195+
196+
statements
197+
.filter_map(|stmt| match stmt {
198+
AnyJsStatement::JsVariableStatement(var_stmt) => var_stmt.declaration().ok(),
199+
_ => None,
200+
})
201+
.for_each(|declaration| {
202+
check_variable_declaration_for_props_destructuring(
203+
&declaration,
204+
signals,
205+
&props_param_name,
206+
)
207+
});
208+
}
209+
210+
fn check_variable_declaration_for_props_destructuring(
211+
declaration: &JsVariableDeclaration,
212+
signals: &mut Vec<RuleState>,
213+
props_param_name: &Option<String>,
214+
) {
215+
let Some(props_name) = props_param_name else {
216+
return;
217+
};
218+
219+
for declarator in declaration.declarators().into_iter().flatten() {
220+
let has_destructuring_pattern = declarator.id().ok().is_some_and(|id| {
221+
id.as_js_object_binding_pattern().is_some()
222+
|| id.as_js_array_binding_pattern().is_some()
223+
});
224+
225+
if !has_destructuring_pattern {
226+
continue;
227+
}
228+
229+
let Some(init_expr) = declarator
230+
.initializer()
231+
.and_then(|init| init.expression().ok())
232+
else {
233+
continue;
234+
};
235+
236+
let should_report = match init_expr {
237+
AnyJsExpression::JsIdentifierExpression(id_expr) => id_expr
238+
.name()
239+
.ok()
240+
.and_then(|name| name.value_token().ok())
241+
.is_some_and(|token| token.text_trimmed() == props_name),
242+
AnyJsExpression::JsCallExpression(call_expr) => {
243+
let is_reactive_helper = call_expr
244+
.callee()
245+
.ok()
246+
.and_then(|callee| callee.as_js_identifier_expression().cloned())
247+
.and_then(|id_expr| id_expr.name().ok())
248+
.and_then(|name| name.value_token().ok())
249+
.is_some_and(|token| {
250+
let text = token.text_trimmed();
251+
matches!(text, "toRefs" | "toRef" | "reactive" | "ref")
252+
});
253+
254+
!is_reactive_helper
255+
&& call_expr.arguments().ok().is_some_and(|args| {
256+
args.args().into_iter().flatten().any(|arg| {
257+
matches!(
258+
arg.as_any_js_expression(),
259+
Some(AnyJsExpression::JsIdentifierExpression(id_expr))
260+
if id_expr
261+
.name()
262+
.ok()
263+
.and_then(|name| name.value_token().ok())
264+
.is_some_and(|token| token.text_trimmed() == props_name)
265+
)
266+
})
267+
})
268+
}
269+
_ => false,
270+
};
271+
272+
if should_report {
273+
signals.push(RuleState::DestructuredAssignment(declarator.range()));
274+
}
275+
}
276+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
// Array destructuring in parameters
2+
export default {
3+
setup([first, second]) {
4+
return () => h('div', first)
5+
}
6+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
---
2+
source: crates/biome_js_analyze/tests/spec_tests.rs
3+
expression: array_destructuring.js
4+
---
5+
# Input
6+
```js
7+
// Array destructuring in parameters
8+
export default {
9+
setup([first, second]) {
10+
return () => h('div', first)
11+
}
12+
}
13+
14+
```
15+
16+
# Diagnostics
17+
```
18+
array_destructuring.js:3:9 lint/nursery/noSetupPropsReactivityLoss ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
19+
20+
i Destructuring props in setup function parameters causes reactivity loss.
21+
22+
1 │ // Array destructuring in parameters
23+
2 │ export default {
24+
> 3setup([first, second]) {
25+
│ ^^^^^^^^^^^^^^^
26+
4 │ return () => h('div', first)
27+
5 │ }
28+
29+
i Use the props object directly to maintain reactivity: `props.propertyName`
30+
31+
32+
```
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
// Destructuring assignment
2+
export default {
3+
setup(props) {
4+
const { count } = props
5+
return () => h('div', count)
6+
}
7+
}

0 commit comments

Comments
 (0)