Skip to content

Commit e039f3b

Browse files
AsherDedyc3
andauthored
feat(lint): implement vue/noSetupPropsReactivityLoss (#7513)
Co-authored-by: Carson McManus <[email protected]>
1 parent 9cf2332 commit e039f3b

File tree

19 files changed

+646
-55
lines changed

19 files changed

+646
-55
lines changed

.changeset/yummy-melons-serve.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
"@biomejs/biome": patch
3+
---
4+
5+
Added the nursery rule [`noVueSetupPropsReactivityLoss`](https://biomejs.dev/linter/rules/no-vue-setup-props-reactivity-loss/).
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_cli/src/execute/migrate/eslint_any_rule_to_biome.rs

Lines changed: 16 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: 75 additions & 54 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: 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
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,7 @@ define_categories! {
201201
"lint/nursery/noVueReservedKeys": "https://biomejs.dev/linter/rules/no-vue-reserved-keys",
202202
"lint/nursery/noVueReservedProps": "https://biomejs.dev/linter/rules/no-vue-reserved-props",
203203
"lint/nursery/noVueVIfWithVFor": "https://biomejs.dev/linter/rules/no-vue-v-if-with-v-for",
204+
"lint/nursery/noVueSetupPropsReactivityLoss": "https://biomejs.dev/linter/rules/no-vue-setup-props-reactivity-loss",
204205
"lint/nursery/useAnchorHref": "https://biomejs.dev/linter/rules/use-anchor-href",
205206
"lint/nursery/useArraySortCompare": "https://biomejs.dev/linter/rules/use-array-sort-compare",
206207
"lint/nursery/useBiomeSuppressionComment": "https://biomejs.dev/linter/rules/use-biome-suppression-comment",

crates/biome_js_analyze/src/lint/nursery.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ pub mod no_vue_data_object_declaration;
3333
pub mod no_vue_duplicate_keys;
3434
pub mod no_vue_reserved_keys;
3535
pub mod no_vue_reserved_props;
36+
pub mod no_vue_setup_props_reactivity_loss;
3637
pub mod use_array_sort_compare;
3738
pub mod use_consistent_arrow_return;
3839
pub mod use_exhaustive_switch_cases;
@@ -46,4 +47,4 @@ pub mod use_sorted_classes;
4647
pub mod use_spread;
4748
pub mod use_vue_define_macros_order;
4849
pub mod use_vue_multi_word_component_names;
49-
declare_lint_group! { pub Nursery { name : "nursery" , rules : [self :: no_continue :: NoContinue , self :: no_deprecated_imports :: NoDeprecatedImports , self :: no_duplicated_spread_props :: NoDuplicatedSpreadProps , self :: no_empty_source :: NoEmptySource , self :: no_equals_to_null :: NoEqualsToNull , 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_multi_str :: NoMultiStr , self :: no_next_async_client_component :: NoNextAsyncClientComponent , self :: no_parameters_only_used_in_recursion :: NoParametersOnlyUsedInRecursion , self :: no_proto :: NoProto , self :: no_react_forward_ref :: NoReactForwardRef , self :: no_shadow :: NoShadow , self :: no_sync_scripts :: NoSyncScripts , self :: no_ternary :: NoTernary , 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_regexp_exec :: UseRegexpExec , self :: use_sorted_classes :: UseSortedClasses , self :: use_spread :: UseSpread , self :: use_vue_define_macros_order :: UseVueDefineMacrosOrder , self :: use_vue_multi_word_component_names :: UseVueMultiWordComponentNames ,] } }
50+
declare_lint_group! { pub Nursery { name : "nursery" , rules : [self :: no_continue :: NoContinue , self :: no_deprecated_imports :: NoDeprecatedImports , self :: no_duplicated_spread_props :: NoDuplicatedSpreadProps , self :: no_empty_source :: NoEmptySource , self :: no_equals_to_null :: NoEqualsToNull , 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_multi_str :: NoMultiStr , self :: no_next_async_client_component :: NoNextAsyncClientComponent , self :: no_parameters_only_used_in_recursion :: NoParametersOnlyUsedInRecursion , self :: no_proto :: NoProto , self :: no_react_forward_ref :: NoReactForwardRef , self :: no_shadow :: NoShadow , self :: no_sync_scripts :: NoSyncScripts , self :: no_ternary :: NoTernary , 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 :: no_vue_setup_props_reactivity_loss :: NoVueSetupPropsReactivityLoss , 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_regexp_exec :: UseRegexpExec , self :: use_sorted_classes :: UseSortedClasses , self :: use_spread :: UseSpread , self :: use_vue_define_macros_order :: UseVueDefineMacrosOrder , self :: use_vue_multi_word_component_names :: UseVueMultiWordComponentNames ,] } }
Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
use crate::frameworks::vue::vue_component::{AnyPotentialVueComponent, VueComponentQuery};
2+
use biome_analyze::{
3+
Rule, RuleDiagnostic, RuleDomain, RuleSource, context::RuleContext, declare_lint_rule,
4+
};
5+
use biome_console::markup;
6+
use biome_js_syntax::{
7+
AnyJsArrowFunctionParameters, AnyJsBindingPattern, AnyJsExpression, AnyJsFunction,
8+
AnyJsObjectMember, AnyJsObjectMemberName, JsCallExpression, JsMethodObjectMember,
9+
JsObjectMemberList, JsParameters,
10+
};
11+
use biome_rowan::{AstNode, AstSeparatedList, TextRange};
12+
13+
declare_lint_rule! {
14+
/// Disallow destructuring of `props` passed to `setup` in Vue projects.
15+
///
16+
/// In Vue's Composition API, props must be accessed as `props.propertyName` to maintain
17+
/// reactivity. Destructuring `props` directly in the `setup` function parameters will
18+
/// cause the resulting variables to lose their reactive nature.
19+
///
20+
/// ## Examples
21+
///
22+
/// ### Invalid
23+
///
24+
/// ```js,expect_diagnostic
25+
/// export default {
26+
/// setup({ count }) {
27+
/// return () => h('div', count);
28+
/// }
29+
/// }
30+
/// ```
31+
///
32+
/// ### Valid
33+
///
34+
/// ```js
35+
/// export default {
36+
/// setup(props) {
37+
/// return () => h('div', props.count);
38+
/// }
39+
/// }
40+
/// ```
41+
///
42+
pub NoVueSetupPropsReactivityLoss {
43+
version: "2.2.6",
44+
name: "noVueSetupPropsReactivityLoss",
45+
language: "js",
46+
domains: &[RuleDomain::Vue],
47+
recommended: false,
48+
sources: &[RuleSource::EslintVueJs("no-setup-props-reactivity-loss").inspired()],
49+
}
50+
}
51+
52+
pub struct Violation(TextRange);
53+
54+
impl Violation {
55+
fn range(&self) -> TextRange {
56+
self.0
57+
}
58+
}
59+
60+
enum SetupFunction {
61+
Function(AnyJsFunction),
62+
Method(JsMethodObjectMember),
63+
}
64+
65+
impl Rule for NoVueSetupPropsReactivityLoss {
66+
type Query = VueComponentQuery;
67+
type State = Violation;
68+
type Signals = Vec<Self::State>;
69+
type Options = ();
70+
71+
fn run(ctx: &RuleContext<Self>) -> Self::Signals {
72+
match ctx.query() {
73+
// Case: export default { setup(props) { ... } }
74+
AnyPotentialVueComponent::JsExportDefaultExpressionClause(export) => {
75+
let Some(expr) = export.expression().ok() else {
76+
return vec![];
77+
};
78+
let Some(obj_expr) = expr.as_js_object_expression() else {
79+
return vec![];
80+
};
81+
check_object_members(&obj_expr.members())
82+
}
83+
// Case: export default defineComponent({ setup(props) { ... } })
84+
AnyPotentialVueComponent::JsCallExpression(call_expr) => {
85+
check_call_expression_setup(call_expr)
86+
}
87+
}
88+
}
89+
90+
fn diagnostic(_ctx: &RuleContext<Self>, state: &Self::State) -> Option<RuleDiagnostic> {
91+
Some(
92+
RuleDiagnostic::new(
93+
rule_category!(),
94+
state.range(),
95+
markup! {
96+
"Destructuring `props` in the `setup` function parameters loses reactivity."
97+
},
98+
)
99+
.note(markup! {
100+
"To preserve reactivity, access props as properties: `props.propertyName`."
101+
}),
102+
)
103+
}
104+
}
105+
106+
fn check_call_expression_setup(call_expr: &JsCallExpression) -> Vec<Violation> {
107+
if let Ok(args) = call_expr.arguments()
108+
&& let Some(Ok(arg)) = args.args().iter().next()
109+
&& let Some(expr) = arg.as_any_js_expression()
110+
&& let Some(obj_expr) = expr.as_js_object_expression()
111+
{
112+
check_object_members(&obj_expr.members())
113+
} else {
114+
vec![]
115+
}
116+
}
117+
118+
fn check_object_members(members: &JsObjectMemberList) -> Vec<Violation> {
119+
members
120+
.iter()
121+
.filter_map(|m| m.ok())
122+
.filter_map(|member| find_setup_function(&member))
123+
.filter_map(|setup| check_setup_params(&setup))
124+
.collect()
125+
}
126+
127+
fn check_setup_params(setup_fn: &SetupFunction) -> Option<Violation> {
128+
let first_param = get_first_parameter(setup_fn)?;
129+
130+
match first_param {
131+
AnyJsBindingPattern::JsObjectBindingPattern(obj) => Some(Violation(obj.range())),
132+
AnyJsBindingPattern::JsArrayBindingPattern(arr) => Some(Violation(arr.range())),
133+
AnyJsBindingPattern::AnyJsBinding(_) => None,
134+
}
135+
}
136+
137+
fn get_first_parameter(setup_fn: &SetupFunction) -> Option<AnyJsBindingPattern> {
138+
match setup_fn {
139+
SetupFunction::Method(method) => {
140+
let params = method.parameters().ok()?;
141+
get_first_binding_from_params(&params)
142+
}
143+
SetupFunction::Function(func) => get_function_first_parameter(func),
144+
}
145+
}
146+
147+
fn get_function_first_parameter(func: &AnyJsFunction) -> Option<AnyJsBindingPattern> {
148+
match func {
149+
AnyJsFunction::JsArrowFunctionExpression(arrow) => match arrow.parameters().ok()? {
150+
AnyJsArrowFunctionParameters::AnyJsBinding(binding) => {
151+
Some(AnyJsBindingPattern::AnyJsBinding(binding))
152+
}
153+
AnyJsArrowFunctionParameters::JsParameters(params) => {
154+
get_first_binding_from_params(&params)
155+
}
156+
},
157+
AnyJsFunction::JsFunctionDeclaration(decl) => {
158+
get_first_binding_from_params(&decl.parameters().ok()?)
159+
}
160+
AnyJsFunction::JsFunctionExpression(expr) => {
161+
get_first_binding_from_params(&expr.parameters().ok()?)
162+
}
163+
_ => None,
164+
}
165+
}
166+
167+
fn find_setup_function(member: &AnyJsObjectMember) -> Option<SetupFunction> {
168+
match member {
169+
AnyJsObjectMember::JsMethodObjectMember(method) => method
170+
.name()
171+
.ok()
172+
.filter(is_named_setup)
173+
.map(|_| SetupFunction::Method(method.clone())),
174+
AnyJsObjectMember::JsPropertyObjectMember(property) => {
175+
let name = property.name().ok()?;
176+
if !is_named_setup(&name) {
177+
return None;
178+
}
179+
let value = property.value().ok()?;
180+
let func = get_function_from_expression(&value)?;
181+
Some(SetupFunction::Function(func))
182+
}
183+
_ => None,
184+
}
185+
}
186+
187+
fn get_function_from_expression(expr: &AnyJsExpression) -> Option<AnyJsFunction> {
188+
match expr {
189+
AnyJsExpression::JsFunctionExpression(func) => {
190+
Some(AnyJsFunction::JsFunctionExpression(func.clone()))
191+
}
192+
AnyJsExpression::JsArrowFunctionExpression(arrow) => {
193+
Some(AnyJsFunction::JsArrowFunctionExpression(arrow.clone()))
194+
}
195+
_ => None,
196+
}
197+
}
198+
199+
fn is_named_setup(name: &AnyJsObjectMemberName) -> bool {
200+
name.name().is_some_and(|text| text.text() == "setup")
201+
}
202+
203+
fn get_first_binding_from_params(params: &JsParameters) -> Option<AnyJsBindingPattern> {
204+
params
205+
.items()
206+
.iter()
207+
.next()?
208+
.ok()?
209+
.as_any_js_formal_parameter()?
210+
.as_js_formal_parameter()?
211+
.binding()
212+
.ok()
213+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
// Parameter destructuring patterns that lose reactivity
2+
3+
// Basic parameter destructuring
4+
export default {
5+
setup({ foo, bar }) {
6+
return () => h('div', foo + bar)
7+
}
8+
}
9+
10+
// Destructuring with default values
11+
export default {
12+
setup({ foo = 'default', bar }) {
13+
return () => h('div', foo + bar)
14+
}
15+
}
16+
17+
// Destructuring with renaming
18+
export default {
19+
setup({ foo: renamedFoo, bar }) {
20+
return () => h('div', renamedFoo + bar)
21+
}
22+
}
23+
24+
// Destructuring with rest pattern
25+
export default {
26+
setup({ foo, ...rest }) {
27+
return () => h('div', foo + rest.bar)
28+
}
29+
}
30+
31+
// defineComponent with parameter destructuring
32+
export default defineComponent({
33+
setup({ foo, bar }) {
34+
return () => h('div', foo + bar)
35+
}
36+
})
37+
38+
// Named export with parameter destructuring
39+
export const MyComponent = {
40+
setup({ foo, bar }) {
41+
return () => h('div', foo + bar)
42+
}
43+
}

0 commit comments

Comments
 (0)